diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f80f8d9..defe046 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: run: cargo build --all-targets --all-features --verbose - name: Run tests - run: cargo test --verbose + run: cargo test --verbose --all-features - name: Run clippy run: cargo clippy --all-targets --all-features diff --git a/.gitignore b/.gitignore index f039371..d7fb467 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,4 @@ TAGS *.db src/target_tmp +downloads/ diff --git a/Cargo.lock b/Cargo.lock index 6b4540f..c4a2c76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,28 @@ dependencies = [ "memchr", ] +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android-activity" version = "0.5.2" @@ -142,7 +164,7 @@ dependencies = [ "jni-sys", "libc", "log", - "ndk", + "ndk 0.8.0", "ndk-context", "ndk-sys 0.5.0+25.2.9519653", "num_enum", @@ -911,6 +933,46 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.9.0", + "ndk-context", + "num-derive", + "num-traits", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -920,6 +982,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -966,6 +1043,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "derivative" version = "2.2.0" @@ -2055,6 +2138,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2219,6 +2311,20 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -2261,6 +2367,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2382,6 +2499,43 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.10.0", + "libc", + "objc2 0.6.3", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.3", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", +] + [[package]] name = "objc2-core-data" version = "0.2.2" @@ -2877,8 +3031,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -2888,7 +3052,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -2900,6 +3074,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "range-alloc" version = "0.1.4" @@ -3095,6 +3278,8 @@ dependencies = [ "aes", "bytemuck", "byteorder", + "bytes", + "cpal", "ctr", "eframe", "egui", @@ -3102,9 +3287,10 @@ dependencies = [ "opencv", "openh264", "openssl", - "rand", + "rand 0.8.5", "rustls", "rustls-pemfile", + "sctp-proto", "sha1", "sha2", "wgpu 27.0.1", @@ -3153,6 +3339,21 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "sctp-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "572b7e45d72e65e8f5ab350f06c205f5cd2a9bb12642d5f087870c8fdd47a331" +dependencies = [ + "bytes", + "crc", + "log", + "rand 0.9.2", + "rustc-hash 2.1.1", + "slab", + "thiserror 2.0.17", +] + [[package]] name = "semver" version = "1.0.27" @@ -4296,6 +4497,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.58.0" @@ -4336,6 +4547,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -4455,6 +4676,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -4819,7 +5049,7 @@ dependencies = [ "libc", "log", "memmap2", - "ndk", + "ndk 0.8.0", "ndk-sys 0.5.0+25.2.9519653", "objc2 0.4.1", "once_cell", @@ -5000,7 +5230,7 @@ dependencies = [ "nix", "once_cell", "ordered-stream", - "rand", + "rand 0.8.5", "serde", "serde_repr", "sha1", diff --git a/Cargo.toml b/Cargo.toml index 15b93b6..846fe7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,9 @@ ctr = "0.9" hmac = "0.12" sha1 = "0.10" byteorder = "1.5" +sctp-proto = "0.6.0" +bytes = "1.0" +cpal = "0.16.0" [features] default = ["log-info"] # Default to Info, Warn, Error @@ -28,6 +31,7 @@ log-debug = ["log-info"] # Debug implies Info log-info = ["log-warn"] # Info implies Warn log-warn = ["log-error"] # Warn implies Error log-error = [] +sctp-transfer-debug = [] # Detailed SCTP/File transfer logs [lints.clippy] diff --git a/Informe FINAL Agregado Room RTC 2025.pdf b/Informe FINAL Agregado Room RTC 2025.pdf new file mode 100644 index 0000000..e09897c Binary files /dev/null and b/Informe FINAL Agregado Room RTC 2025.pdf differ diff --git a/Informe Final Room RTC 2025.pdf b/Informe Final Room RTC 2025.pdf new file mode 100644 index 0000000..db08a20 Binary files /dev/null and b/Informe Final Room RTC 2025.pdf differ diff --git a/README.md b/README.md index 021c495..eb293d1 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,21 @@ # rustyrtc -A small, WebRTC/RTC engine written in Rust. It includes SDP parsing, ICE, RTP/RTCP, a media pipeline (encode/decode/packetize/depacketize), a camera manager, a logger, and a minimal GUI app for local testing. +A robust WebRTC/RTC engine written in Rust. It includes SDP parsing, ICE, DTLS, SRTP, a full media pipeline (encode/decode/packetize/depacketize), a congestion controller, a custom signaling solution, and a GUI app for testing. -> **Status:** actively evolving; latest stable slice includes the modules listed below. +> **Status:** actively evolving; supports secure media transport and custom signaling. -## Features (high level) +## Features (High Level) -* **SDP module** – parse/build offers & answers, rtpmap/fmtp helpers. -* **ICE module** – role selection, ufrag/pwd, candidate handling, pairing. -* **RTP module** – packet/headers, payload handling (H.264), packetizer. -* **RTCP module** – SR/RR, NACK/PLI basics. -* **RTP Session module** – SSRC streams, jitter/seq, (de)packetization glue. -* **Camera Manager** – capture frames from a local camera. -* **Connection Manager** – wiring between signaling, ICE, and media. -* **Media Agent** – encode/decode + track management (H.264, access units). -* **Core module** – events bus, session orchestration. -* **Logger** – bounded, non-blocking channel + simple macros. -* **App/GUI module** – minimal desktop app to exercise the engine. +* **SDP module** – Parse/build offers & answers, rtpmap/fmtp helpers. +* **ICE module** – Connectivity checks, role selection, candidate gathering/pairing. +* **DTLS module** – Secure handshake and key derivation (wrapping OpenSSL). +* **SRTP module** – Secure Real-time Transport Protocol (AES/HMAC encryption for media). +* **Congestion Controller** – Bandwidth estimation and flow control. +* **RTP/RTCP modules** – Packet handling, headers, SR/RR reports, NACKs/PLI. +* **Media Transport** – Event loops for packetization/depacketization and media flow. +* **Signaling** – dedicated Server (`signaling_server`) and Client (`signaling_client`) implementation. +* **Camera Manager** – Capture frames from local devices via OpenCV. +* **App/GUI module** – `eframe/wgpu` based desktop app for testing calls. --- @@ -25,77 +24,18 @@ A small, WebRTC/RTC engine written in Rust. It includes SDP parsing, ICE, RTP/RT ### Prerequisites * Rust (stable) — install via [https://rustup.rs](https://rustup.rs) -* Linux/macOS/Windows are fine. -* H.264 via OpenH264. -* System OpenCV/Clang/LLVM dev packages. +* **OpenSSL** (dev packages) — Required for DTLS/SRTP. +* **OpenH264** — Required for video encoding/decoding. +* **OpenCV** — Required for camera capture. +* Clang/LLVM — Required for bindgen operations. ### Build & Run +#### 1. Build the project ```bash # clone -git clone https://github.com/taller-1-fiuba-rust/25C2-rustyrtc && cd rustyrtc +git clone [https://github.com/taller-1-fiuba-rust/25C2-rustyrtc](https://github.com/taller-1-fiuba-rust/25C2-rustyrtc) && cd rustyrtc -# build -cargo build - -# run the GUI app -cargo run -``` - - -> If your platform doesn’t have prebuilt binaries for OpenH264/OpenCV, you may need to install those libs from your package manager first. - ---- - -## Testing - -Run the whole test suite: - -```bash -cargo test -``` - -Lint with Clippy (treat warnings as errors) and check formatting: - -```bash -cargo clippy -cargo fmt --all -- --check -``` - -Run a single test (example): - -```bash -cargo test logger_handle -- --nocapture -``` - ---- - -## Project layout (overview) - -``` -src/ - app/ # App/GUI module (desktop harness, demo UI, logger) - core/ # Core events, session orchestration - connection_manager/ - ice_agent/ # ICE module - media_agent/ # Media Agent (encode/decode, tracks) - rtp/ # RTP packet, header, packetizer - rtcp/ # RTCP packets (SR/RR/PLI/NACK) - rtp_session/ # RTP session management (recv/send streams) - sdp/ # SDP parse/build helpers - camera_manager/ # Camera Manager -``` - ---- - -## Developer roster - -| Name | Email | -| -------------- | ---------------------- | -| *Tom Pinargote* | tpinargote@fi.uba.ar | -| *Nervo Olalla* | nolalla@fi.uba.ar | -| *Alexander Villa Jimenez* | avilla@fi.uba.ar | -| *Nico Cruz* | ncruz@fi.uba.ar | - ---- +# build (release is recommended for video performance) +cargo build --release diff --git a/client_default.conf b/client_default.conf index caa9b10..29d7750 100644 --- a/client_default.conf +++ b/client_default.conf @@ -5,7 +5,7 @@ log_level = "Error" [Signaling] # Address for the client to connect to the signaling server. Cannot be empty -server_address = "192.168.0.12:6000" +server_address = "192.168.0.12:7000" # TLS domain for self-signed certificate. When empty fallback to default = "signal.internal" tls_domain = "signal.internal" @@ -56,3 +56,6 @@ stun_request_timeout_secs = 2 # Maximum number of candidate pairs to check. Affects performance. max_candidate_pairs = 100 + +[file_handler] +storage_path = "" diff --git a/gatito.jpg b/gatito.jpg new file mode 100644 index 0000000..be07c1a Binary files /dev/null and b/gatito.jpg differ diff --git a/server_default.conf b/server_default.conf index d28d529..23195cd 100644 --- a/server_default.conf +++ b/server_default.conf @@ -5,7 +5,7 @@ log_level = "Error" [Signaling] # Address for the signaling server to listen on -listen_address = "192.168.0.12:6000" +listen_address = "192.168.0.12:7000" # TLS domain for self-signed certificate. When empty fallback to default = "signal.internal" tls_domain = "signal.internal" diff --git a/src/app/debug_yuv_to_rgb.rs b/src/app/debug_yuv_to_rgb.rs index b639ce0..f9015dc 100644 --- a/src/app/debug_yuv_to_rgb.rs +++ b/src/app/debug_yuv_to_rgb.rs @@ -77,7 +77,6 @@ fn convert_bt709_limited(y: u8, u: u8, v: u8) -> Rgb { } } - /// Prints a debug table of YUV to RGB conversion results using different standards. pub fn debug_yuv_to_rgb(y: u8, u: u8, v: u8) { println!("=== YUV→RGB Debug ==="); diff --git a/src/app/rtc_app.rs b/src/app/rtc_app.rs index 181c5aa..486894e 100644 --- a/src/app/rtc_app.rs +++ b/src/app/rtc_app.rs @@ -22,7 +22,11 @@ use eframe::{App, Frame, egui, egui_wgpu::RenderState}; use std::{ collections::VecDeque, io, - sync::{Arc, mpsc::TrySendError}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc::TrySendError, + }, time::Instant, }; @@ -51,6 +55,28 @@ enum CallFlow { }, } +#[derive(Debug, Clone)] +enum FileTransferState { + Idle, + RemoteOffered { + props: crate::sctp::events::SctpFileProperties, + }, + Sending { + id: u32, + filename: String, + progress: f32, + }, + Receiving { + id: u32, + filename: String, + total_size: usize, + progress: f32, + }, + Finished { + msg: String, + }, +} + /// The main application struct for the RoomRTC client. /// It holds the state for the GUI, the WebRTC engine, and the signaling client. pub struct RtcApp { @@ -106,6 +132,14 @@ pub struct RtcApp { //Network Metrics last_metrics: Option, current_bitrate: Option, + + // File Transfer + sending_files: Arc, + receiving_files: Arc, + file_transfer_state: FileTransferState, + file_path_input: String, + + is_muted: bool, } impl RtcApp { @@ -148,12 +182,20 @@ impl RtcApp { }, ); + let sending_files = Arc::new(AtomicBool::new(false)); + let receiving_files = Arc::new(AtomicBool::new(false)); + Self { remote_sdp_text: String::new(), local_sdp_text: String::new(), pending_remote_sdp: None, status_line: "Ready.".into(), - engine: Engine::new(logger_handle, config.clone()), + engine: Engine::new( + logger_handle, + config.clone(), + sending_files.clone(), + receiving_files.clone(), + ), has_remote_description: false, has_local_description: false, is_local_offerer: false, @@ -183,6 +225,11 @@ impl RtcApp { config, last_metrics: None, current_bitrate: None, + sending_files, + receiving_files, + file_transfer_state: FileTransferState::Idle, + file_path_input: String::new(), + is_muted: false, } } @@ -618,6 +665,11 @@ impl RtcApp { self.background_log(LogLevel::Info, &s); // keep a small echo in UI: self.push_ui_log(&s); + + if s.contains("File download complete") { + self.file_transfer_state = FileTransferState::Finished { msg: s.clone() }; + self.receiving_files.store(false, Ordering::SeqCst); + } } Established => { self.conn_state = ConnState::Running; @@ -662,6 +714,201 @@ impl RtcApp { // Update the bitrate being used by the Encoder self.current_bitrate = Some(bps); } + EngineEvent::ReceivedFileOffer(props) => { + self.status_line = + format!("File offer: {} ({})", props.file_name, props.file_size); + self.file_transfer_state = FileTransferState::RemoteOffered { props }; + // If we were busy, we might want to auto-reject? + // But for now assume one file at a time. + } + EngineEvent::ReceivedFileAccept(id) => { + self.status_line = format!("Peer accepted file (id: {id}). Sending..."); + // state is already Sending likely + } + EngineEvent::ReceivedFileReject(id) => { + self.status_line = format!("Peer rejected file (id: {id})."); + self.file_transfer_state = FileTransferState::Idle; + self.sending_files.store(false, Ordering::SeqCst); + } + EngineEvent::ReceivedFileCancel(id) => { + self.status_line = format!("File transfer cancelled (id: {id})."); + self.file_transfer_state = FileTransferState::Idle; + self.sending_files.store(false, Ordering::SeqCst); + self.receiving_files.store(false, Ordering::SeqCst); + } + EngineEvent::SendFileOffer(props) => { + // We initiated sending + self.file_transfer_state = FileTransferState::Sending { + id: props.transaction_id, + filename: props.file_name, + progress: 0.0, + }; + } + EngineEvent::SendFileChunk(..) + | EngineEvent::SendFileAccept(..) + | EngineEvent::SendFileReject(..) + | EngineEvent::SendFileCancel(..) => { + // Internal events, ignore + } + EngineEvent::ReceivedFileChunk(..) => { + // Internal + } + EngineEvent::SendFileEnd(_) => { + self.status_line = "File transfer finished (sent).".into(); + self.file_transfer_state = FileTransferState::Idle; + self.sending_files.store(false, Ordering::SeqCst); + } + EngineEvent::ReceivedFileEnd(_) => { + self.status_line = "File transfer finished (received).".into(); + self.file_transfer_state = FileTransferState::Idle; + self.receiving_files.store(false, Ordering::SeqCst); + } + EngineEvent::UploadProgress { id, current, total } => { + if let FileTransferState::Sending { + id: current_id, + progress, + .. + } = &mut self.file_transfer_state + { + if *current_id == id { + *progress = (current as f32 / total as f32) * 100.0; + } + } + } + EngineEvent::DownloadProgress { id, current } => { + if let FileTransferState::Receiving { + id: current_id, + progress, + total_size, + .. + } = &mut self.file_transfer_state + { + if *current_id == id && *total_size > 0 { + *progress = (current as f32 / *total_size as f32) * 100.0; + } + } + } + EngineEvent::ToggleAudio(muted) => { + self.is_muted = muted; + } + } + } + } + + fn render_file_transfer(&mut self, ui: &mut egui::Ui) { + ui.separator(); + ui.heading("File Transfer"); + + // Check atomic flags for active state + let sending = self.sending_files.load(Ordering::SeqCst); + let receiving = self.receiving_files.load(Ordering::SeqCst); + + // Debug info to diagnose button visibility issues + ui.collapsing("Debug State", |ui| { + ui.label(format!("ConnState: {:?}", self.conn_state)); + ui.label(format!("Sending: {}", sending)); + ui.label(format!("Receiving: {}", receiving)); + ui.label(format!("TransferState: {:?}", self.file_transfer_state)); + }); + + match &self.file_transfer_state { + FileTransferState::Idle => { + if matches!(self.conn_state, ConnState::Running) && !sending && !receiving { + ui.horizontal(|ui| { + ui.label("Path:"); + ui.text_edit_singleline(&mut self.file_path_input); + if ui.button("Send File").clicked() { + println!("[CLI DEBUG] Send File button clicked!"); // Force output to console + let path = self.file_path_input.trim().to_string(); + if !path.is_empty() { + self.background_log( + LogLevel::Info, + format!("[UI] User clicked Send File for path: {}", path), + ); + // Use a random ID or sequential + let id = rand::random::(); + self.engine.send_file(path, id); + self.status_line = "Preparing file...".into(); + // We wait for SendFileOffer event to switch state + } else { + self.background_log( + LogLevel::Warn, + "[UI] User clicked Send File but path is empty", + ); + } + } + }); + } else if sending || receiving { + ui.label("Transfer in progress..."); + if ui.button("Cancel").clicked() { + self.engine.cancel_file(0); + self.sending_files.store(false, Ordering::SeqCst); + self.receiving_files.store(false, Ordering::SeqCst); + } + } else { + ui.label("Connect to a peer to transfer files."); + } + } + FileTransferState::RemoteOffered { + props: remote_props, + } => { + ui.label(format!( + "Incoming file: {} ({} bytes)", + remote_props.file_name, remote_props.file_size + )); + let id_to_accept = remote_props.transaction_id; + let filename_to_receive = remote_props.file_name.clone(); + let filesize_to_receive = remote_props.file_size as usize; + + ui.horizontal(|ui| { + if ui.button("Accept").clicked() { + self.engine + .accept_file(id_to_accept, filename_to_receive.clone()); + self.file_transfer_state = FileTransferState::Receiving { + id: id_to_accept, + filename: filename_to_receive, + total_size: filesize_to_receive, + progress: 0.0, + }; + } + if ui.button("Reject").clicked() { + self.engine.reject_file(id_to_accept); + self.file_transfer_state = FileTransferState::Idle; + } + }); + } + FileTransferState::Sending { + id, + filename, + progress, + } => { + ui.label(format!("Sending {}... {:.1}%", filename, progress)); + ui.add(egui::ProgressBar::new(progress / 100.0)); + if ui.button("Cancel").clicked() { + self.engine.cancel_file(*id); + self.sending_files.store(false, Ordering::SeqCst); + self.file_transfer_state = FileTransferState::Idle; + } + } + FileTransferState::Receiving { + id, + filename, + progress, + .. + } => { + ui.label(format!("Receiving {}... {:.1}%", filename, progress)); + ui.add(egui::ProgressBar::new(progress / 100.0)); + if ui.button("Cancel").clicked() { + self.engine.cancel_file(*id); + self.receiving_files.store(false, Ordering::SeqCst); + self.file_transfer_state = FileTransferState::Idle; + } + } + FileTransferState::Finished { msg } => { + ui.label(msg); + if ui.button("OK").clicked() { + self.file_transfer_state = FileTransferState::Idle; + } } } } @@ -672,12 +919,6 @@ impl RtcApp { _local_frame: Option<&VideoFrame>, _remote_frame: Option<&VideoFrame>, ) { - sink_debug!( - self.logger.handle(), - "[UI] remote_camera_texture exists? {} (id={:?})", - self.remote_camera_texture.is_some(), - self.remote_camera_texture.map(|(id, _)| id) - ); // show the window if we are running OR we already have any texture let have_any_texture = self.local_camera_texture.is_some() || self.remote_camera_texture.is_some(); @@ -917,6 +1158,13 @@ impl RtcApp { { self.teardown_call(Some("stopped".into()), true); } + + let mute_label = if self.is_muted { "Unmute" } else { "Mute" }; + if ui.button(mute_label).clicked() { + self.is_muted = !self.is_muted; + self.engine.set_audio_mute(self.is_muted); + } + ui.label(format!("State: {:?}", self.conn_state)); }); } @@ -971,7 +1219,7 @@ impl RtcApp { ); } } else { - self.background_log(LogLevel::Debug, "Skipping debug checks for non-RGB frames"); + //self.background_log(LogLevel::Trace, "Skipping debug checks for non-RGB frames"); } } } @@ -1062,17 +1310,24 @@ impl RtcApp { // 2) Tear down media (safe to call even if session never started) self.engine.stop(); + // Reset file transfer state + self.file_transfer_state = FileTransferState::Idle; + self.file_path_input.clear(); + self.sending_files.store(false, Ordering::SeqCst); + self.receiving_files.store(false, Ordering::SeqCst); + // 3) Re-initialize the Engine for the next call. - // The Engine (and its internal MediaTransport) consumes one-time resources (channels) - // during startup. To support a second call, we must create a fresh instance. let logger_handle = Arc::new(self.logger.handle()); - self.engine = Engine::new(logger_handle, self.config.clone()); + self.engine = Engine::new( + logger_handle, + self.config.clone(), + self.sending_files.clone(), + self.receiving_files.clone(), + ); // 4) Reset call-related state self.call_flow = CallFlow::Idle; - // Since we dropped the old engine, we will never receive its "Closed" event, - // so we must force the state to Idle to enable the "Start Connection" button. self.conn_state = ConnState::Idle; self.pending_remote_sdp = None; @@ -1172,6 +1427,7 @@ impl App for RtcApp { return; } Self::render_video_summary(ui, local_frame.as_ref(), remote_frame.as_ref()); + self.render_file_transfer(ui); self.render_network_stats(ui); self.render_connection_controls(ui); self.render_status_line(ui); diff --git a/src/config.rs b/src/config.rs index af7af14..2364fba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,7 @@ +//! Configuration management module. +//! +//! Handles loading and parsing of INI-style configuration files. + use std::collections::HashMap; use std::fs; @@ -13,9 +17,14 @@ pub struct Config { impl Config { /// Loads a configuration from a file. /// + /// The file format is a simple INI-style format. + /// Lines starting with `#` are comments. + /// Sections are denoted by `[section_name]`. + /// Key-value pairs are `key = value`. + /// /// # Errors /// - /// Returns an error if the file cannot be read. + /// Returns an error string if the file cannot be read or accessed. pub fn load(path: &str) -> Result { let content = fs::read_to_string(path).map_err(|e| format!("Error reading file {path}: {e}"))?; diff --git a/src/connection_manager/config.rs b/src/connection_manager/config.rs index 8c47e7e..77b6850 100644 --- a/src/connection_manager/config.rs +++ b/src/connection_manager/config.rs @@ -6,4 +6,4 @@ pub(super) const DEFAULT_FMT: &str = "96"; pub(super) const DEFAULT_NET_TYPE: &str = "IN"; pub(super) const DEFAULT_ADDR_TYPE: SDPAddrType = SDPAddrType::IP4; pub(super) const DEFAULT_CONN_ADDR: &str = "0.0.0.0"; -pub(super) const DEFAULT_MEDIA_KIND: SDPMediaKind = SDPMediaKind::Video; +pub(super) const _DEFAULT_MEDIA_KIND: SDPMediaKind = SDPMediaKind::Video; diff --git a/src/connection_manager/connection_manager.rs b/src/connection_manager/connection_manager.rs index c0abfb8..b150ee0 100644 --- a/src/connection_manager/connection_manager.rs +++ b/src/connection_manager/connection_manager.rs @@ -4,24 +4,26 @@ use super::{ }; use crate::config::Config; use crate::connection_manager::config::{ - DEFAULT_ADDR_TYPE, DEFAULT_CONN_ADDR, DEFAULT_FMT, DEFAULT_MEDIA_KIND, DEFAULT_NET_TYPE, - DEFAULT_PORT, DEFAULT_PROTO, + DEFAULT_ADDR_TYPE, DEFAULT_CONN_ADDR, DEFAULT_FMT, DEFAULT_NET_TYPE, DEFAULT_PORT, + DEFAULT_PROTO, }; use crate::connection_manager::ice_worker::IceWorker; use crate::ice::gathering_service; use crate::ice::type_ice::ice_agent::{IceAgent, IceRole}; use crate::log::log_sink::LogSink; +use crate::media_agent::spec::MediaType; use crate::media_transport::codec::CodecDescriptor; use crate::rtp_session::rtp_codec::RtpCodec; use crate::sdp::attribute::Attribute as SDPAttribute; use crate::sdp::connection::Connection as SDPConnection; use crate::sdp::media::Media as SDPMedia; +use crate::sdp::media::MediaKind; use crate::sdp::origin::Origin as SDPOrigin; use crate::sdp::port_spec::PortSpec as SDPPortSpec; use crate::sdp::sdpc::Sdp; use crate::sdp::time_desc::TimeDesc as SDPTimeDesc; -use crate::sink_error; use crate::tls_utils::get_local_fingerprint_sha256; +use crate::{sink_error, sink_info}; use std::collections::HashSet; use std::{ io::ErrorKind, @@ -104,6 +106,11 @@ impl ConnectionManager { match self.signaling { SignalingState::Stable => { let offer = self.build_local_sdp(); + sink_info!( + &self.logger_handle, + "Generated Local SDP Offer:\n{}", + offer.encode() + ); self.local_description = Some(offer.clone()); self.signaling = SignalingState::HaveLocalOffer; self.set_ice_role_from_signaling(true, false); @@ -129,6 +136,7 @@ impl ConnectionManager { /// - If SDP parsing fails /// - If negotiation state is invalid pub fn apply_remote_sdp(&mut self, remote: &str) -> Result { + sink_info!(&self.logger_handle, "Received Remote SDP:\n{}", remote); let sdp = Sdp::parse(remote).map_err(ConnectionError::Sdp)?; let out = match self.signaling { SignalingState::Stable => { @@ -140,6 +148,11 @@ impl ConnectionManager { self.signaling = SignalingState::HaveRemoteOffer; let answer = self.build_local_sdp(); + sink_info!( + &self.logger_handle, + "Generated Local SDP Answer:\n{}", + answer.encode() + ); self.local_description = Some(answer.clone()); self.set_ice_role_from_signaling(false, remote_is_ice_lite); @@ -251,7 +264,45 @@ impl ConnectionManager { /// Constructs a local SDP description (offer or answer) based on current local codecs and ICE info. fn build_local_sdp(&mut self) -> Sdp { - let media: Vec = vec![self.build_media_description()]; + // Gather candidates once to avoid duplication side-effects + let candidates_attrs = get_local_candidates_as_attributes(self); + + // Group codecs by MediaType + let mut audio_codecs = Vec::new(); + let mut video_codecs = Vec::new(); + + for c in &self.local_codecs { + match c.spec.media_type() { + MediaType::Audio => audio_codecs.push(c.clone()), + MediaType::Video => video_codecs.push(c.clone()), + } + } + + let mut media = Vec::new(); + + // Add Audio m-line if present + if !audio_codecs.is_empty() { + media.push(self.build_media_description( + MediaType::Audio, + &audio_codecs, + &candidates_attrs, + )); + } + + // Add Video m-line if present + if !video_codecs.is_empty() { + media.push(self.build_media_description( + MediaType::Video, + &video_codecs, + &candidates_attrs, + )); + } + + // Fallback: if no codecs found (e.g. init), default to Video + if media.is_empty() { + media.push(self.build_media_description(MediaType::Video, &[], &candidates_attrs)); + } + Sdp::new( 0, SDPOrigin::new_blank(), @@ -456,16 +507,25 @@ impl ConnectionManager { } /// Builds a media description SDP with ICE candidates, codecs, and connection info. - fn build_media_description(&mut self) -> SDPMedia { + fn build_media_description( + &mut self, + media_type: MediaType, + codecs: &[CodecDescriptor], + candidates: &[SDPAttribute], + ) -> SDPMedia { let mut media_desc = SDPMedia::new_blank(); - media_desc.set_kind(DEFAULT_MEDIA_KIND); + let kind = match media_type { + MediaType::Audio => MediaKind::Audio, + MediaType::Video => MediaKind::Video, + }; + media_desc.set_kind(kind); media_desc.set_port(SDPPortSpec::new(DEFAULT_PORT, None)); media_desc.set_proto(DEFAULT_PROTO); - let formats = if self.local_codecs.is_empty() { + let formats = if codecs.is_empty() { vec![DEFAULT_FMT.to_owned()] } else { - self.local_codecs + codecs .iter() .map(|c| c.rtp_representation.payload_type.to_string()) .collect() @@ -477,7 +537,10 @@ impl ConnectionManager { DEFAULT_CONN_ADDR, ))); - let mut attrs = get_local_candidates_as_attributes(self); + let mut attrs = Vec::new(); + // Add candidates + attrs.extend_from_slice(candidates); + let (ufrag, pwd) = self.ice_agent.local_credentials(); attrs.push(SDPAttribute::new("ice-ufrag", ufrag)); attrs.push(SDPAttribute::new("ice-pwd", pwd)); @@ -495,16 +558,20 @@ impl ConnectionManager { attrs.push(SDPAttribute::new("setup", Some("active".into()))); } - if self.local_codecs.is_empty() { + if codecs.is_empty() { + // Default fallback if absolutely no codecs provided attrs.push(SDPAttribute::new( "rtpmap", Some("96 H264/90000".to_owned()), )); } else { - for descriptor in &self.local_codecs { + for descriptor in codecs { let codec = &descriptor.rtp_representation; let name = if codec.name.is_empty() { - "H264" + match media_type { + MediaType::Audio => "PCMU", + MediaType::Video => "H264", + } } else { &codec.name }; diff --git a/src/core/engine.rs b/src/core/engine.rs index 36e7f58..96617e1 100644 --- a/src/core/engine.rs +++ b/src/core/engine.rs @@ -1,9 +1,16 @@ +//! Core WebRTC Engine module. +//! +//! The [`Engine`] struct is the main entry point for managing a WebRTC session, +//! orchestrating signaling, ICE, DTLS, and media transport. + use std::{ net::SocketAddr, sync::{ Arc, Mutex, + atomic::{AtomicBool, Ordering}, mpsc::{self, Receiver, Sender}, }, + thread, time::{Duration, Instant}, }; @@ -13,14 +20,16 @@ use crate::{ connection_manager::{ConnectionManager, OutboundSdp, connection_error::ConnectionError}, core::{ events::EngineEvent, - session::{Session, SessionConfig}, + session::{Session, SessionConfig, SessionInitArgs}, }, dtls::{self, DtlsRole}, + file_handler::{FileHandler, events::FileHandlerEvents}, ice::type_ice::ice_agent::IceRole, log::log_sink::LogSink, media_agent::video_frame::VideoFrame, media_transport::{MediaTransport, media_transport_event::MediaTransportEvent}, - sink_debug, sink_info, sink_trace, + sctp::events::SctpEvents, + sink_debug, sink_error, sink_info, sink_trace, }; use super::constants::{MAX_BITRATE, MIN_BITRATE}; @@ -38,11 +47,19 @@ pub struct Engine { media_transport: MediaTransport, congestion_controller: CongestionController, config: Arc, + file_handler: Arc>>>, + sending_files: Arc, + receiving_files: Arc, } impl Engine { /// Creates a new `Engine` instance. - pub fn new(logger_sink: Arc, config: Arc) -> Self { + pub fn new( + logger_sink: Arc, + config: Arc, + sending_files: Arc, + receiving_files: Arc, + ) -> Self { let (ui_tx, ui_rx) = mpsc::channel(); let (event_tx, event_rx) = mpsc::channel(); let media_transport = @@ -97,6 +114,9 @@ impl Engine { congestion_controller, ui_rx, config, + file_handler: Arc::new(Mutex::new(None)), + sending_files, + receiving_files, } } @@ -157,6 +177,10 @@ impl Engine { /// # Errors /// /// Returns a `String` error if no nominated ICE pair is available. + /// + /// # Panics + /// + /// Panics if the internal session lock is poisoned. #[allow(clippy::expect_used)] pub fn start(&mut self) -> Result<(), String> { let mut guard = self.session.lock().expect("session lock poisoned"); @@ -169,29 +193,137 @@ impl Engine { } /// Stops the WebRTC session. + /// + /// # Panics + /// + /// Panics if the internal session lock is poisoned. #[allow(clippy::expect_used)] pub fn stop(&mut self) { if let Some(sess) = self.session.lock().expect("session lock poisoned").as_mut() { sess.request_close(); } self.media_transport.stop(); + // Stop file handler + if let Ok(mut fh_guard) = self.file_handler.lock() { + if let Some(fh) = fh_guard.as_ref() { + // Reset flags + self.sending_files.store(false, Ordering::SeqCst); + self.receiving_files.store(false, Ordering::SeqCst); + // Shutdown + fh.shutdown(); + } + *fh_guard = None; + } } /// Closes the WebRTC session and resets the connection manager. + /// + /// # Panics + /// + /// Panics if the internal session lock is poisoned. #[allow(clippy::expect_used)] pub fn close_session(&mut self) { let mut guard = self.session.lock().expect("session lock poisoned"); *guard = None; - // This ensures cm.ice_agent.get_data_channel_socket() returns Err/None - // in the next poll() loop, preventing the zombie DTLS handshake. self.cm.reset(); sink_debug!( self.logger_sink, "[Engine] Session closed and ConnectionManager reset." ); + // Reset file handler + if let Ok(mut fh) = self.file_handler.lock() { + *fh = None; + } + } + + pub fn send_file(&self, path: String, id: u32) { + println!( + "[CLI DEBUG] Engine::send_file called path={} id={}", + path, id + ); + sink_info!( + self.logger_sink, + "[Engine] send_file called for path: {} (id: {})", + path, + id + ); + if let Ok(fh_guard) = self.file_handler.lock() { + if let Some(fh) = fh_guard.as_ref() { + sink_info!( + self.logger_sink, + "[Engine] FileHandler found, sending ReadFile event" + ); + self.sending_files.store(true, Ordering::SeqCst); + if let Err(e) = fh.send(FileHandlerEvents::ReadFile { path, id }) { + sink_error!( + self.logger_sink, + "[Engine] Failed to send ReadFile event to FileHandler: {}", + e + ); + } + } else { + sink_error!( + self.logger_sink, + "[Engine] FileHandler is None in send_file!" + ); + } + } else { + sink_error!( + self.logger_sink, + "[Engine] Failed to lock FileHandler in send_file" + ); + } + } + + pub fn accept_file(&self, id: u32, filename: String) { + if let Ok(sess_guard) = self.session.lock() + && let Some(sess) = sess_guard.as_ref() + { + // We are receiving a file + self.receiving_files.store(true, Ordering::SeqCst); + sess.send_sctp_event(SctpEvents::SendAccept { id }); + } + // Notify local FileHandler to start writing + if let Ok(fh_guard) = self.file_handler.lock() + && let Some(fh) = fh_guard.as_ref() + { + let _ = fh.send(FileHandlerEvents::WriteFile { filename, id }); + } + } + + pub fn reject_file(&self, id: u32) { + if let Ok(sess_guard) = self.session.lock() + && let Some(sess) = sess_guard.as_ref() + { + sess.send_sctp_event(SctpEvents::SendReject { id }); + } + } + + pub fn cancel_file(&self, id: u32) { + // Cancel can be local sender cancelling, or local receiver cancelling + // Notify Session to send Cancel msg + if let Ok(sess_guard) = self.session.lock() + && let Some(sess) = sess_guard.as_ref() + { + sess.send_sctp_event(SctpEvents::SendCancel { id }); + } + // Also notify local FileHandler to stop + if let Ok(fh_guard) = self.file_handler.lock() + && let Some(fh) = fh_guard.as_ref() + { + let _ = fh.send(FileHandlerEvents::Cancel(id)); + } + } + + pub fn set_audio_mute(&mut self, mute: bool) { + self.media_transport.set_audio_mute(mute); } /// Polls for `EngineEvent`s and processes them. /// This method is called repeatedly to drive the engine's state. + /// + /// # Panics + /// + /// Panics if the internal session lock or file handler lock is poisoned. #[allow(clippy::expect_used)] pub fn poll(&mut self) -> Vec { // keep ICE reactive @@ -217,8 +349,6 @@ impl Engine { remote: peer, }); - // Matar al worker de ICE antes de DTLS --- - // Esto asegura que nadie más esté leyendo del socket. self.cm.stop_ice_worker(); // --- IceRole -> DtlsRole --- @@ -231,7 +361,8 @@ impl Engine { let remote_fp = self.cm.remote_fingerprint.clone(); // --- blocking DTLS handshake --- - let srtp_cfg = match dtls::run_dtls_handshake( + // Modified to destructure the tuple + match dtls::run_dtls_handshake( Arc::clone(&sock), peer, dtls_role, @@ -240,30 +371,83 @@ impl Engine { remote_fp, self.config.clone(), ) { - Ok(cfg) => Some(cfg), + Ok((srtp_cfg, ssl_stream)) => { + // Create FileHandler + let fh = Arc::new(FileHandler::new( + self.config.clone(), + self.logger_sink.clone(), + self.event_tx.clone(), + )); + *self.file_handler.lock().expect("fh lock") = Some(fh.clone()); + + // Spawn DrainChunks thread + let sending_files_clone = self.sending_files.clone(); + let fh_weak = Arc::downgrade(&fh); + let session_clone = self.session.clone(); + // Interval from config or default + let drain_interval_ms = self + .config + .get("file_handler", "drain_interval_ms") + .and_then(|s| s.parse().ok()) + .unwrap_or(1); + let drain_interval = Duration::from_millis(drain_interval_ms); + + thread::spawn(move || { + loop { + thread::sleep(drain_interval); + if sending_files_clone.load(Ordering::SeqCst) { + // Check buffered amount ONCE before the burst + let mut high_buffer = false; + if let Ok(guard) = session_clone.lock() { + if let Some(sess) = guard.as_ref() { + if sess.buffered_amount() > 512_000 { + high_buffer = true; + } + } + } + + if !high_buffer { + for _ in 0..20 { + if let Some(fh) = fh_weak.upgrade() { + if fh.send(FileHandlerEvents::DrainChunks).is_err() + { + return; + } + } else { + return; + } + } + } + } else if fh_weak.strong_count() == 0 { + break; + } + } + }); + + let sess = Session::new(SessionInitArgs { + sock: Arc::clone(&sock), + peer, + remote_codecs: self.cm.remote_codecs().clone(), + event_tx: self.event_tx.clone(), + logger: self.logger_sink.clone(), + cfg: SessionConfig { + handshake_timeout: Duration::from_secs(10), + resend_every: Duration::from_millis(250), + close_timeout: Duration::from_secs(5), + close_resend_every: Duration::from_millis(250), + }, + srtp_cfg: Some(srtp_cfg), + ssl_stream, + is_client: dtls_role == DtlsRole::Client, + }); + *self.session.lock().expect("session lock poisoned") = Some(sess); + } Err(e) => { let _ = self .event_tx .send(EngineEvent::Error(format!("DTLS handshake failed: {e}"))); - None // podrías también hacer `continue` para no crear sesión } }; - - let sess = Session::new( - Arc::clone(&sock), - peer, - self.cm.remote_codecs().clone(), - self.event_tx.clone(), - self.logger_sink.clone(), - SessionConfig { - handshake_timeout: Duration::from_secs(10), - resend_every: Duration::from_millis(250), - close_timeout: Duration::from_secs(5), - close_resend_every: Duration::from_millis(250), - }, - srtp_cfg, - ); - *self.session.lock().expect("session lock poisoned") = Some(sess); } } @@ -278,7 +462,7 @@ impl Engine { break; } match self.ui_rx.try_recv() { - Ok(ev) => match &ev { + Ok(ev) => match ev { EngineEvent::NetworkMetrics(m) => { self.congestion_controller.on_network_metrics(m.clone()); processed += 1; @@ -289,11 +473,72 @@ impl Engine { if let Some(media_transport_tx) = self.media_transport.media_transport_event_tx() { - let _ = - media_transport_tx.send(MediaTransportEvent::UpdateBitrate(*br)); + let _ = media_transport_tx.send(MediaTransportEvent::UpdateBitrate(br)); + } + processed += 1; + out.push(EngineEvent::UpdateBitrate(br)); + } + + EngineEvent::SendFileOffer(props) => { + if let Ok(sess_guard) = self.session.lock() + && let Some(sess) = sess_guard.as_ref() + { + sess.send_sctp_event(SctpEvents::SendOffer { + file_properties: props, + }); + } + } + EngineEvent::SendFileChunk(id, payload) => { + if let Ok(sess_guard) = self.session.lock() + && let Some(sess) = sess_guard.as_ref() + { + sess.send_sctp_event(SctpEvents::SendChunk { + file_id: id, + payload, + }); + } + } + EngineEvent::SendFileEnd(id) => { + if let Ok(sess_guard) = self.session.lock() + && let Some(sess) = sess_guard.as_ref() + { + sess.send_sctp_event(SctpEvents::SendEndFile { id }); + } + // Reset sending flag if no other files? For now simple reset. + self.sending_files.store(false, Ordering::SeqCst); + } + EngineEvent::ReceivedFileChunk(id, _seq, payload) => { + // Don't expose to UI, send to FileHandler + if let Ok(fh_guard) = self.file_handler.lock() + && let Some(fh) = fh_guard.as_ref() + { + let _ = fh.send(FileHandlerEvents::WriteChunk { id, payload }); } + } + EngineEvent::ReceivedFileEnd(id) => { + self.receiving_files.store(false, Ordering::SeqCst); + out.push(EngineEvent::ReceivedFileEnd(id)); + processed += 1; + } + EngineEvent::ReceivedFileOffer(props) => { + out.push(EngineEvent::ReceivedFileOffer(props)); + processed += 1; + } + EngineEvent::ReceivedFileAccept(id) => { + // Peer accepted our file. Notify FileHandler to start sending. + if let Ok(fh_guard) = self.file_handler.lock() + && let Some(fh) = fh_guard.as_ref() + { + let _ = fh.send(FileHandlerEvents::RemoteAccepted(id)); + } + out.push(EngineEvent::ReceivedFileAccept(id)); + processed += 1; + } + EngineEvent::ToggleAudio(mute) => { + self.media_transport.set_audio_mute(mute); + // We push it out so the UI can update its state if the event came from elsewhere + out.push(EngineEvent::ToggleAudio(mute)); processed += 1; - out.push(EngineEvent::UpdateBitrate(*br)); } _ => { diff --git a/src/core/events.rs b/src/core/events.rs index 1c7da2a..306b4cc 100644 --- a/src/core/events.rs +++ b/src/core/events.rs @@ -2,7 +2,7 @@ use std::net::SocketAddr; use crate::{ congestion_controller::NetworkMetrics, log::log_msg::LogMsg, - media_transport::media_transport_event::RtpIn, + media_transport::media_transport_event::RtpIn, sctp::events::SctpFileProperties, }; /// Represents events that can be emitted by the `Engine` to the UI or other components. @@ -33,4 +33,31 @@ pub enum EngineEvent { NetworkMetrics(NetworkMetrics), /// Request to update the encoder bitrate. UpdateBitrate(u32), + + // File Transfer Events + SendFileOffer(SctpFileProperties), + SendFileAccept(u32), + SendFileReject(u32), + SendFileCancel(u32), + SendFileChunk(u32, Vec), + SendFileEnd(u32), + ReceivedFileOffer(SctpFileProperties), + ReceivedFileAccept(u32), + ReceivedFileReject(u32), + ReceivedFileCancel(u32), + ReceivedFileChunk(u32, u32, Vec), + ReceivedFileEnd(u32), + + UploadProgress { + id: u32, + current: usize, + total: usize, + }, + DownloadProgress { + id: u32, + current: usize, + }, + + /// Updates the mute state of the audio capture (true = muted, false = active). + ToggleAudio(bool), } diff --git a/src/core/protocol.rs b/src/core/protocol.rs index d4bc1a3..b61a5ca 100644 --- a/src/core/protocol.rs +++ b/src/core/protocol.rs @@ -17,8 +17,6 @@ pub enum AppMsg { FinAck { your: u64, mine: u64 }, /// FIN-ACK2 message for graceful session termination completion, acknowledging peer's token. FinAck2 { your: u64 }, - /// Any other message (e.g., media data) which is not part of the handshake/teardown. - Other(Vec), } /// Encodes a SYN message. @@ -58,15 +56,13 @@ fn parse_hex(t: &str) -> Option { /// Parses a byte slice into an `AppMsg`. #[must_use] -pub fn parse_app_msg(bytes: &[u8]) -> AppMsg { +pub fn parse_app_msg(bytes: &[u8]) -> Option { let s = String::from_utf8_lossy(bytes); let s = s.trim(); let mut it = s.split_whitespace(); - let Some(kind) = it.next() else { - return AppMsg::Other(bytes.to_vec()); - }; + let kind = it.next()?; - let msg = match kind { + match kind { "SYN" => { let token = it.next().and_then(parse_hex); token.map(|token| AppMsg::Syn { token }) @@ -100,7 +96,5 @@ pub fn parse_app_msg(bytes: &[u8]) -> AppMsg { your.map(|your| AppMsg::FinAck2 { your }) } _ => None, - }; - - msg.unwrap_or_else(|| AppMsg::Other(bytes.to_vec())) + } } diff --git a/src/core/session.rs b/src/core/session.rs index 2fd134c..51c898d 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -1,3 +1,8 @@ +//! Session management module. +//! +//! Handles the life cycle of a WebRTC session, including handshake, keep-alive, +//! data transmission (RTP/SCTP), and tear-down. + use crate::{sink_debug, sink_error, sink_info, srtp::SrtpSessionConfig}; use rand::{RngCore, rngs::OsRng}; use std::{ @@ -20,10 +25,14 @@ use crate::{ events::EngineEvent, protocol::{self, AppMsg}, }, + dtls::buffered_udp_channel::BufferedUdpChannel, log::log_sink::LogSink, media_transport::payload::rtp_payload_chunk::RtpPayloadChunk, + sctp::{events::SctpEvents, sctp_session::SctpSession}, }; +use openssl::ssl::SslStream; +#[allow(unused_variables)] #[derive(Clone, Copy)] /// Configuration for a `Session`. pub struct SessionConfig { @@ -82,36 +91,81 @@ pub struct Session { //SRTP config srtp_cfg: Option, + + sctp_session: Arc, +} + +/// Arguments for initializing a new `Session`. +pub struct SessionInitArgs { + /// The UDP socket to use for communication. + pub sock: Arc, + /// The address of the remote peer. + pub peer: std::net::SocketAddr, + /// A list of RTP codecs supported by the remote peer. + pub remote_codecs: Vec, + /// A sender for `EngineEvent`s to communicate with the engine. + pub event_tx: Sender, + /// A logger instance for logging session events. + pub logger: Arc, + /// The session configuration. + pub cfg: SessionConfig, + /// Optional SRTP configuration. + pub srtp_cfg: Option, + /// The DTLS stream over UDP. + pub ssl_stream: SslStream, + /// Whether we are the DTLS client (active opener) + pub is_client: bool, } impl Session { /// Creates a new `Session` instance. - /// - /// # Arguments - /// - /// * `sock` - The UDP socket to use for communication. - /// * `peer` - The address of the remote peer. - /// * `remote_codecs` - A list of RTP codecs supported by the remote peer. - /// * `event_tx` - A sender for `EngineEvent`s to communicate with the engine. - /// * `logger` - A logger instance for logging session events. - /// * `cfg` - The session configuration. - /// - /// # Returns - /// - /// A new `Session` instance. - pub fn new( - sock: Arc, - peer: std::net::SocketAddr, - remote_codecs: Vec, - event_tx: Sender, - logger: Arc, - cfg: SessionConfig, - srtp_cfg: Option, - ) -> Self { + pub fn new(args: SessionInitArgs) -> Self { + let (sctp_parent_tx, sctp_parent_rx) = mpsc::channel(); + let sctp_session = Arc::new(SctpSession::new( + args.logger.clone(), + sctp_parent_tx, + args.ssl_stream, + args.is_client, + )); + + // Spawn thread to forward SCTP events to EngineEvent + let evt_tx_clone = args.event_tx.clone(); + thread::spawn(move || { + while let Ok(ev) = sctp_parent_rx.recv() { + let engine_ev = match ev { + SctpEvents::ReceivedOffer { file_properties } => { + Some(EngineEvent::ReceivedFileOffer(file_properties)) + } + SctpEvents::ReceivedAccept { id } => Some(EngineEvent::ReceivedFileAccept(id)), + SctpEvents::ReceivedReject { id } => Some(EngineEvent::ReceivedFileReject(id)), + SctpEvents::ReceivedCancel { id } => Some(EngineEvent::ReceivedFileCancel(id)), + SctpEvents::ReceivedChunk { id, seq, payload } => { + Some(EngineEvent::ReceivedFileChunk(id, seq, payload)) + } + SctpEvents::ReceivedEndFile { id } => Some(EngineEvent::ReceivedFileEnd(id)), + SctpEvents::SendOffer { file_properties } => { + Some(EngineEvent::SendFileOffer(file_properties)) + } + SctpEvents::SendAccept { id } => Some(EngineEvent::SendFileAccept(id)), + SctpEvents::SendReject { id } => Some(EngineEvent::SendFileReject(id)), + SctpEvents::SendCancel { id } => Some(EngineEvent::SendFileCancel(id)), + SctpEvents::SendChunk { file_id, payload } => { + Some(EngineEvent::SendFileChunk(file_id, payload)) + } + SctpEvents::SendEndFile { id } => Some(EngineEvent::SendFileEnd(id)), + SctpEvents::SctpErr(e) => Some(EngineEvent::Error(format!("SCTP Error: {e}"))), + _ => None, + }; + if let Some(e) = engine_ev { + let _ = evt_tx_clone.send(e); + } + } + }); + Self { - sock, - peer, - remote_codecs, + sock: args.sock, + peer: args.peer, + remote_codecs: args.remote_codecs, run_flag: Arc::new(AtomicBool::new(false)), established: Arc::new(AtomicBool::new(false)), token_local: 0, @@ -119,14 +173,15 @@ impl Session { we_initiated_close: Arc::new(AtomicBool::new(false)), peer_initiated_close: Arc::new(AtomicBool::new(false)), close_done: Arc::new(AtomicBool::new(false)), - tx_evt: event_tx, - logger, - cfg, + tx_evt: args.event_tx, + logger: args.logger, + cfg: args.cfg, rtp_session: Arc::new(Mutex::new(None)), rtp_media_tx: Arc::new(Mutex::new(None)), hs_got_syn: Arc::new(AtomicBool::new(false)), hs_sent_synack: Arc::new(AtomicBool::new(false)), - srtp_cfg, + srtp_cfg: args.srtp_cfg, + sctp_session, } } @@ -216,37 +271,80 @@ impl Session { let rtp_session_handle = Arc::clone(&self.rtp_session); let hs_got_syn = Arc::clone(&self.hs_got_syn); let hs_sent_synack = Arc::clone(&self.hs_sent_synack); + let sctp_session = self.sctp_session.clone(); thread::spawn(move || { - let mut buf = [0u8; 1500]; + let mut buf = [0u8; 65535]; + let mut packet_batch: Vec> = Vec::with_capacity(64); + while rx_run.load(Ordering::SeqCst) { - match rx_sock.recv(&mut buf) { - Ok(n) => { - let msg = protocol::parse_app_msg(&buf[..n]); - let args = HandleAppMsgArgs { - msg, - rx_sock: &rx_sock, - rx_tok_peer: &rx_tok_peer, - rx_est: &rx_est, - rx_close_done: &rx_close_done, - rx_peer_init: &rx_peer_init, - local_token, - tx: &tx, - logger: &logger, - rtp_media_tx: &rtp_media_tx, - rtp_session_handle: &rtp_session_handle, - hs_got_syn: &hs_got_syn, - hs_sent_synack: &hs_sent_synack, - }; - handle_app_msg(args); + // 1. Burst Drain + for _ in 0..64 { + match rx_sock.recv(&mut buf) { + Ok(n) => { + if n > 0 { + packet_batch.push(buf[..n].to_vec()); + } + } + Err(ref e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + break; + } + Err(e) => { + sink_error!(&logger, "recv error: {e}"); + let _ = tx.send(EngineEvent::Error(format!("recv error: {e}"))); + return; + } } - Err(ref e) - if e.kind() == std::io::ErrorKind::WouldBlock - || e.kind() == std::io::ErrorKind::TimedOut => {} - Err(e) => { - sink_error!(&logger, "recv error: {e}"); - let _ = tx.send(EngineEvent::Error(format!("recv error: {e}"))); - break; + } + + // 2. Process Batch + if packet_batch.is_empty() { + thread::yield_now(); + continue; + } + + for pkt in packet_batch.drain(..) { + let first_byte = pkt[0]; + + if (20..=63).contains(&first_byte) { + // DTLS (SCTP) + sctp_session.handle_sctp_packet(pkt); + } else if (128..=191).contains(&first_byte) { + // RTP/RTCP + if rx_est.load(Ordering::SeqCst) { + let maybe_tx = rtp_media_tx + .lock() + .ok() + .and_then(|guard| guard.as_ref().cloned()); + if let Some(tx_media) = maybe_tx { + let _ = tx_media.send(pkt); + } + } + } else { + // AppMsg + if let Some(msg) = protocol::parse_app_msg(&pkt) { + let args = HandleAppMsgArgs { + msg, + rx_sock: &rx_sock, + rx_tok_peer: &rx_tok_peer, + rx_est: &rx_est, + rx_close_done: &rx_close_done, + rx_peer_init: &rx_peer_init, + local_token, + tx: &tx, + logger: &logger, + rtp_media_tx: &rtp_media_tx, + rtp_session_handle: &rtp_session_handle, + hs_got_syn: &hs_got_syn, + hs_sent_synack: &hs_sent_synack, + }; + handle_app_msg(args); + } else { + sink_debug!(&logger, "Ignored unknown packet (len={})", pkt.len()); + } } } } @@ -353,7 +451,10 @@ impl Session { }); } + /// Registers a new outbound track with the session. + /// /// # Errors + /// /// Returns an error if the rtp session is not running or the lock is poisoned. pub fn register_outbound_track(&self, codec: RtpCodec) -> Result { let guard = self @@ -368,7 +469,10 @@ impl Session { .map_err(|e| e.to_string()) } + /// Sends RTP chunks for a video frame. + /// /// # Errors + /// /// Returns an error if the rtp session is not running or the lock is poisoned. pub fn send_rtp_chunks_for_frame( &self, @@ -391,6 +495,20 @@ impl Session { fn teardown_rtp(&self) { stop_rtp_session(&self.rtp_session, &self.rtp_media_tx); } + + pub fn send_sctp_event(&self, event: SctpEvents) { + let _ = self.sctp_session.tx.send(event); + } + + pub fn buffered_amount(&self) -> usize { + self.sctp_session.buffered_amount() + } +} + +impl Drop for Session { + fn drop(&mut self) { + self.sctp_session.shutdown(); + } } /// Helper struct to pass arguments to the `handle_app_msg` function. @@ -571,22 +689,6 @@ fn handle_app_msg(args: HandleAppMsgArgs) { sink_debug!(args.logger, "[CLOSE] recv FIN-ACK2 not for us -> ignored"); } } - - AppMsg::Other(pkt) => { - // Only deliver media once established - if args.rx_est.load(Ordering::SeqCst) { - let maybe_tx = args - .rtp_media_tx - .lock() - .ok() - .and_then(|guard| guard.as_ref().cloned()); - if let Some(tx_media) = maybe_tx { - let _ = tx_media.send(pkt); - } - } else { - sink_debug!(args.logger, "[HS] recv media before established -> ignored"); - } - } } } /// Stops the RTP session and clears the media sender. diff --git a/src/dtls/buffered_udp_channel.rs b/src/dtls/buffered_udp_channel.rs index b822f33..87c2d2c 100644 --- a/src/dtls/buffered_udp_channel.rs +++ b/src/dtls/buffered_udp_channel.rs @@ -1,5 +1,6 @@ use core::fmt; use std::{ + collections::VecDeque, io::Write, io::{self, Cursor, Read}, net::{SocketAddr, UdpSocket}, @@ -10,42 +11,74 @@ use crate::{log::log_sink::LogSink, sink_trace, sink_warn}; // Struct modificado para incluir logger #[derive(Clone)] -pub(crate) struct BufferedUdpChannel { +pub struct BufferedUdpChannel { sock: Arc, peer: SocketAddr, reader: Cursor>, recv_buf: Vec, + incoming_queue: VecDeque, + manual_mode: bool, logger: Arc, + outgoing_queue: VecDeque>, } impl fmt::Debug for BufferedUdpChannel { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("BufferedUdpChannel") .field("peer", &self.peer) + .field("manual_mode", &self.manual_mode) .finish() } } impl BufferedUdpChannel { - pub(crate) fn new(sock: Arc, peer: SocketAddr, logger: Arc) -> Self { + pub fn new(sock: Arc, peer: SocketAddr, logger: Arc) -> Self { Self { sock, peer, reader: Cursor::new(Vec::new()), - recv_buf: vec![0u8; 4096], + recv_buf: vec![0u8; 65535], + incoming_queue: VecDeque::new(), + manual_mode: false, logger, + outgoing_queue: VecDeque::new(), } } + + pub fn set_manual_mode(&mut self, manual: bool) { + self.manual_mode = manual; + } + + pub fn push_incoming(&mut self, data: Vec) { + self.incoming_queue.extend(data); + } + + pub fn has_pending_writes(&self) -> bool { + !self.outgoing_queue.is_empty() + } } impl Read for BufferedUdpChannel { fn read(&mut self, buf: &mut [u8]) -> io::Result { - // entrega datos pendientes + // 1. Consume what's in the cursor first (leftovers from previous packet) let pos = self.reader.position(); if pos < self.reader.get_ref().len() as u64 { return self.reader.read(buf); } + if self.manual_mode { + // In manual mode, we only read from incoming_queue + if self.incoming_queue.is_empty() { + return Err(io::Error::from(io::ErrorKind::WouldBlock)); + } + let amt = std::cmp::min(buf.len(), self.incoming_queue.len()); + for (i, b) in self.incoming_queue.drain(..amt).enumerate() { + buf[i] = b; + } + return Ok(amt); + } + + // 2. Normal socket mode // buffer vacío: leer del socket loop { match self.sock.recv_from(&mut self.recv_buf) { @@ -75,15 +108,49 @@ impl Read for BufferedUdpChannel { impl Write for BufferedUdpChannel { fn write(&mut self, buf: &[u8]) -> io::Result { - sink_trace!( - &self.logger, - "[DTLS IO] Sending {} bytes to {}", - buf.len(), - self.peer - ); - self.sock.send_to(buf, self.peer) + // If queue is not empty, we must queue this new packet to maintain order + if !self.outgoing_queue.is_empty() { + self.outgoing_queue.push_back(buf.to_vec()); + return Ok(buf.len()); + } + + match self.sock.send_to(buf, self.peer) { + Ok(n) => { + sink_trace!(&self.logger, "[DTLS IO] Sent {} bytes to {}", n, self.peer); + Ok(n) + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + // Queue the packet instead of failing + sink_warn!( + &self.logger, + "[DTLS IO] Socket WouldBlock, queuing packet of size {}", + buf.len() + ); + self.outgoing_queue.push_back(buf.to_vec()); + Ok(buf.len()) // Pretend we wrote it + } + Err(e) => Err(e), + } } + fn flush(&mut self) -> io::Result<()> { + while let Some(packet) = self.outgoing_queue.front() { + match self.sock.send_to(packet, self.peer) { + Ok(n) => { + sink_trace!( + &self.logger, + "[DTLS IO] Flushed queued packet of {} bytes", + n + ); + self.outgoing_queue.pop_front(); + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + // Still blocked, stop flushing + return Ok(()); + } + Err(e) => return Err(e), + } + } Ok(()) } } diff --git a/src/dtls/runtime.rs b/src/dtls/runtime.rs index 6e4303b..4e31474 100644 --- a/src/dtls/runtime.rs +++ b/src/dtls/runtime.rs @@ -56,7 +56,7 @@ pub fn run_dtls_handshake( timeout: Duration, expected_fingerprint: Option, config: Arc, -) -> Result { +) -> Result<(SrtpSessionConfig, SslStream), DtlsError> { // Draining socket (nonblocking) sock.set_nonblocking(true).ok(); let mut drain_buf = [0u8; 4096]; @@ -115,7 +115,7 @@ pub fn run_dtls_handshake( })?; sink_info!(&logger, "[DTLS] Handshake Success! SRTP keys derived."); - Ok(cfg) + Ok((cfg, dtls_stream)) } /// Initiates a DTLS client handshake using OpenSSL. diff --git a/src/file_handler/events.rs b/src/file_handler/events.rs new file mode 100644 index 0000000..a357e04 --- /dev/null +++ b/src/file_handler/events.rs @@ -0,0 +1,47 @@ +#[derive(Debug, Clone)] +pub enum ReaderCommands { + GetChunk, + Cancel, +} + +#[derive(Debug, Clone)] +pub enum WriterCommands { + WriteChunk(Vec), + Cancel, +} + +#[derive(Debug, Clone)] +pub enum FileHandlerEvents { + ReadFile { + path: String, + id: u32, + }, + WriteFile { + filename: String, + id: u32, + }, + GetChunk(u32), + ReadChunk { + id: u32, + payload: Vec, + }, + WriteChunk { + id: u32, + payload: Vec, + }, + RemoteAccepted(u32), + ReaderWorkerFinished(u32), + WriterWorkerFinished(u32), + UploadProgress { + id: u32, + current: usize, + total: usize, + }, + DownloadProgress { + id: u32, + current: usize, + }, + Cancel(u32), + Err(String), + DrainChunks, +} diff --git a/src/file_handler/file_handler.rs b/src/file_handler/file_handler.rs new file mode 100644 index 0000000..edf61d2 --- /dev/null +++ b/src/file_handler/file_handler.rs @@ -0,0 +1,395 @@ +//! File Handler module. +//! +//! Manages file transfer operations (reading and writing) using worker threads. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; + +use crate::config::Config; +use crate::core::events::EngineEvent; +use crate::file_handler::events::{FileHandlerEvents, ReaderCommands, WriterCommands}; +use crate::file_handler::reader_worker::ReaderWorker; +use crate::file_handler::writer_worker::WriterWorker; +use crate::log::log_sink::LogSink; +use crate::sctp::events::SctpFileProperties; +use crate::{sink_debug, sink_error, sink_info, sink_trace, sink_warn}; + +#[allow(clippy::expect_used)] +#[allow(unused_variables)] +#[allow(clippy::collapsible_if)] +enum WorkerTx { + Reader(mpsc::Sender), + Writer(mpsc::Sender), +} + +/// Orchestrates file reading and writing workers. +pub struct FileHandler { + _config: Arc, + tx_listener: Mutex>>, + workers: Arc>>, + log_sink: Arc, +} + +impl FileHandler { + /// Creates a new `FileHandler`. + pub fn new( + config: Arc, + log_sink: Arc, + event_tx: mpsc::Sender, + ) -> Self { + let (tx, rx) = mpsc::channel(); + let workers = Arc::new(Mutex::new(HashMap::new())); + + let tx_for_listener = tx.clone(); + let workers_clone = workers.clone(); + let config_clone = config.clone(); + let log_sink_clone = log_sink.clone(); + + thread::spawn(move || { + Self::listener_loop( + rx, + tx_for_listener, + workers_clone, + config_clone, + log_sink_clone, + event_tx, + ); + }); + + Self { + _config: config, + tx_listener: Mutex::new(Some(tx)), + workers, + log_sink, + } + } + + /// Sends an event to the file handler listener loop. + /// + /// # Errors + /// + /// Returns an error if the listener loop is shut down or the channel is closed. + /// + /// # Panics + /// + /// Panics if the internal lock is poisoned. + pub fn send(&self, event: FileHandlerEvents) -> Result<(), String> { + let guard = self.tx_listener.lock().map_err(|e| e.to_string())?; + if let Some(tx) = &*guard { + tx.send(event).map_err(|e| e.to_string()) + } else { + Err("FileHandler is shut down".to_string()) + } + } + + /// Shuts down the file handler and all active workers. + /// + /// # Panics + /// + /// Panics if the internal lock is poisoned. + #[allow(clippy::expect_used)] + pub fn shutdown(&self) { + sink_info!(self.log_sink, "[FILE_HANDLER] Shutting down"); + + // 1. Cancel all workers + let mut workers = self.workers.lock().expect("Worker lock poisoned"); + for (_id, worker_tx) in workers.drain() { + match worker_tx { + WorkerTx::Reader(tx) => { + let _ = tx.send(ReaderCommands::Cancel); + } + WorkerTx::Writer(tx) => { + let _ = tx.send(WriterCommands::Cancel); + } + } + sink_debug!( + self.log_sink, + "[FILE_HANDLER] Sent Cancel to worker {}", + _id + ); + } + + // 2. Drop the listener sender to signal the listener loop to potentially stop (once workers drop theirs) + if let Ok(mut guard) = self.tx_listener.lock() { + *guard = None; + } + } + + #[allow(clippy::expect_used)] + fn listener_loop( + rx: mpsc::Receiver, + tx_listener: mpsc::Sender, + workers: Arc>>, + config: Arc, + log_sink: Arc, + event_tx: mpsc::Sender, + ) { + sink_info!(log_sink, "[FILE_HANDLER] Listener started"); + let mut active_readers = HashSet::new(); + + while let Ok(event) = rx.recv() { + match event { + FileHandlerEvents::ReadFile { path, id } => { + sink_debug!( + log_sink, + "[FILE_HANDLER] ReadFile request: {} (id: {})", + path, + id + ); + let (tx_worker, rx_worker) = mpsc::channel(); + + let path_obj = Path::new(&path); + let file_name = path_obj + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + + sink_trace!( + log_sink, + "[FILE_HANDLER] Spawning ReaderWorker for id: {}", + id + ); + + match ReaderWorker::new( + id, + path, + tx_listener.clone(), + rx_worker, + log_sink.clone(), + ) { + Ok(worker) => { + thread::spawn(move || worker.run()); + workers + .lock() + .expect("Workers lock poisoned") + .insert(id, WorkerTx::Reader(tx_worker)); + + // Notify Engine to send offer + let props = SctpFileProperties { + file_name, + file_size, + transaction_id: id, + }; + sink_trace!( + log_sink, + "[FILE_HANDLER] Sending SendFileOffer to Engine for id: {}", + id + ); + let _ = event_tx.send(EngineEvent::SendFileOffer(props)); + } + Err(e) => { + sink_error!( + log_sink, + "[FILE_HANDLER] Failed to create ReaderWorker: {}", + e + ); + let _ = tx_listener.send(FileHandlerEvents::Err(e)); + } + } + } + FileHandlerEvents::RemoteAccepted(id) => { + sink_info!( + log_sink, + "[FILE_HANDLER] Remote accepted file {}, activating reader", + id + ); + active_readers.insert(id); + } + FileHandlerEvents::WriteFile { filename, id } => { + sink_debug!( + log_sink, + "[FILE_HANDLER] WriteFile request: {} (id: {})", + filename, + id + ); + + let storage_path = config.get_non_empty_or_default( + "file_handler", + "storage_path", + "./downloads", + ); + let full_path = Path::new(storage_path).join(&filename); + + // Ensure directory exists + if let Some(parent) = full_path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + sink_error!( + log_sink, + "[FILE_HANDLER] Failed to create directory {:?}: {}", + parent, + e + ); + let _ = tx_listener.send(FileHandlerEvents::Err(e.to_string())); + continue; + } + + sink_trace!( + log_sink, + "[FILE_HANDLER] Spawning WriterWorker for id: {}", + id + ); + + let (tx_worker, rx_worker) = mpsc::channel(); + match WriterWorker::new( + id, + full_path, + tx_listener.clone(), + rx_worker, + log_sink.clone(), + ) { + Ok(worker) => { + thread::spawn(move || worker.run()); + workers + .lock() + .expect("Worker lock poisoned") + .insert(id, WorkerTx::Writer(tx_worker)); + } + Err(e) => { + sink_error!( + log_sink, + "[FILE_HANDLER] Failed to create WriterWorker: {}", + e + ); + let _ = tx_listener.send(FileHandlerEvents::Err(e)); + } + } + } + FileHandlerEvents::GetChunk(id) => { + sink_trace!( + log_sink, + "[FILE_HANDLER] Processing GetChunk for id: {}", + id + ); + let map = workers.lock().expect("Worker lock posioned"); + if let Some(WorkerTx::Reader(tx)) = map.get(&id) { + if let Err(e) = tx.send(ReaderCommands::GetChunk) { + sink_warn!( + log_sink, + "[FILE_HANDLER] Failed to send GetChunk to worker {}: {}", + id, + e + ); + } + } else { + sink_warn!( + log_sink, + "[FILE_HANDLER] GetChunk received for unknown or non-reader worker {}", + id + ); + } + } + FileHandlerEvents::ReadChunk { id, payload } => { + sink_debug!( + log_sink, + "[FILE_HANDLER] ReadChunk received from worker {}", + id + ); + crate::sctp_log!(log_sink, "ReadChunk: FileID:{} Size:{}", id, payload.len()); + sink_trace!( + log_sink, + "[FILE_HANDLER] Forwarding ReadChunk to Engine. Payload size: {}", + payload.len() + ); + let _ = event_tx.send(EngineEvent::SendFileChunk(id, payload)); + } + FileHandlerEvents::WriteChunk { id, payload } => { + sink_trace!( + log_sink, + "[FILE_HANDLER] Processing WriteChunk for id: {}. Payload size: {}", + id, + payload.len() + ); + crate::sctp_log!(log_sink, "WriteChunk: FileID:{} Size:{}", id, payload.len()); + let map = workers.lock().expect("Worker lock poisoned"); + if let Some(WorkerTx::Writer(tx)) = map.get(&id) { + if let Err(e) = tx.send(WriterCommands::WriteChunk(payload)) { + sink_warn!( + log_sink, + "[FILE_HANDLER] Failed to send WriteChunk to worker {}: {}", + id, + e + ); + } + } else { + sink_warn!( + log_sink, + "[FILE_HANDLER] WriteChunk received for unknown or non-writer worker {}", + id + ); + } + } + FileHandlerEvents::ReaderWorkerFinished(id) => { + sink_info!( + log_sink, + "[FILE_HANDLER] ReaderWorker {} finished successfully", + id + ); + workers.lock().expect("Worker lock posioned").remove(&id); + active_readers.remove(&id); + let _ = event_tx.send(EngineEvent::SendFileEnd(id)); + } + FileHandlerEvents::WriterWorkerFinished(id) => { + sink_info!( + log_sink, + "[FILE_HANDLER] WriterWorker {} finished successfully", + id + ); + workers.lock().expect("Worker lock poisoned").remove(&id); + let _ = event_tx.send(EngineEvent::Status(format!( + "File download complete: {}", + id + ))); + } + FileHandlerEvents::Cancel(id) => { + sink_info!(log_sink, "[FILE_HANDLER] Processing Cancel for id: {}", id); + active_readers.remove(&id); + let mut map = workers.lock().expect("Worker lock poisoned"); + if let Some(tx) = map.remove(&id) { + match tx { + WorkerTx::Reader(tx) => { + let _ = tx.send(ReaderCommands::Cancel); + } + WorkerTx::Writer(tx) => { + let _ = tx.send(WriterCommands::Cancel); + } + } + sink_debug!(log_sink, "[FILE_HANDLER] Cancelled worker {}", id); + } else { + sink_warn!( + log_sink, + "[FILE_HANDLER] Cancel received for unknown worker {}", + id + ); + } + } + FileHandlerEvents::Err(e) => { + sink_error!(log_sink, "[FILE_HANDLER] Error: {}", e); + let _ = event_tx.send(EngineEvent::Error(format!("FileHandler: {}", e))); + } + FileHandlerEvents::DrainChunks => { + sink_trace!(log_sink, "[FILE_HANDLER] Processing DrainChunks"); + let map = workers.lock().expect("Worker lock poisoned"); + for (id, worker) in map.iter() { + if let WorkerTx::Reader(tx) = worker + && active_readers.contains(id) + { + let _ = tx.send(ReaderCommands::GetChunk); + } + } + } + FileHandlerEvents::UploadProgress { id, current, total } => { + let _ = event_tx.send(EngineEvent::UploadProgress { id, current, total }); + } + FileHandlerEvents::DownloadProgress { id, current } => { + let _ = event_tx.send(EngineEvent::DownloadProgress { id, current }); + } + } + } + sink_info!(log_sink, "[FILE_HANDLER] Listener stopped"); + } +} diff --git a/src/file_handler/mod.rs b/src/file_handler/mod.rs new file mode 100644 index 0000000..cdbe5a0 --- /dev/null +++ b/src/file_handler/mod.rs @@ -0,0 +1,11 @@ +pub mod events; +#[allow(clippy::module_inception)] +pub mod file_handler; +pub mod reader_worker; +pub mod writer_worker; + +#[cfg(test)] +mod tests; + +pub use events::FileHandlerEvents; +pub use file_handler::FileHandler; diff --git a/src/file_handler/reader_worker.rs b/src/file_handler/reader_worker.rs new file mode 100644 index 0000000..1868be5 --- /dev/null +++ b/src/file_handler/reader_worker.rs @@ -0,0 +1,126 @@ +use crate::file_handler::events::{FileHandlerEvents, ReaderCommands}; +use crate::log::log_sink::LogSink; +use crate::{sink_debug, sink_error, sink_info, sink_trace, sink_warn}; +use std::fs::File; +use std::io::{BufReader, Read}; +use std::sync::{Arc, mpsc::Receiver, mpsc::Sender}; + +const CHUNK_SIZE: usize = 1024 * 16; + +pub struct ReaderWorker { + id: u32, + reader: BufReader, + tx_listener: Sender, + rx_cmd: Receiver, + log_sink: Arc, +} + +impl ReaderWorker { + pub fn new( + id: u32, + path: String, + tx_listener: Sender, + rx_cmd: Receiver, + log_sink: Arc, + ) -> Result { + let file = File::open(&path).map_err(|e| e.to_string())?; + let reader = BufReader::new(file); + Ok(Self { + id, + reader, + tx_listener, + rx_cmd, + log_sink, + }) + } + + pub fn run(mut self) { + sink_info!(self.log_sink, "[READER_WORKER] Worker {} started", self.id); + + let file_size = self + .reader + .get_ref() + .metadata() + .map(|m| m.len()) + .unwrap_or(0); + let mut total_read = 0; + + while let Ok(cmd) = self.rx_cmd.recv() { + match cmd { + ReaderCommands::GetChunk => { + sink_trace!( + self.log_sink, + "[READER_WORKER] Worker {} processing GetChunk", + self.id + ); + let mut buffer = vec![0u8; CHUNK_SIZE]; + match self.reader.read(&mut buffer) { + Ok(0) => { + // EOF + sink_debug!(self.log_sink, "[READER_WORKER] Worker {} EOF", self.id); + let _ = self.tx_listener.send(FileHandlerEvents::ReadChunk { + id: self.id, + payload: Vec::new(), + }); + let _ = self + .tx_listener + .send(FileHandlerEvents::ReaderWorkerFinished(self.id)); + break; + } + Ok(n) => { + buffer.truncate(n); + total_read += n as u64; + sink_debug!( + self.log_sink, + "[READER_WORKER] Worker {} read {} bytes (Total: {}/{})", + self.id, + n, + total_read, + file_size + ); + + let _ = self.tx_listener.send(FileHandlerEvents::UploadProgress { + id: self.id, + current: total_read as usize, + total: file_size as usize, + }); + + if let Err(e) = self.tx_listener.send(FileHandlerEvents::ReadChunk { + id: self.id, + payload: buffer, + }) { + sink_warn!( + self.log_sink, + "[READER_WORKER] Worker {} failed to send ReadChunk: {}", + self.id, + e + ); + break; + } + // Pacing to avoid UDP buffer overflow + std::thread::sleep(std::time::Duration::from_micros(500)); + } + Err(e) => { + sink_error!( + self.log_sink, + "[READER_WORKER] Worker {} read error: {}", + self.id, + e + ); + let _ = self.tx_listener.send(FileHandlerEvents::Err(e.to_string())); + } + } + } + ReaderCommands::Cancel => { + sink_info!( + self.log_sink, + "[READER_WORKER] Worker {} cancelled", + self.id + ); + break; + } + } + } + sink_info!(self.log_sink, "[READER_WORKER] Worker {} stopped", self.id); + } +} diff --git a/src/file_handler/tests.rs b/src/file_handler/tests.rs new file mode 100644 index 0000000..ae5356e --- /dev/null +++ b/src/file_handler/tests.rs @@ -0,0 +1,298 @@ +#[cfg(test)] +#[allow(clippy::module_inception)] +#[allow(clippy::expect_used)] +mod tests { + use super::super::events::{FileHandlerEvents, ReaderCommands, WriterCommands}; + use super::super::reader_worker::ReaderWorker; + use super::super::writer_worker::WriterWorker; + use crate::log::NoopLogSink; + use std::fs::{self, File}; + use std::io::{Read, Write}; + use std::sync::{Arc, mpsc}; + use std::thread; + use std::time::Duration; + + #[test] + fn test_reader_worker_flow() { + let tmp_dir = std::env::temp_dir().join("rustyrtc_reader_test"); + fs::create_dir_all(&tmp_dir).expect("failed to create temp dir"); + let file_path = tmp_dir.join("test_read.txt"); + let content = b"Hello Reader"; + { + let mut file = File::create(&file_path).expect("failed to create file"); + file.write_all(content).expect("failed to write content"); + } + + let (tx_listener, rx_listener) = mpsc::channel(); + let (tx_cmd, rx_cmd) = mpsc::channel(); + let log_sink = Arc::new(NoopLogSink); + + let worker = ReaderWorker::new( + 1, + file_path.to_str().expect("invalid path").to_string(), + tx_listener, + rx_cmd, + log_sink, + ) + .expect("failed to create worker"); + + thread::spawn(move || worker.run()); + + // Request chunk + tx_cmd + .send(ReaderCommands::GetChunk) + .expect("failed to send command"); + + // Expect UploadProgress + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::UploadProgress { id, current, total } => { + assert_eq!(id, 1); + assert_eq!(current, content.len()); + assert_eq!(total, content.len()); + } + _ => panic!("Expected UploadProgress"), + } + + // Expect chunk + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::ReadChunk { id, payload } => { + assert_eq!(id, 1); + assert_eq!(payload, content); + } + _ => panic!("Expected ReadChunk"), + } + + // Request EOF + tx_cmd + .send(ReaderCommands::GetChunk) + .expect("failed to send command"); + + // Expect empty chunk (EOF) + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::ReadChunk { id, payload } => { + assert_eq!(id, 1); + assert!(payload.is_empty()); + } + _ => panic!("Expected empty ReadChunk"), + } + + // Expect Finished event + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::ReaderWorkerFinished(id) => { + assert_eq!(id, 1); + } + _ => panic!("Expected ReaderWorkerFinished"), + } + + fs::remove_dir_all(tmp_dir).expect("failed to remove tmp dir"); + } + + #[test] + fn test_reader_worker_chunking() { + let tmp_dir = std::env::temp_dir().join("rustyrtc_reader_chunking_test"); + fs::create_dir_all(&tmp_dir).expect("failed to create temp dir"); + let file_path = tmp_dir.join("test_read_large.bin"); + + // Create content: 16KB + 1KB + let chunk_size = 16 * 1024; + let extra_size = 1024; + let total_size = chunk_size + extra_size; + let content: Vec = (0..total_size).map(|i| (i % 255) as u8).collect(); + + { + let mut file = File::create(&file_path).expect("failed to create file"); + file.write_all(&content).expect("failed to write content"); + } + + let (tx_listener, rx_listener) = mpsc::channel(); + let (tx_cmd, rx_cmd) = mpsc::channel(); + let log_sink = Arc::new(NoopLogSink); + + let worker = ReaderWorker::new( + 1, + file_path.to_str().expect("invalid path").to_string(), + tx_listener, + rx_cmd, + log_sink, + ) + .expect("failed to create worker"); + + thread::spawn(move || worker.run()); + + // 1. Request first chunk + tx_cmd + .send(ReaderCommands::GetChunk) + .expect("failed to send command"); + + // Expect UploadProgress + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::UploadProgress { + id, + current, + total: _, + } => { + assert_eq!(id, 1); + assert_eq!(current, chunk_size); + } + _ => panic!("Expected UploadProgress"), + } + + // Expect full 16KB chunk + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::ReadChunk { id, payload } => { + assert_eq!(id, 1); + assert_eq!( + payload.len(), + chunk_size, + "First chunk should be full chunk size" + ); + assert_eq!(payload, content[..chunk_size]); + } + _ => panic!("Expected ReadChunk"), + } + + // 2. Request second chunk + tx_cmd + .send(ReaderCommands::GetChunk) + .expect("failed to send command"); + + // Expect UploadProgress + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::UploadProgress { + id, + current, + total: _, + } => { + assert_eq!(id, 1); + assert_eq!(current, total_size); + } + _ => panic!("Expected UploadProgress"), + } + + // Expect remaining 1KB chunk + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::ReadChunk { id, payload } => { + assert_eq!(id, 1); + assert_eq!( + payload.len(), + extra_size, + "Second chunk should be remaining size" + ); + assert_eq!(payload, content[chunk_size..]); + } + _ => panic!("Expected ReadChunk"), + } + + // 3. Request EOF + tx_cmd + .send(ReaderCommands::GetChunk) + .expect("failed to send command"); + + // Expect empty chunk (EOF) + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::ReadChunk { id, payload } => { + assert_eq!(id, 1); + assert!(payload.is_empty()); + } + _ => panic!("Expected empty ReadChunk"), + } + + // Expect Finished event + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::ReaderWorkerFinished(id) => { + assert_eq!(id, 1); + } + _ => panic!("Expected ReaderWorkerFinished"), + } + + fs::remove_dir_all(tmp_dir).expect("failed to remove tmp dir"); + } + + #[test] + fn test_writer_worker_flow() { + let tmp_dir = std::env::temp_dir().join("rustyrtc_writer_test"); + fs::create_dir_all(&tmp_dir).expect("failed to create tmp dir"); + let file_path = tmp_dir.join("test_write.txt"); + + let (tx_listener, rx_listener) = mpsc::channel(); + let (tx_cmd, rx_cmd) = mpsc::channel(); + let log_sink = Arc::new(NoopLogSink); + + let worker = WriterWorker::new(2, file_path.clone(), tx_listener, rx_cmd, log_sink) + .expect("failed to create worker"); + + thread::spawn(move || worker.run()); + + let content = b"Hello Writer"; + tx_cmd + .send(WriterCommands::WriteChunk(content.to_vec())) + .expect("failed to send command"); + + // Expect DownloadProgress + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::DownloadProgress { id, current } => { + assert_eq!(id, 2); + assert_eq!(current, content.len()); + } + _ => panic!("Expected DownloadProgress"), + } + + // Send EOF + tx_cmd + .send(WriterCommands::WriteChunk(vec![])) + .expect("failed to send command"); + + // Expect Finished event + match rx_listener + .recv_timeout(Duration::from_secs(1)) + .expect("recv timeout") + { + FileHandlerEvents::WriterWorkerFinished(id) => { + assert_eq!(id, 2); + } + _ => panic!("Expected WriterWorkerFinished"), + } + + // Verify file content + let mut file = File::open(&file_path).expect("failed to open file"); + let mut read_content = Vec::new(); + file.read_to_end(&mut read_content) + .expect("failed to read file"); + assert_eq!(read_content, content); + + fs::remove_dir_all(tmp_dir).expect("failed to remove tmp dir"); + } +} diff --git a/src/file_handler/writer_worker.rs b/src/file_handler/writer_worker.rs new file mode 100644 index 0000000..a95940a --- /dev/null +++ b/src/file_handler/writer_worker.rs @@ -0,0 +1,151 @@ +use crate::file_handler::events::{FileHandlerEvents, WriterCommands}; +use crate::log::log_sink::LogSink; +use crate::{sink_debug, sink_error, sink_info, sink_trace, sink_warn}; +use std::fs::{self, File}; +use std::io::{BufWriter, Write}; +use std::path::PathBuf; +use std::sync::{Arc, mpsc::Receiver, mpsc::Sender}; +use std::time::Duration; + +const TIMEOUT_DURATION: Duration = Duration::from_secs(10); + +pub struct WriterWorker { + id: u32, + writer: BufWriter, + path: PathBuf, + tx_listener: Sender, + rx_cmd: Receiver, + log_sink: Arc, +} + +impl WriterWorker { + pub fn new( + id: u32, + path: PathBuf, + tx_listener: Sender, + rx_cmd: Receiver, + log_sink: Arc, + ) -> Result { + let file = File::create(&path).map_err(|e| e.to_string())?; + let writer = BufWriter::with_capacity(10 * 1024 * 1024, file); + Ok(Self { + id, + writer, + path, + tx_listener, + rx_cmd, + log_sink, + }) + } + + pub fn run(mut self) { + sink_info!(self.log_sink, "[WRITER_WORKER] Worker {} started", self.id); + let mut total_written = 0; + loop { + match self.rx_cmd.recv_timeout(TIMEOUT_DURATION) { + Ok(WriterCommands::WriteChunk(payload)) => { + sink_trace!( + self.log_sink, + "[WRITER_WORKER] Worker {} processing WriteChunk of size {}", + self.id, + payload.len() + ); + if payload.is_empty() { + sink_debug!( + self.log_sink, + "[WRITER_WORKER] Worker {} received EOF", + self.id + ); + if let Err(e) = self.writer.flush() { + sink_error!( + self.log_sink, + "[WRITER_WORKER] Worker {} flush error: {}", + self.id, + e + ); + let _ = self.tx_listener.send(FileHandlerEvents::Err(e.to_string())); + self.cleanup(); + } else { + let _ = self + .tx_listener + .send(FileHandlerEvents::WriterWorkerFinished(self.id)); + } + break; + } + + if let Err(e) = self.writer.write_all(&payload) { + sink_error!( + self.log_sink, + "[WRITER_WORKER] Worker {} write error: {}", + self.id, + e + ); + let _ = self.tx_listener.send(FileHandlerEvents::Err(e.to_string())); + self.cleanup(); + break; + } + total_written += payload.len(); + sink_debug!( + self.log_sink, + "[WRITER_WORKER] Worker {} wrote {} bytes (Total: {})", + self.id, + payload.len(), + total_written + ); + let _ = self.tx_listener.send(FileHandlerEvents::DownloadProgress { + id: self.id, + current: total_written, + }); + } + Ok(WriterCommands::Cancel) => { + sink_info!( + self.log_sink, + "[WRITER_WORKER] Worker {} cancelled", + self.id + ); + self.cleanup(); + break; + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + sink_warn!( + self.log_sink, + "[WRITER_WORKER] Worker {} timed out", + self.id + ); + self.cleanup(); + break; + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + sink_info!( + self.log_sink, + "[WRITER_WORKER] Worker {} channel disconnected", + self.id + ); + self.cleanup(); + break; + } + } + } + sink_info!(self.log_sink, "[WRITER_WORKER] Worker {} stopped", self.id); + } + + fn cleanup(&self) { + // Try to remove the file + if let Err(e) = fs::remove_file(&self.path) { + sink_warn!( + self.log_sink, + "[WRITER_WORKER] Worker {} failed to remove file {:?}: {}", + self.id, + self.path, + e + ); + } else { + sink_info!( + self.log_sink, + "[WRITER_WORKER] Worker {} removed file {:?}", + self.id, + self.path + ); + } + } +} diff --git a/src/ice/type_ice/ice_agent.rs b/src/ice/type_ice/ice_agent.rs index f14af2f..12980da 100644 --- a/src/ice/type_ice/ice_agent.rs +++ b/src/ice/type_ice/ice_agent.rs @@ -677,7 +677,7 @@ impl IceAgent { }; if should_nominate { - sink_info!( + sink_debug!( self.logger, "[ICE] Nominating pair: [local={}, remote={}]", pair.local.address, @@ -690,14 +690,14 @@ impl IceAgent { if let Err(e) = local_sock.send_to(NOMINATION_REQUEST, pair.remote.address) { - sink_error!( + sink_debug!( self.logger, "[ICE] Error sending NOMINATION_REQUEST to {}: {}", pair.remote.address, e ); } else { - sink_info!( + sink_debug!( self.logger, "[ICE] Sent NOMINATION_REQUEST to {}", pair.remote.address @@ -714,7 +714,7 @@ impl IceAgent { } } else if packet == BINDING_REQUEST || packet == NOMINATION_REQUEST { if self.role == IceRole::Controlled && packet == NOMINATION_REQUEST { - sink_info!( + sink_debug!( self.logger, "[ICE] Received NOMINATION_REQUEST from {}", from_addr @@ -726,7 +726,7 @@ impl IceAgent { pair.is_nominated = true; pair.state = CandidatePairState::Succeeded; self.nominated_pair = Some(pair.clone_light()); - sink_info!( + sink_debug!( self.logger, "[ICE] Pair nominated by peer: [local={}, remote={}]", pair.local.address, diff --git a/src/lib.rs b/src/lib.rs index 4a48c2e..34f8f5e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,8 @@ pub mod connection_manager; pub mod core; /// DTLS (Datagram Transport Layer Security) implementation. pub mod dtls; +/// File handler for P2P file transfer. +pub mod file_handler; /// ICE (Interactive Connectivity Establishment) implementation for NAT traversal. pub mod ice; /// Logging utilities for the application. @@ -35,6 +37,8 @@ pub mod rtcp; pub mod rtp; /// Manages RTP sessions for sending and receiving media. pub mod rtp_session; +/// SCTP implementation for file transfer. +pub mod sctp; /// SDP (Session Description Protocol) parsing and building. pub mod sdp; /// Signaling server implementation for coordinating WebRTC connections. diff --git a/src/log/log_macros.rs b/src/log/log_macros.rs index 5d73325..d7432ce 100644 --- a/src/log/log_macros.rs +++ b/src/log/log_macros.rs @@ -314,3 +314,21 @@ macro_rules! logger_error { () }; } + +// ---------------------- SCTP TRANSFER DEBUG ---------------------- +#[cfg(feature = "sctp-transfer-debug")] +#[macro_export] +macro_rules! sctp_log { + ($sink:expr, $($arg:tt)*) => { + // Force INFO level logs for transfer debug so they appear even in INFO mode + $crate::sink_log!($sink, $crate::log::log_level::LogLevel::Info, "[SCTP_DEBUG] {}", format!($($arg)*)); + } +} + +#[cfg(not(feature = "sctp-transfer-debug"))] +#[macro_export] +macro_rules! sctp_log { + ($($arg:tt)*) => { + () + }; +} diff --git a/src/log/logger.rs b/src/log/logger.rs index f72010e..bb4f90a 100644 --- a/src/log/logger.rs +++ b/src/log/logger.rs @@ -190,7 +190,7 @@ impl Logger { while let Ok(m) = rx.recv() { let _ = writeln!(&mut out, "[{:?}] {} | {}", m.level, m.ts_ms, m.text); lines_written = lines_written.wrapping_add(1); - + // Flush periodically to ensure data persists on crash. if lines_written.is_multiple_of(FLUSH_BATCH_SIZE) { let _ = out.flush(); @@ -238,7 +238,7 @@ impl Logger { /// This method sends the message to the logger’s internal synchronous channel. /// If the channel is full, the message is **dropped** and an error is returned. /// - /// This function never blocks — use [`log`](Self::log) for the blocking variant. + /// This function never blocks. /// /// # Parameters /// - `level`: The severity level of the message (e.g. `Info`, `Warn`, `Error`). diff --git a/src/media_agent/audio_capture_error.rs b/src/media_agent/audio_capture_error.rs new file mode 100644 index 0000000..4ed8a20 --- /dev/null +++ b/src/media_agent/audio_capture_error.rs @@ -0,0 +1,22 @@ +use std::fmt; + +#[derive(Debug, Clone)] +pub enum AudioCaptureError { + StreamConfig(String), + StreamBuild(String), + StreamPlay(String), + Runtime(String), +} + +impl fmt::Display for AudioCaptureError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AudioCaptureError::StreamConfig(e) => write!(f, "Stream Config Error: {}", e), + AudioCaptureError::StreamBuild(e) => write!(f, "Stream Build Error: {}", e), + AudioCaptureError::StreamPlay(e) => write!(f, "Stream Play Error: {}", e), + AudioCaptureError::Runtime(e) => write!(f, "Runtime Error: {}", e), + } + } +} + +impl std::error::Error for AudioCaptureError {} diff --git a/src/media_agent/audio_capture_worker.rs b/src/media_agent/audio_capture_worker.rs new file mode 100644 index 0000000..3a8f386 --- /dev/null +++ b/src/media_agent/audio_capture_worker.rs @@ -0,0 +1,153 @@ +use crate::log::log_sink::LogSink; +use crate::media_agent::{ + audio_capture_error::AudioCaptureError, + audio_frame::AudioFrame, + media_agent_error::{MediaAgentError, Result}, + utils::now_millis, +}; +use crate::{sink_debug, sink_error, sink_info, sink_warn}; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use std::collections::VecDeque; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + mpsc::Sender, +}; +use std::thread; +use std::time::Duration; + +#[allow(clippy::expect_used)] +/// Event sent by the AudioCaptureWorker. +#[derive(Debug)] +pub enum AudioCaptureEvent { + Frame(AudioFrame), + Error(AudioCaptureError), +} + +/// Spawns the audio capture worker. +/// +/// This function initializes the default input device and starts capturing audio frames. +/// +/// # Arguments +/// +/// * `logger` - Logger instance. +/// * `running` - Atomic flag to control the worker loop. +/// * `is_muted` - Atomic flag to control audio muting. +/// +/// # Returns +/// +/// A tuple containing the receiver for captured audio events and the join handle of the worker thread. +pub fn spawn_audio_capture_worker( + logger: Arc, + running: Arc, + is_muted: Arc, +) -> ( + std::sync::mpsc::Receiver, + Option>, +) { + let (tx, rx) = std::sync::mpsc::channel(); + + let handle = thread::Builder::new() + .name("media-agent-audio-capture".into()) + .spawn(move || { + if let Err(e) = run_audio_capture(logger.clone(), tx.clone(), running, is_muted) { + sink_error!(logger, "[AudioCaptureWorker] Error: {}", e); + let _ = tx.send(AudioCaptureEvent::Error(AudioCaptureError::Runtime( + e.to_string(), + ))); + } + }) + .ok(); + + (rx, handle) +} + +fn run_audio_capture( + logger: Arc, + tx: Sender, + running: Arc, + is_muted: Arc, +) -> Result<()> { + let host = cpal::default_host(); + let device = host + .default_input_device() + .ok_or_else(|| MediaAgentError::Io("Failed to get default input device".to_string()))?; + + sink_info!( + logger, + "[AudioCaptureWorker] Using audio device: {}", + device.name().unwrap_or_default() + ); + + let config = cpal::StreamConfig { + channels: 1, + sample_rate: cpal::SampleRate(8000), + buffer_size: cpal::BufferSize::Default, + }; + + let buffer = Arc::new(Mutex::new(VecDeque::with_capacity(320))); + let buffer_clone = buffer.clone(); + + let logger_clone = logger.clone(); + let tx_err = tx.clone(); + let tx_data = tx.clone(); + let is_muted_clone = is_muted.clone(); + + let err_fn = move |err: cpal::StreamError| { + sink_warn!(logger_clone, "[AudioCaptureWorker] Stream error: {}", err); + let _ = tx_err.send(AudioCaptureEvent::Error(AudioCaptureError::StreamPlay( + err.to_string(), + ))); + }; + + let stream = device + .build_input_stream( + &config, + move |data: &[f32], _: &cpal::InputCallbackInfo| { + let mut buf = buffer_clone + .lock() + .map_err(|e| MediaAgentError::Io(format!("Failed to lock audio buffer: {}", e))) + .unwrap_or_else(|_| { + panic!("Failed to lock audio buffer: poisoned"); + }); + + if is_muted_clone.load(Ordering::Relaxed) { + // If muted, fill with silence (zeros) + buf.extend(std::iter::repeat_n(0.0, data.len())); + } else { + // If not muted, copy captured data + buf.extend(data.iter().cloned()); + } + + while buf.len() >= 160 { + let chunk: Vec = buf.drain(0..160).collect(); + let frame = AudioFrame { + data: Arc::new(chunk), + samples: 160, + sample_rate: 8000, + channels: 1, + timestamp_ms: now_millis(), + }; + + if tx_data.send(AudioCaptureEvent::Frame(frame)).is_err() { + // Receiver disconnected + } + } + }, + err_fn, + None, + ) + .map_err(|e| MediaAgentError::Io(format!("Failed to build input stream: {}", e)))?; + + stream + .play() + .map_err(|e| MediaAgentError::Io(format!("Failed to play stream: {}", e)))?; + + sink_debug!(logger, "[AudioCaptureWorker] Audio capture started"); + + while running.load(Ordering::SeqCst) { + thread::sleep(Duration::from_millis(100)); + } + + Ok(()) +} diff --git a/src/media_agent/audio_codec.rs b/src/media_agent/audio_codec.rs new file mode 100644 index 0000000..4eb3902 --- /dev/null +++ b/src/media_agent/audio_codec.rs @@ -0,0 +1,124 @@ +/// Audio Codec module for G.711 u-law encoding and decoding. +/// +/// This module provides pure functions to convert between raw PCM audio samples (f32) +/// and compressed G.711 u-law bytes (u8). +/// +/// The internal algorithm works with 16-bit signed integers (i16), but the public API +/// uses `f32` to maintain consistency with the rest of the media pipeline. +const BIAS: i16 = 0x84; +const CLIP: i32 = 32635; + +/// Encodes a slice of f32 PCM samples to G.711 u-law bytes. +/// +/// The input samples are expected to be in the range [-1.0, 1.0]. +/// They are converted to 14-bit signed integers (via i16) before u-law compression. +pub fn encode(pcm_samples: &[f32]) -> Vec { + pcm_samples + .iter() + .map(|&sample| { + // Convert f32 [-1.0, 1.0] to i16 [-32768, 32767] + let s = (sample * 32767.0) as i32; + let clamped = s.clamp(-32768, 32767) as i16; + linear_to_ulaw(clamped) + }) + .collect() +} + +/// Decodes a slice of G.711 u-law bytes to f32 PCM samples. +/// +/// The resulting samples will be in the range [-1.0, 1.0]. +pub fn decode(ulaw_bytes: &[u8]) -> Vec { + ulaw_bytes + .iter() + .map(|&byte| { + let sample = ulaw_to_linear(byte); + // Convert i16 to f32 [-1.0, 1.0] + sample as f32 / 32767.0 + }) + .collect() +} + +/// Converts a 16-bit linear PCM sample to 8-bit u-law. +fn linear_to_ulaw(sample: i16) -> u8 { + let sign = (sample >> 8) & 0x80; + let mut s = sample as i32; + if s < 0 { + s = -s; + } + if s > CLIP { + s = CLIP; + } + + s += BIAS as i32; + + // Better reference implementation: + let mut mask = 0x4000; + let mut exp = 7; + while (s & mask) == 0 && exp > 0 { + mask >>= 1; + exp -= 1; + } + + let mantissa = (s >> (exp + 3)) & 0x0F; + let ulaw_byte = (sign as u8) | ((exp as u8) << 4) | (mantissa as u8); + + !ulaw_byte // Invert bits for u-law +} + +/// Converts an 8-bit u-law sample to 16-bit linear PCM. +fn ulaw_to_linear(ulaw_byte: u8) -> i16 { + let ulaw_byte = !ulaw_byte; // Invert bits back + let sign = ulaw_byte & 0x80; + let exponent = (ulaw_byte >> 4) & 0x07; + let mantissa = ulaw_byte & 0x0F; + + let mut sample = (((mantissa as i32) << 3) + 132) << exponent; + sample -= BIAS as i32; + + if sign != 0 { + sample = -sample; + } + + sample as i16 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_decode_cycle() { + let original = 0.5f32; + let encoded = encode(&[original]); + let decoded = decode(&encoded); + + // G.711 is lossy, so we check if the result is close enough. + let diff = (original - decoded[0]).abs(); + assert!( + diff < 0.05, + "Decoded value {} too far from original {}", + decoded[0], + original + ); + } + + #[test] + fn test_silence() { + let original = 0.0f32; + let encoded = encode(&[original]); + let decoded = decode(&encoded); + + let diff = (original - decoded[0]).abs(); + assert!(diff < 0.01, "Silence should be preserved reasonably well"); + } + + #[test] + fn test_clipping() { + let original = 1.5f32; // > 1.0, should clip + let encoded = encode(&[original]); + let decoded = decode(&encoded); + + // Should be close to max value (~1.0) + assert!(decoded[0] > 0.9, "Should be close to max positive value"); + } +} diff --git a/src/media_agent/audio_frame.rs b/src/media_agent/audio_frame.rs new file mode 100644 index 0000000..7ba69be --- /dev/null +++ b/src/media_agent/audio_frame.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; + +/// Represents a single audio frame with associated metadata. +#[derive(Debug, Clone)] +pub struct AudioFrame { + /// The raw audio samples (mono, f32). + pub data: Arc>, + /// Number of samples in this frame. + pub samples: usize, + /// Sample rate in Hz (e.g., 48000). + pub sample_rate: u32, + /// Number of channels (e.g., 1). + pub channels: u16, + /// Timestamp of capture in milliseconds. + pub timestamp_ms: u128, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audio_frame_creation() { + let data = vec![0.0; 960]; + let frame = AudioFrame { + data: Arc::new(data.clone()), + samples: 960, + sample_rate: 48000, + channels: 1, + timestamp_ms: 123456789, + }; + + assert_eq!(frame.samples, 960); + assert_eq!(frame.sample_rate, 48000); + assert_eq!(frame.channels, 1); + assert_eq!(frame.timestamp_ms, 123456789); + assert_eq!(frame.data.len(), 960); + } +} diff --git a/src/media_agent/audio_player_worker.rs b/src/media_agent/audio_player_worker.rs new file mode 100644 index 0000000..42d3c70 --- /dev/null +++ b/src/media_agent/audio_player_worker.rs @@ -0,0 +1,143 @@ +use std::{ + collections::VecDeque, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + mpsc::{Receiver, RecvTimeoutError}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + +use crate::{log::log_sink::LogSink, sink_debug, sink_error, sink_info, sink_trace, sink_warn}; + +/// Commands sent from the MediaAgent to the AudioPlayerWorker. +pub enum AudioPlayerCommand { + /// Play a chunk of decoded audio samples. + PlayFrame(Vec), +} + +/// Max buffer size in samples before dropping data to reduce latency. +/// 8kHz * 0.5s = 4000 samples. +const MAX_BUFFER_SIZE: usize = 4000; + +#[allow(clippy::expect_used)] +/// Spawns the audio player worker. +/// +/// This worker manages the audio output device and a jitter buffer. +/// It receives decoded audio frames via `command_rx` and plays them. +/// +/// # Arguments +/// +/// * `logger` - Logger instance. +/// * `command_rx` - Channel to receive playback commands. +/// * `running` - Atomic flag to control the worker's lifecycle. +/// +/// # Returns +/// +/// The `JoinHandle` of the worker thread. +pub fn spawn_audio_player_worker( + logger: Arc, + command_rx: Receiver, + running: Arc, +) -> JoinHandle<()> { + sink_info!(logger, "[AudioPlayer] Starting..."); + + thread::Builder::new() + .name("media-agent-audio-player".into()) + .spawn(move || { + let host = cpal::default_host(); + let device = match host.default_output_device() { + Some(d) => d, + None => { + sink_error!(logger, "[AudioPlayer] No default output device found"); + return; + } + }; + + sink_info!(logger, "[AudioPlayer] Using output device: {}", device.name().unwrap_or_default()); + + let config = cpal::StreamConfig { + channels: 1, + sample_rate: cpal::SampleRate(8000), + buffer_size: cpal::BufferSize::Default, + }; + + // Shared buffer between the event loop (producer) and the audio callback (consumer). + let buffer = Arc::new(Mutex::new(VecDeque::with_capacity(MAX_BUFFER_SIZE * 2))); + let buffer_cb = buffer.clone(); + + let logger_cb = logger.clone(); + + let err_fn = move |err| { + sink_warn!(logger_cb, "[AudioPlayer] Stream error: {}", err); + }; + + let stream = match device.build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let mut buf = buffer_cb.lock().expect("audio buffer lock poisoned"); + for sample in data.iter_mut() { + if let Some(s) = buf.pop_front() { + *sample = s; + } else { + // Buffer empty (underrun), play silence + *sample = 0.0; + } + } + }, + err_fn, + None, + ) { + Ok(s) => s, + Err(e) => { + sink_error!(logger, "[AudioPlayer] Failed to build output stream: {}", e); + return; + } + }; + + if let Err(e) = stream.play() { + sink_error!(logger, "[AudioPlayer] Failed to play stream: {}", e); + return; + } + + sink_debug!(logger, "[AudioPlayer] Playback started"); + + while running.load(Ordering::Relaxed) { + // Poll for commands + match command_rx.recv_timeout(Duration::from_millis(100)) { + Ok(cmd) => match cmd { + AudioPlayerCommand::PlayFrame(samples) => { + let mut buf = buffer.lock().expect("audio buffer lock poisoned"); + + // Latency control: if buffer is too full, drop old data + let current_len = buf.len(); + let incoming_len = samples.len(); + + if current_len + incoming_len > MAX_BUFFER_SIZE { + let drop_count = (current_len + incoming_len) - MAX_BUFFER_SIZE; + let to_drop = drop_count.min(current_len); + sink_trace!(logger, "[AudioPlayer] Buffer full, dropping {} samples for latency catch-up", drop_count); + buf.drain(0..to_drop); + } + + buf.extend(samples); + sink_trace!(logger, "[AudioPlayer] Buffered {} samples. Total buffered: {}", incoming_len, buf.len()); + } + }, + Err(RecvTimeoutError::Timeout) => { + // Continue checking running flag + } + Err(RecvTimeoutError::Disconnected) => { + sink_debug!(logger, "[AudioPlayer] Channel disconnected, stopping"); + break; + } + } + } + + sink_debug!(logger, "[AudioPlayer] Stopped"); + }) + .expect("spawn media-agent-audio-player") +} diff --git a/src/media_agent/camera_worker.rs b/src/media_agent/camera_worker.rs index c775f12..f202109 100644 --- a/src/media_agent/camera_worker.rs +++ b/src/media_agent/camera_worker.rs @@ -59,7 +59,7 @@ pub fn camera_loop( let h = cam.height(); // Propagates conversion errors immediately let vf = convert_to_videoframe(&frame, w, h)?; - + // If the receiver hangs up, we exit the loop gracefully if tx.send(vf).is_err() { break; @@ -119,7 +119,7 @@ fn convert_to_videoframe(mat: &Mat, w: u32, h: u32) -> Result { opencv::core::AlgorithmHint::ALGO_HINT_DEFAULT, ) .map_err(|e| MediaAgentError::Io(format!("cvtColor: {e}")))?; - + let bytes = tight_rgb_bytes(&rgb_mat, w, h) .map_err(|e| MediaAgentError::Io(format!("pack RGB: {e}")))?; @@ -150,11 +150,11 @@ pub fn synthetic_loop( let fps = target_fps.clamp(1, 120); let period = Duration::from_millis(1_000 / fps as u64); let mut phase = 0u8; - + while running.load(Ordering::SeqCst) { let frame = VideoFrame::synthetic_rgb(320, 240, phase); phase = phase.wrapping_add(1); - + if tx.send(frame).is_err() { logger_error!(logger, "[Synthethic Loop]: an error occured, exiting!"); break; @@ -191,7 +191,7 @@ pub fn spawn_camera_worker( ) -> (Receiver, Option, Option>) { sink_info!(logger, "[CameraWorker] Starting camera worker"); let (local_frame_tx, local_frame_rx) = mpsc::channel(); - + // Attempt to initialize physical hardware let camera_manager = CameraManager::new(camera_id, logger.clone()); diff --git a/src/media_agent/decoder_worker.rs b/src/media_agent/decoder_worker.rs index 472fcaa..d551d3c 100644 --- a/src/media_agent/decoder_worker.rs +++ b/src/media_agent/decoder_worker.rs @@ -15,7 +15,7 @@ use crate::{ constants::CHANNELS_TIMEOUT, decoder_event::DecoderEvent, events::MediaAgentEvent, frame_format::FrameFormat, h264_decoder::H264Decoder, spec::CodecSpec, }, - sink_debug, sink_info, + sink_debug, sink_info, sink_trace, }; /// Target pixel format for the decoder output. @@ -111,11 +111,11 @@ pub fn spawn_decoder_worker( &bytes[..bytes.len().min(12)] ); let t0 = std::time::Instant::now(); - + match h264_decoder.decode_frame(&bytes, FRAME_FORMAT) { Ok(Some(frame)) => { let took = t0.elapsed(); - sink_info!( + sink_trace!( logger, "[Decoder] Frame Ready sending MediaAgentEvent::DecodedVideoFrame" ); @@ -145,6 +145,9 @@ pub fn spawn_decoder_worker( } } }, + _ => { + logger_error!(logger, "[Decoder] Unsupported codec for decoder worker: {:?}", codec_spec); + } } }, } diff --git a/src/media_agent/encoder_worker.rs b/src/media_agent/encoder_worker.rs index d7eb480..26ca1d3 100644 --- a/src/media_agent/encoder_worker.rs +++ b/src/media_agent/encoder_worker.rs @@ -63,7 +63,7 @@ pub fn spawn_encoder_worker( config: Arc, ) -> Result, Error> { sink_debug!(logger.clone(), "[Encoder] Starting..."); - + thread::Builder::new() .name("media-agent-encoder".into()) .spawn(move || { @@ -94,7 +94,7 @@ pub fn spawn_encoder_worker( if force_keyframe { h264_encoder.request_keyframe(); } - + match h264_encoder.encode_frame_to_h264(&frame) { Ok(annexb_frame) => { sink_debug!( @@ -128,7 +128,6 @@ pub fn spawn_encoder_worker( }, Err(RecvTimeoutError::Timeout) => { - // Timeout is expected; allows checking the `running` flag. #[cfg(debug_assertions)] logger_debug!( logger, @@ -142,7 +141,6 @@ pub fn spawn_encoder_worker( logger, "[MediaAgent Encoder] The channel has been disconnected" ); - // Optional: break; // If the instruction channel dies, the worker could exit. } } } diff --git a/src/media_agent/events.rs b/src/media_agent/events.rs index 969b2ed..c95ef71 100644 --- a/src/media_agent/events.rs +++ b/src/media_agent/events.rs @@ -11,6 +11,10 @@ pub enum MediaAgentEvent { timestamp_ms: u128, codec_spec: CodecSpec, }, + EncodedAudioFrame { + payload: Vec, + codec_spec: CodecSpec, + }, DecodedVideoFrame(Box), UpdateBitrate(u32), } diff --git a/src/media_agent/h264_decoder.rs b/src/media_agent/h264_decoder.rs index dd943ec..e2f5a94 100644 --- a/src/media_agent/h264_decoder.rs +++ b/src/media_agent/h264_decoder.rs @@ -82,7 +82,7 @@ impl H264Decoder { let t1 = std::time::Instant::now(); let frame = yuv_to_videoframe(&yuv, frame_format); let t_conv = t1.elapsed(); - + sink_debug!( self.logger, "[Decoder timing] decode: {:?}, yuv_convertion: {:?}", @@ -211,6 +211,6 @@ fn yuv_to_yuv420frame(yuv: &DecodedYUV<'_>) -> VideoFrame { /// /// Current standard: `wgpu::COPY_BYTES_PER_ROW_ALIGNMENT` is 256 bytes. fn aligned_stride(width: usize) -> usize { - const ALIGNMENT: usize = 256; + const ALIGNMENT: usize = 256; width.div_ceil(ALIGNMENT) * ALIGNMENT } diff --git a/src/media_agent/h264_encoder.rs b/src/media_agent/h264_encoder.rs index 2d51a92..09d8df0 100644 --- a/src/media_agent/h264_encoder.rs +++ b/src/media_agent/h264_encoder.rs @@ -88,9 +88,7 @@ impl H264Encoder { // Placeholder for future zero-copy YUV path implementation match frame.format { FrameFormat::Rgb => {} - FrameFormat::Yuv420 => { - // TODO: use YUVBuffer::new(...) if you already have planar YUV to avoid conversion. - } + FrameFormat::Yuv420 => {} } let Some(enc) = self.enc.as_mut() else { @@ -101,7 +99,7 @@ impl H264Encoder { let w = frame.width as usize; let h = frame.height as usize; - + // Prepare source slice for conversion let rgb_slice = match &frame.data { crate::media_agent::video_frame::VideoFrameData::Rgb(buf) => { diff --git a/src/media_agent/media_agent_c.rs b/src/media_agent/media_agent_c.rs index 74414cb..15104fa 100644 --- a/src/media_agent/media_agent_c.rs +++ b/src/media_agent/media_agent_c.rs @@ -5,6 +5,9 @@ use crate::{ core::events::EngineEvent, log::log_sink::LogSink, media_agent::{ + audio_capture_worker::{AudioCaptureEvent, spawn_audio_capture_worker}, + audio_codec, + audio_player_worker::{AudioPlayerCommand, spawn_audio_player_worker}, camera_worker::spawn_camera_worker, decoder_event::DecoderEvent, decoder_worker::spawn_decoder_worker, @@ -48,26 +51,41 @@ pub struct MediaAgent { remote_frame: Arc>>, /// List of supported codecs and media types. supported_media: Vec, - + // --- Thread Handles --- decoder_handle: Option>, encoder_handle: Option>, listener_handle: Option>, camera_handle: Option>, - + audio_handle: Option>, + audio_player_handle: Option>, + /// Flag to track if we have successfully sent at least one keyframe. sent_any_frame: Arc, - + // --- Channels --- /// Channel to send events back to the listener loop from outside. media_agent_event_tx: Option>, /// Channel to send instructions to the encoder worker. ma_encoder_event_tx: Option>, - + /// Channel to send instructions to the audio player worker. + audio_player_tx: Option>, + running: Arc, + is_audio_muted: Arc, config: Arc, } +struct MediaAgentContext<'a> { + logger: &'a Arc, + ma_decoder_event_tx: &'a Sender, + ma_encoder_event_tx: &'a Sender, + audio_player_tx: &'a Sender, + media_transport_event_tx: &'a Sender, + remote_frame: &'a Arc>>, + config: &'a Arc, +} + impl MediaAgent { /// Creates a new `MediaAgent` instance. /// @@ -76,10 +94,16 @@ impl MediaAgent { pub fn new(logger: Arc, config: Arc) -> Self { let sent_any_frame = Arc::new(AtomicBool::new(false)); - let supported_media = vec![MediaSpec { - media_type: MediaType::Video, - codec_spec: CodecSpec::H264, - }]; + let supported_media = vec![ + MediaSpec { + media_type: MediaType::Video, + codec_spec: CodecSpec::H264, + }, + MediaSpec { + media_type: MediaType::Audio, + codec_spec: CodecSpec::G711U, + }, + ]; Self { logger, @@ -90,10 +114,14 @@ impl MediaAgent { encoder_handle: None, listener_handle: None, camera_handle: None, + audio_handle: None, + audio_player_handle: None, sent_any_frame, media_agent_event_tx: None, ma_encoder_event_tx: None, + audio_player_tx: None, running: Arc::new(AtomicBool::new(false)), + is_audio_muted: Arc::new(AtomicBool::new(false)), config, } } @@ -118,7 +146,7 @@ impl MediaAgent { ) -> Result<(), MediaAgentError> { let logger = self.logger.clone(); sink_debug!(logger, "[MediaAgent] Starting MediaAgent"); - + self.running.store(true, Ordering::SeqCst); let logger = self.logger.clone(); let running = self.running.clone(); @@ -144,12 +172,38 @@ impl MediaAgent { let (local_frame_rx, status, handle) = spawn_camera_worker(target_fps, logger.clone(), camera_id, running.clone()); sink_debug!(logger.clone(), "[MediaAgent] Camera Worker Started"); - + if let Some(msg) = status { let _ = event_tx.send(EngineEvent::Status(format!("[MediaAgent] {msg}"))); } self.camera_handle = handle; + // --- Start Audio Capture Worker --- + sink_debug!( + logger.clone(), + "[MediaAgent] Starting Audio Capture Worker..." + ); + let (audio_frame_rx, audio_handle) = spawn_audio_capture_worker( + logger.clone(), + running.clone(), + self.is_audio_muted.clone(), + ); + self.audio_handle = audio_handle; + sink_debug!(logger.clone(), "[MediaAgent] Audio Capture Worker Started"); + + // --- Start Audio Player Worker --- + let (audio_player_tx, audio_player_rx) = mpsc::channel(); + self.audio_player_tx = Some(audio_player_tx.clone()); + + sink_debug!( + logger.clone(), + "[MediaAgent] Starting Audio Player Worker..." + ); + let audio_player_handle = + spawn_audio_player_worker(logger.clone(), audio_player_rx, running.clone()); + self.audio_player_handle = Some(audio_player_handle); + sink_debug!(logger.clone(), "[MediaAgent] Audio Player Worker Started"); + // Setup internal channels let (ma_decoder_event_tx, ma_decoder_event_rx) = mpsc::channel::(); let (media_agent_event_tx, media_agent_event_rx) = mpsc::channel::(); @@ -171,7 +225,7 @@ impl MediaAgent { let (ma_encoder_event_tx, ma_encoder_event_rx) = mpsc::channel::(); let ma_encoder_event_tx_clone = ma_encoder_event_tx.clone(); self.ma_encoder_event_tx = Some(ma_encoder_event_tx_clone); - + sink_debug!(logger.clone(), "[MediaAgent] Starting Encoder Worker..."); let encoder_handle = spawn_encoder_worker( logger.clone(), @@ -189,9 +243,11 @@ impl MediaAgent { let listener_handle = Self::spawn_listener_thread( logger.clone(), local_frame_rx, + audio_frame_rx, media_agent_event_rx, ma_decoder_event_tx, ma_encoder_event_tx, + audio_player_tx, media_transport_event_tx, local_frame, remote_frame, @@ -230,6 +286,14 @@ impl MediaAgent { let _ = handle.join(); } + if let Some(handle) = self.audio_handle.take() { + let _ = handle.join(); + } + + if let Some(handle) = self.audio_player_handle.take() { + let _ = handle.join(); + } + self.sent_any_frame.store(false, Ordering::SeqCst); if let Ok(mut lf) = self.local_frame.lock() { @@ -248,6 +312,12 @@ impl MediaAgent { &self.supported_media } + pub fn set_audio_mute(&self, mute: bool) { + self.is_audio_muted.store(mute, Ordering::SeqCst); + let status = if mute { "muted" } else { "unmuted" }; + sink_info!(self.logger, "[MediaAgent] Microphone {}", status); + } + /// Enqueues an event into the MediaAgent's internal processing loop. pub fn post_event(&self, event: MediaAgentEvent) { if let Some(media_agent_event_tx) = self.media_agent_event_tx.clone() @@ -287,9 +357,11 @@ impl MediaAgent { fn spawn_listener_thread( logger: Arc, local_frame_rx: Receiver, + audio_frame_rx: Receiver, media_agent_event_rx: Receiver, ma_decoder_event_tx: Sender, ma_encoder_event_tx: Sender, + audio_player_tx: Sender, media_transport_event_tx: Sender, local_frame: Arc>>, remote_frame: Arc>>, @@ -304,9 +376,11 @@ impl MediaAgent { Self::listener_loop( logger, local_frame_rx, + audio_frame_rx, media_agent_event_rx, ma_decoder_event_tx, ma_encoder_event_tx, + audio_player_tx, media_transport_event_tx, local_frame, remote_frame, @@ -327,9 +401,11 @@ impl MediaAgent { fn listener_loop( logger: Arc, local_frame_rx: Receiver, + audio_frame_rx: Receiver, media_agent_event_rx: Receiver, ma_decoder_event_tx: Sender, ma_encoder_event_tx: Sender, + audio_player_tx: Sender, media_transport_event_tx: Sender, local_frame: Arc>>, remote_frame: Arc>>, @@ -347,18 +423,21 @@ impl MediaAgent { &sent_any_frame, ); + Self::drain_audio_frames(&logger, &audio_frame_rx, &media_transport_event_tx); + // Poll for other events with a short timeout to keep the loop responsive match media_agent_event_rx.recv_timeout(Duration::from_millis(5)) { Ok(event) => { - Self::handle_media_agent_event( - &logger, - event, - &ma_decoder_event_tx, - &ma_encoder_event_tx, - &media_transport_event_tx, - &remote_frame, - &config, - ); + let ctx = MediaAgentContext { + logger: &logger, + ma_decoder_event_tx: &ma_decoder_event_tx, + ma_encoder_event_tx: &ma_encoder_event_tx, + audio_player_tx: &audio_player_tx, + media_transport_event_tx: &media_transport_event_tx, + remote_frame: &remote_frame, + config: &config, + }; + Self::handle_media_agent_event(ctx, event); } Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => { @@ -404,6 +483,45 @@ impl MediaAgent { } } + fn drain_audio_frames( + logger: &Arc, + audio_frame_rx: &Receiver, + media_transport_event_tx: &Sender, + ) { + loop { + match audio_frame_rx.try_recv() { + Ok(event) => match event { + AudioCaptureEvent::Frame(frame) => { + sink_trace!( + logger, + "[MediaAgent] Received AudioFrame: ts={}, samples={}", + frame.timestamp_ms, + frame.samples + ); + + let encoded_payload = audio_codec::encode(&frame.data); + + let _ = media_transport_event_tx.send( + MediaTransportEvent::SendEncodedAudioFrame { + payload: encoded_payload, + timestamp_ms: frame.timestamp_ms, + codec_spec: CodecSpec::G711U, + }, + ); + } + AudioCaptureEvent::Error(e) => { + sink_warn!(logger, "[MediaAgent] Audio capture error: {}", e); + } + }, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + sink_debug!(logger, "[MediaAgent] audio capture worker disconnected"); + break; + } + } + } + } + /// Updates the local frame state and forwards the frame to the encoder. fn handle_local_frame( logger: &Arc, @@ -424,7 +542,7 @@ impl MediaAgent { let ts = frame.timestamp_ms; let instruction = EncoderInstruction::Encode(frame, force_keyframe); - + if ma_encoder_event_tx.send(instruction).is_err() { sink_error!( logger, @@ -441,30 +559,22 @@ impl MediaAgent { } /// Routes system events to their appropriate destinations. - fn handle_media_agent_event( - logger: &Arc, - event: MediaAgentEvent, - ma_decoder_event_tx: &Sender, - ma_encoder_event_tx: &Sender, - media_transport_event_tx: &Sender, - remote_frame: &Arc>>, - config: &Arc, - ) { + fn handle_media_agent_event(ctx: MediaAgentContext, event: MediaAgentEvent) { match event { MediaAgentEvent::DecodedVideoFrame(frame) => { - sink_info!(logger, "[MediaAgent] Received DecodedVideoFrame"); + sink_trace!(ctx.logger, "[MediaAgent] Received DecodedVideoFrame"); let frame = *frame; let ts = frame.timestamp_ms; - + // Update remote UI snapshot - if let Ok(mut guard) = remote_frame.lock() { + if let Ok(mut guard) = ctx.remote_frame.lock() { *guard = Some(frame); } else { - sink_warn!(logger, "[MediaAgent] failed to update remote frame"); + sink_warn!(ctx.logger, "[MediaAgent] failed to update remote frame"); return; } sink_debug!( - logger, + ctx.logger, "[MediaAgent] updated remote frame snapshot (ts={ts})" ); } @@ -474,15 +584,16 @@ impl MediaAgent { codec_spec, } => { sink_trace!( - logger, + ctx.logger, "[MediaAgent] encoded frame ready for transport (ts={timestamp_ms})" ); sink_debug!( - logger, + ctx.logger, "[MediaAgent] Received EncodedVideoFrame from Encoder. Now sending SendEncodedFrame to Media Transport" ); // Forward to network layer - if media_transport_event_tx + if ctx + .media_transport_event_tx .send(MediaTransportEvent::SendEncodedFrame { annexb_frame, timestamp_ms, @@ -491,45 +602,69 @@ impl MediaAgent { .is_err() { sink_warn!( - logger, + ctx.logger, "[MediaAgent] media transport channel dropped encoded frame" ); } } MediaAgentEvent::AnnexBFrameReady { codec_spec, bytes } => { sink_trace!( - logger, + ctx.logger, "[MediaAgent] forwarding AnnexB payload to decoder ({:?})", codec_spec ); // Forward to decoder worker - if ma_decoder_event_tx + if ctx + .ma_decoder_event_tx .send(DecoderEvent::AnnexBFrameReady { codec_spec, bytes }) .is_err() { sink_warn!( - logger, + ctx.logger, "[MediaAgent] decoder worker offline, dropping AnnexB frame" ); } } MediaAgentEvent::UpdateBitrate(b) => { - let fps = config + let fps = ctx + .config .get("Media", "fps") .and_then(|s| s.parse().ok()) .unwrap_or(TARGET_FPS); - let keyint = config + let keyint = ctx + .config .get("Media", "keyframe_interval") .and_then(|s| s.parse().ok()) .unwrap_or(KEYINT); - + let instruction = EncoderInstruction::SetConfig { fps, bitrate: b, keyint, }; - if ma_encoder_event_tx.send(instruction).is_ok() { - sink_debug!(logger, "Reconfigured H264 encoder: bitrate={}bps", b,); + if ctx.ma_encoder_event_tx.send(instruction).is_ok() { + sink_debug!(ctx.logger, "Reconfigured H264 encoder: bitrate={}bps", b,); + } + } + MediaAgentEvent::EncodedAudioFrame { + payload, + codec_spec, + } => { + sink_trace!( + ctx.logger, + "[MediaAgent] Decoding audio frame ({:?})", + codec_spec + ); + let decoded_samples = audio_codec::decode(&payload); + if let Err(e) = ctx + .audio_player_tx + .send(AudioPlayerCommand::PlayFrame(decoded_samples)) + { + sink_error!( + ctx.logger, + "[MediaAgent] Failed to send PlayFrame command: {}", + e + ); } } } diff --git a/src/media_agent/mod.rs b/src/media_agent/mod.rs index 314cc98..b1f7709 100644 --- a/src/media_agent/mod.rs +++ b/src/media_agent/mod.rs @@ -1,3 +1,8 @@ +pub mod audio_capture_error; +pub mod audio_capture_worker; +pub mod audio_codec; +pub mod audio_frame; +pub mod audio_player_worker; pub mod camera_worker; pub mod constants; pub mod decoder_event; @@ -9,7 +14,7 @@ pub mod frame_format; pub mod h264_decoder; mod h264_encoder; pub mod media_agent_c; -mod media_agent_error; +pub mod media_agent_error; pub mod spec; pub mod utils; pub mod video_frame; diff --git a/src/media_agent/spec.rs b/src/media_agent/spec.rs index 73eb605..2e36b65 100644 --- a/src/media_agent/spec.rs +++ b/src/media_agent/spec.rs @@ -1,13 +1,22 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MediaType { Video, - // Audio, + Audio, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CodecSpec { H264, - // Opus, + G711U, +} + +impl CodecSpec { + pub fn media_type(&self) -> MediaType { + match self { + CodecSpec::H264 => MediaType::Video, + CodecSpec::G711U => MediaType::Audio, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/src/media_agent/video_frame.rs b/src/media_agent/video_frame.rs index 0090de2..45d0263 100644 --- a/src/media_agent/video_frame.rs +++ b/src/media_agent/video_frame.rs @@ -90,7 +90,7 @@ impl VideoFrame { pub fn synthetic_yuv420(width: u32, height: u32, tick: u8) -> Self { let w = width as usize; let h = height as usize; - + // Calculate dimensions and strides (tightly packed) let y_stride = w; let uv_w = w.div_ceil(2); diff --git a/src/media_transport/codec.rs b/src/media_transport/codec.rs index 29e85c7..dcda0b2 100644 --- a/src/media_transport/codec.rs +++ b/src/media_transport/codec.rs @@ -9,17 +9,17 @@ use crate::{media_agent::spec::CodecSpec, rtp_session::rtp_codec::RtpCodec}; pub struct CodecDescriptor { /// The human-readable name of the codec (e.g., "H264", "OPUS"). pub codec_name: &'static str, - + /// The RTP-specific configuration (Payload Type, Clock Rate, etc.). pub rtp_representation: RtpCodec, - + /// The SDP `fmtp` (Format Parameter) line. /// /// This string contains specific configuration parameters negotiated via SDP. /// For H.264, this includes the Profile-Level-ID and Packetization Mode. /// Example: `"profile-level-id=42e01f;packetization-mode=1"` pub sdp_fmtp: Option, - + /// The internal enum identifier used by the `MediaAgent` logic. pub spec: CodecSpec, } @@ -52,4 +52,13 @@ impl CodecDescriptor { spec: CodecSpec::H264, } } + + pub fn pcmu_dynamic(pt: u8) -> Self { + Self { + codec_name: "PCMU", + rtp_representation: RtpCodec::with_name(pt, 8000, "PCMU"), + sdp_fmtp: None, + spec: CodecSpec::G711U, + } + } } diff --git a/src/media_transport/depacketizer_worker.rs b/src/media_transport/depacketizer_worker.rs index 7ad089a..0498dd5 100644 --- a/src/media_transport/depacketizer_worker.rs +++ b/src/media_transport/depacketizer_worker.rs @@ -10,6 +10,7 @@ use std::{ use crate::media_transport::{codec::CodecDescriptor, events::DepacketizerEvent}; use crate::{ log::log_sink::LogSink, + media_agent::spec::CodecSpec, media_transport::{ depacketizer::h264_depacketizer::H264Depacketizer, media_transport_event::RtpIn, }, @@ -66,7 +67,7 @@ pub fn spawn_depacketizer_worker( pkt.ssrc, pkt.seq ); - + // 1. Verify if this Payload Type is currently negotiated/allowed. let ok_pt = allowed_pts .read() @@ -92,19 +93,29 @@ pub fn spawn_depacketizer_worker( pkt.seq ); - // 3. Feed the packet into the reassembly logic. - // The depacketizer returns `Some(bytes)` only when a full frame is complete. - if let Some(annex_b_frame) = - depacketizer.push_rtp(&pkt.payload, pkt.marker, pkt.timestamp_90khz, pkt.seq) - { - sink_trace!( - logger, - "[Depacketizer] AnnexBFrameReady sending it to DepcketizerEventLoop (MT)" - ); - let _ = event_tx.send(DepacketizerEvent::AnnexBFrameReady { - codec_spec: codec_desc.spec, - bytes: annex_b_frame, - }); + match codec_desc.spec { + CodecSpec::H264 => { + // 3. Feed the packet into the reassembly logic. + // The depacketizer returns `Some(bytes)` only when a full frame is complete. + if let Some(annex_b_frame) = + depacketizer.push_rtp(&pkt.payload, pkt.marker, pkt.timestamp_90khz, pkt.seq) + { + sink_trace!( + logger, + "[Depacketizer] AnnexBFrameReady sending it to DepcketizerEventLoop (MT)" + ); + let _ = event_tx.send(DepacketizerEvent::AnnexBFrameReady { + codec_spec: codec_desc.spec, + bytes: annex_b_frame, + }); + } + } + CodecSpec::G711U => { + let _ = event_tx.send(DepacketizerEvent::EncodedAudioFrameReady { + codec_spec: codec_desc.spec, + payload: pkt.payload, + }); + } } } }) diff --git a/src/media_transport/event_loops/depacketizer_event_loop.rs b/src/media_transport/event_loops/depacketizer_event_loop.rs index e36a159..8c9ffe3 100644 --- a/src/media_transport/event_loops/depacketizer_event_loop.rs +++ b/src/media_transport/event_loops/depacketizer_event_loop.rs @@ -68,7 +68,7 @@ impl DepacketizerEventLoop { let handle = std::thread::spawn(move || { while !stop_flag.load(Ordering::SeqCst) { const TIMEOUT: Duration = Duration::from_millis(RECV_TIMEOUT); - + // Use recv_timeout to ensure we can check the `stop_flag` periodically // even if no video traffic is incoming. match depacketizer_event_rx.recv_timeout(TIMEOUT) { @@ -83,6 +83,19 @@ impl DepacketizerEventLoop { media_agent_event_tx .send(MediaAgentEvent::AnnexBFrameReady { codec_spec, bytes }) } + DepacketizerEvent::EncodedAudioFrameReady { + codec_spec, + payload, + } => { + sink_trace!( + logger, + "[DepacketizerEventLoop (MT)] Received EncodedAudioFrameReady. Sending it to MediaAgent" + ); + media_agent_event_tx.send(MediaAgentEvent::EncodedAudioFrame { + codec_spec, + payload, + }) + } }; } @@ -110,7 +123,7 @@ impl DepacketizerEventLoop { ); running_flag.store(false, Ordering::SeqCst); }); - + self.running_flag.store(true, Ordering::SeqCst); self.event_loop_handler = Some(handle); } diff --git a/src/media_transport/event_loops/media_agent_event_loop.rs b/src/media_transport/event_loops/media_agent_event_loop.rs index 9238c05..091a6b3 100644 --- a/src/media_transport/event_loops/media_agent_event_loop.rs +++ b/src/media_transport/event_loops/media_agent_event_loop.rs @@ -89,8 +89,11 @@ impl MediaAgentEventLoop { let handle = std::thread::spawn(move || { let mut last_received_local_ts_ms = None; + let mut last_received_audio_ts_ms = None; + // Initialize random start timestamp for security/standard compliance. - let mut rtp_ts = rand::random::(); + let mut video_rtp_ts = rand::random::(); + let mut audio_rtp_ts = rand::random::(); while !stop_flag.load(Ordering::SeqCst) { match media_transport_event_rx.recv_timeout(Duration::from_millis(RECV_TIMEOUT)) { @@ -113,22 +116,49 @@ impl MediaAgentEventLoop { // Construct the order for the packetizer worker let order = PacketizeOrder { - annexb_frame, - rtp_ts, // Assign the monotonic RTP timestamp + payload: annexb_frame, + rtp_ts: video_rtp_ts, // Assign the monotonic RTP timestamp codec_spec, }; - + sink_trace!( logger.clone(), "[MT Event Loop MA] Sending PacketizeOrder to Packetizer." ); - + // Send to Packetizer and increment timestamp for the next frame if packetizer_order_tx.send(order).is_ok() { - rtp_ts = rtp_ts.wrapping_add(rtp_ts_step); + video_rtp_ts = video_rtp_ts.wrapping_add(rtp_ts_step); } } - + + // --- Egress Audio Path --- + MediaTransportEvent::SendEncodedAudioFrame { + payload, + timestamp_ms, + codec_spec, + } => { + sink_debug!( + logger.clone(), + "[MT Event Loop MA] Received SendEncodedAudioFrame." + ); + if last_received_audio_ts_ms == Some(timestamp_ms) { + continue; + } + last_received_audio_ts_ms = Some(timestamp_ms); + + let order = PacketizeOrder { + payload, + rtp_ts: audio_rtp_ts, + codec_spec, + }; + + if packetizer_order_tx.send(order).is_ok() { + // 160 samples per frame for 20ms @ 8kHz + audio_rtp_ts = audio_rtp_ts.wrapping_add(160); + } + } + // --- Raw Packet Forwarding --- MediaTransportEvent::RtpIn(pkt) => { sink_trace!( @@ -142,7 +172,7 @@ impl MediaAgentEventLoop { MediaTransportEvent::Established => { sink_info!(logger, "[MediaAgent Event Loop (MT)] Received Established"); let mut sess_guard = session.lock().expect("session lock poisoned"); - + if let Some(sess) = sess_guard.as_mut() { // 1. Register outbound tracks (SSRCs) in the RTP session if let Err(e) = ensure_outbound_tracks( @@ -243,16 +273,16 @@ fn ensure_outbound_tracks( let mut guard = outbound_tracks .lock() .expect("outbound_tracks lock poisoned"); - + if guard.contains_key(pt) { continue; } - + // Register new track with the underlying RTP session let handle = session .register_outbound_track(codec.rtp_representation.clone()) .map_err(|e| MediaTransportError::Send(e.to_string()))?; - + sink_debug!( logger, "[ensure_outbound_tracks] Adding outbound track PT {} ({:?})", diff --git a/src/media_transport/event_loops/packetizer_event_loop.rs b/src/media_transport/event_loops/packetizer_event_loop.rs index d56ec87..f7c0a64 100644 --- a/src/media_transport/event_loops/packetizer_event_loop.rs +++ b/src/media_transport/event_loops/packetizer_event_loop.rs @@ -125,7 +125,7 @@ impl PacketizerEventLoop { logger, "[Packetizer Event Loop (MT)] Using Session to send frame" ); - + // Actual network IO happens here if let Some(sess) = sess_guard.as_mut() && let Err(e) = sess.send_rtp_chunks_for_frame( @@ -167,7 +167,7 @@ impl PacketizerEventLoop { ); running_flag.store(false, Ordering::SeqCst); }); - + self.running_flag.store(true, Ordering::SeqCst); self.event_loop_handler = Some(handle); } diff --git a/src/media_transport/events.rs b/src/media_transport/events.rs index d3c8f72..4b42468 100644 --- a/src/media_transport/events.rs +++ b/src/media_transport/events.rs @@ -8,6 +8,10 @@ pub enum DepacketizerEvent { codec_spec: CodecSpec, bytes: Vec, }, + EncodedAudioFrameReady { + codec_spec: CodecSpec, + payload: Vec, + }, } #[derive(Debug)] diff --git a/src/media_transport/media_transport_c.rs b/src/media_transport/media_transport_c.rs index 70fbf16..c75d42e 100644 --- a/src/media_transport/media_transport_c.rs +++ b/src/media_transport/media_transport_c.rs @@ -27,6 +27,7 @@ use std::{ thread::JoinHandle, }; +const DEFAULT_AUDIO_PT: u8 = 0; /// The high-level orchestrator that bridges the Application Layer (`MediaAgent`) /// and the Network Layer (`RtpSession`). /// @@ -39,10 +40,10 @@ pub struct MediaTransport { logger: Arc, /// Channel to bubble up critical status events to the main engine. event_tx: Sender, - + /// The application-side logic (Camera, Encoder, Decoder). media_agent: MediaAgent, - + // --- Event Loops (Logic Processors) --- media_agent_event_loop: MediaAgentEventLoop, depacketizer_event_loop: DepacketizerEventLoop, @@ -83,7 +84,7 @@ impl MediaTransport { .get("Media", "fps") .and_then(|s| s.parse().ok()) .unwrap_or(TARGET_FPS); - + let media_agent_event_loop = MediaAgentEventLoop::new(target_fps, logger.clone()); let depacketizer_event_loop = DepacketizerEventLoop::new(logger.clone()); let packetizer_event_loop = PacketizerEventLoop::new(logger.clone()); @@ -92,6 +93,24 @@ impl MediaTransport { let media_transport_event_tx = Some(mt_event_tx); let media_transport_event_rx = Some(mt_event_rx); + // Build Payload Map (Negotiate Codecs) + let mut payload_map_inner = HashMap::new(); + let mut current_pt = DYNAMIC_PAYLOAD_TYPE_START; + + for spec in media_agent.supported_media() { + let codec_descriptor = match spec.codec_spec { + CodecSpec::H264 => CodecDescriptor::h264_dynamic(current_pt), + CodecSpec::G711U => CodecDescriptor::pcmu_dynamic(DEFAULT_AUDIO_PT), + }; + let pt = codec_descriptor.rtp_representation.payload_type; + payload_map_inner.insert(pt, codec_descriptor); + + if pt >= DYNAMIC_PAYLOAD_TYPE_START { + current_pt += 1; + } + } + let payload_map = Arc::new(payload_map_inner); + Self { logger, media_agent, @@ -102,7 +121,7 @@ impl MediaTransport { rtp_tx: None, depacketizer_handle: None, packetizer_handle: None, - payload_map: Arc::new(HashMap::new()), + payload_map, outbound_tracks: Arc::new(Mutex::new(HashMap::new())), allowed_pts: None, media_transport_event_tx, @@ -145,22 +164,13 @@ impl MediaTransport { } // 2. Build Payload Map (Negotiate Codecs) - let mut payload_map_inner = HashMap::new(); - let mut current_pt = DYNAMIC_PAYLOAD_TYPE_START; - - for spec in self.media_agent.supported_media() { - let codec_descriptor = match spec.codec_spec { - CodecSpec::H264 => CodecDescriptor::h264_dynamic(current_pt), - }; - payload_map_inner.insert(current_pt, codec_descriptor); - current_pt += 1; - } + // Already built in new() + let payload_map = self.payload_map.clone(); - let payload_map = Arc::new(payload_map_inner); let (rtp_tx, rtp_rx) = mpsc::sync_channel::(RTP_TX_CHANNEL_SIZE); let rtp_tx_clone = rtp_tx; self.rtp_tx = Some(rtp_tx_clone); - + let allowed_pts = Arc::new(RwLock::new( payload_map.keys().copied().collect::>(), )); @@ -168,7 +178,6 @@ impl MediaTransport { self.allowed_pts = Some(allowed_pts_clone); let payload_map_for_worker = payload_map.clone(); - self.payload_map = payload_map; // 3. Start Depacketizer (Ingress) let (depacketizer_event_tx, depacketizer_event_rx) = mpsc::channel(); @@ -179,7 +188,7 @@ impl MediaTransport { depacketizer_event_tx, payload_map_for_worker.clone(), )); - + // Connect Depacketizer output -> MediaAgent input if let Some(media_agent_event_tx) = self.media_agent.media_agent_event_tx() { self.depacketizer_event_loop @@ -254,6 +263,10 @@ impl MediaTransport { self.media_transport_event_tx.clone() } + pub fn set_audio_mute(&self, mute: bool) { + self.media_agent.set_audio_mute(mute); + } + /// Stops all threads and cleans up resources. /// /// This stops the `MediaAgent` first, then the transport event loops, diff --git a/src/media_transport/media_transport_event.rs b/src/media_transport/media_transport_event.rs index e746f31..85a0689 100644 --- a/src/media_transport/media_transport_event.rs +++ b/src/media_transport/media_transport_event.rs @@ -17,6 +17,11 @@ pub enum MediaTransportEvent { timestamp_ms: u128, codec_spec: CodecSpec, }, + SendEncodedAudioFrame { + payload: Vec, + timestamp_ms: u128, + codec_spec: CodecSpec, + }, UpdateBitrate(u32), Established, Closed, diff --git a/src/media_transport/packetizer_worker.rs b/src/media_transport/packetizer_worker.rs index 7e8f08a..e2a0a73 100644 --- a/src/media_transport/packetizer_worker.rs +++ b/src/media_transport/packetizer_worker.rs @@ -15,8 +15,8 @@ use crate::{log::log_sink::LogSink, media_agent::spec::CodecSpec, sink_trace}; /// Represents a request sent to the Packetizer worker to process a frame. #[derive(Debug)] pub struct PacketizeOrder { - /// The raw encoded video data (e.g., H.264 Annex B stream with Start Codes). - pub annexb_frame: Vec, + /// The raw encoded media data (Video: Annex B, Audio: Raw payload). + pub payload: Vec, /// The RTP timestamp assigned to this frame. /// This timestamp will be shared by all RTP packets generated from this single frame. pub rtp_ts: u32, @@ -76,30 +76,47 @@ pub fn spawn_packetizer_worker( logger.clone(), "[Packetizer] Received Order" ); - + match order.codec_spec { CodecSpec::H264 => { // Performs the slicing (identifies NAL boundaries, handles FU-A) let chunks = - h264_packetizer.packetize_annexb_to_payloads(&order.annexb_frame); - + h264_packetizer.packetize_annexb_to_payloads(&order.payload); + if !chunks.is_empty() { let packetized_frame = PacketizedFrame { chunks, rtp_ts: order.rtp_ts, codec_spec: order.codec_spec, }; - + sink_trace!( logger.clone(), "[Packetizer] Sending PacketizedFrame to MediaTranport Packetizer Event Loop" ); - + // Forward the chunks to the next stage (RTP encapsulation) let _ = event_tx.send(PacketizerEvent::FramePacketized(packetized_frame)); } } + CodecSpec::G711U => { + let packetized_frame = PacketizedFrame { + chunks: vec![RtpPayloadChunk { + bytes: order.payload, + marker: true, + }], + rtp_ts: order.rtp_ts, + codec_spec: order.codec_spec, + }; + + sink_trace!( + logger.clone(), + "[Packetizer] Sending Audio PacketizedFrame" + ); + + let _ = event_tx.send(PacketizerEvent::FramePacketized(packetized_frame)); + } } } }) diff --git a/src/rtp_session/rtp_session_c.rs b/src/rtp_session/rtp_session_c.rs index 394356b..4eb1f06 100644 --- a/src/rtp_session/rtp_session_c.rs +++ b/src/rtp_session/rtp_session_c.rs @@ -70,7 +70,6 @@ impl RtpSession { initial_send: Vec, srtp_cfg: Option, ) -> Result { - // Inicializar contextos SRTP si hay configuración let (srtp_inbound, srtp_outbound) = if let Some(srtp_session_cfg) = &srtp_cfg { ( Some(Arc::new(Mutex::new(SrtpContext::new( diff --git a/src/sctp/debug_utils.rs b/src/sctp/debug_utils.rs new file mode 100644 index 0000000..2a1c5e6 --- /dev/null +++ b/src/sctp/debug_utils.rs @@ -0,0 +1,106 @@ +use byteorder::{BigEndian, ReadBytesExt}; +use std::io::Cursor; +use std::io::Seek; + +/// Parses a raw SCTP packet and returns a summary string (TSN, SSN, etc.). +/// This is used for debug logging. +pub fn parse_sctp_packet_summary(packet: &[u8]) -> String { + let mut cursor = Cursor::new(packet); + + // Skip Common Header (Source Port(2) + Dest Port(2) + Verification Tag(4) + Checksum(4)) + if packet.len() < 12 { + return "Invalid SCTP Packet (too short)".to_string(); + } + cursor.set_position(12); + + let mut summary = String::new(); + let mut chunk_count = 0; + + // Parse Chunks + while cursor.position() < packet.len() as u64 { + // Chunk Header: Type(1) + Flags(1) + Length(2) + let Ok(chunk_type) = cursor.read_u8() else { + break; + }; + let Ok(_chunk_flags) = cursor.read_u8() else { + break; + }; + let Ok(chunk_length) = cursor.read_u16::() else { + break; + }; + + if chunk_length < 4 { + summary.push_str(&format!("[BadChunkLen:{}]", chunk_length)); + break; + } + + // DATA Chunk Type is 0 + if chunk_type == 0 { + // DATA Chunk: + // TSN(4) + Stream ID(2) + SSN(2) + PPI(4) + Payload Data... + if chunk_length < 16 { + summary.push_str("[BadDataChunk]"); + } else { + if let Ok(tsn) = cursor.read_u32::() { + if let Ok(sid) = cursor.read_u16::() { + if let Ok(ssn) = cursor.read_u16::() { + // Skipping PPI(4) to just advance + let _ = cursor.read_u32::(); // PPI + summary + .push_str(&format!("[DATA:TSN={},SID={},SSN={}]", tsn, sid, ssn)); + } + } + } + } + } else if chunk_type == 6 { + summary.push_str("[ABORT]"); + } else if chunk_type == 7 { + summary.push_str("[SHUTDOWN]"); + } else if chunk_type == 14 { + summary.push_str("[SHUTDOWN_ACK]"); + } else if chunk_type == 1 { + summary.push_str("[INIT]"); + } else if chunk_type == 2 { + summary.push_str("[INIT_ACK]"); + } else if chunk_type == 3 { + summary.push_str("[SACK]"); + } else if chunk_type == 4 { + summary.push_str("[HEARTBEAT]"); + } else if chunk_type == 5 { + summary.push_str("[HEARTBEAT_ACK]"); + } else { + summary.push_str(&format!("[Type:{}]", chunk_type)); + } + + chunk_count += 1; + + // Move to next chunk (padded to 4 bytes boundary) + let current_pos = cursor.position(); + + let bytes_read_in_body = if chunk_type == 0 { 12 } else { 0 }; + let remaining_in_chunk = (chunk_length as i64) - 4 - bytes_read_in_body; + + if remaining_in_chunk < 0 { + // Should not happen if length check passed, but safety first + break; + } + + if let Err(_) = cursor.seek(std::io::SeekFrom::Current(remaining_in_chunk)) { + break; + } + + // Padding + let padding = (4 - (chunk_length % 4)) % 4; + if padding > 0 { + if let Err(_) = cursor.seek(std::io::SeekFrom::Current(padding as i64)) { + break; + } + } + } + + if summary.is_empty() { + summary = "Empty/NoChunks".to_string(); + } + + format!("Count:{} Details:{}", chunk_count, summary) +} diff --git a/src/sctp/events.rs b/src/sctp/events.rs new file mode 100644 index 0000000..ea8ff40 --- /dev/null +++ b/src/sctp/events.rs @@ -0,0 +1,29 @@ +#[derive(Debug, Clone)] +pub struct SctpFileProperties { + pub file_name: String, + pub file_size: u64, + pub transaction_id: u32, +} + +#[derive(Debug, Clone)] +pub enum SctpEvents { + SendAccept { id: u32 }, + SendCancel { id: u32 }, + SendChunk { file_id: u32, payload: Vec }, + SendEndFile { id: u32 }, + SendOffer { file_properties: SctpFileProperties }, + SendReject { id: u32 }, + IncomingSctpPacket { sctp_packet: Vec }, + ReadableSctpPacket { sctp_packet: Vec }, + ReceivedOffer { file_properties: SctpFileProperties }, + ReceivedAccept { id: u32 }, + ReceivedReject { id: u32 }, + ReceivedCancel { id: u32 }, + ReceivedChunk { id: u32, seq: u32, payload: Vec }, + ReceivedEndFile { id: u32 }, + SctpConnected, + SctpErr(String), + TransmitSctpPacket { payload: Vec }, + KickSender, + Shutdown, +} diff --git a/src/sctp/mod.rs b/src/sctp/mod.rs new file mode 100644 index 0000000..707b5e8 --- /dev/null +++ b/src/sctp/mod.rs @@ -0,0 +1,8 @@ +pub mod debug_utils; +pub mod events; +pub mod protocol; +pub mod receiver; +pub mod sctp_session; +pub mod sender; +pub mod stream; +pub mod transport; diff --git a/src/sctp/protocol.rs b/src/sctp/protocol.rs new file mode 100644 index 0000000..4151e04 --- /dev/null +++ b/src/sctp/protocol.rs @@ -0,0 +1,133 @@ +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use std::io::{Cursor, Read, Write}; + +#[derive(Debug, Clone, PartialEq)] +pub enum SctpProtocolMessage { + Offer { + id: u32, + filename: String, + file_size: u64, + }, + Accept { + id: u32, + }, + Reject { + id: u32, + }, + Cancel { + id: u32, + }, + Chunk { + id: u32, + seq: u64, + payload: Vec, + }, + EndFile { + id: u32, + }, +} + +impl SctpProtocolMessage { + const TYPE_OFFER: u8 = 1; + const TYPE_ACCEPT: u8 = 2; + const TYPE_REJECT: u8 = 3; + const TYPE_CANCEL: u8 = 4; + const TYPE_CHUNK: u8 = 5; + const TYPE_END_FILE: u8 = 6; + + pub fn serialize(&self) -> Result, std::io::Error> { + let mut buf = Vec::new(); + match self { + SctpProtocolMessage::Offer { + id, + filename, + file_size, + } => { + buf.write_u8(Self::TYPE_OFFER)?; + buf.write_u32::(*id)?; + buf.write_u64::(*file_size)?; + let filename_bytes = filename.as_bytes(); + buf.write_u16::(filename_bytes.len() as u16)?; + buf.write_all(filename_bytes)?; + } + SctpProtocolMessage::Accept { id } => { + buf.write_u8(Self::TYPE_ACCEPT)?; + buf.write_u32::(*id)?; + } + SctpProtocolMessage::Reject { id } => { + buf.write_u8(Self::TYPE_REJECT)?; + buf.write_u32::(*id)?; + } + SctpProtocolMessage::Cancel { id } => { + buf.write_u8(Self::TYPE_CANCEL)?; + buf.write_u32::(*id)?; + } + SctpProtocolMessage::Chunk { id, seq, payload } => { + buf.write_u8(Self::TYPE_CHUNK)?; + buf.write_u32::(*id)?; + buf.write_u64::(*seq)?; + buf.write_u32::(payload.len() as u32)?; + buf.write_all(payload)?; + } + SctpProtocolMessage::EndFile { id } => { + buf.write_u8(Self::TYPE_END_FILE)?; + buf.write_u32::(*id)?; + } + } + Ok(buf) + } + + pub fn deserialize(data: &[u8]) -> Result { + // println!("[CLI DEBUG] SctpProtocolMessage::deserialize len={}", data.len()); + let mut cursor = Cursor::new(data); + let msg_type = cursor.read_u8()?; + + match msg_type { + Self::TYPE_OFFER => { + let id = cursor.read_u32::()?; + let file_size = cursor.read_u64::()?; + let filename_len = cursor.read_u16::()?; + let mut filename_bytes = vec![0u8; filename_len as usize]; + cursor.read_exact(&mut filename_bytes)?; + let filename = String::from_utf8(filename_bytes) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + Ok(SctpProtocolMessage::Offer { + id, + filename, + file_size, + }) + } + Self::TYPE_ACCEPT => { + let id = cursor.read_u32::()?; + Ok(SctpProtocolMessage::Accept { id }) + } + Self::TYPE_REJECT => { + let id = cursor.read_u32::()?; + Ok(SctpProtocolMessage::Reject { id }) + } + Self::TYPE_CANCEL => { + let id = cursor.read_u32::()?; + Ok(SctpProtocolMessage::Cancel { id }) + } + Self::TYPE_CHUNK => { + let id = cursor.read_u32::()?; + let seq = cursor.read_u64::()?; + let payload_len = cursor.read_u32::()?; + let mut payload = vec![0u8; payload_len as usize]; + cursor.read_exact(&mut payload)?; + Ok(SctpProtocolMessage::Chunk { id, seq, payload }) + } + Self::TYPE_END_FILE => { + let id = cursor.read_u32::()?; + Ok(SctpProtocolMessage::EndFile { id }) + } + unknown_type => { + println!("[CLI DEBUG] Unknown SCTP message type: {}", unknown_type); + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Unknown message type: {}", unknown_type), + )) + } + } + } +} diff --git a/src/sctp/receiver.rs b/src/sctp/receiver.rs new file mode 100644 index 0000000..22260b9 --- /dev/null +++ b/src/sctp/receiver.rs @@ -0,0 +1,412 @@ +use crate::log::log_sink::LogSink; +use crate::sctp::events::SctpEvents; +use crate::sctp::stream::SctpStream; +use crate::{sink_debug, sink_error, sink_info, sink_trace, sink_warn}; +use bytes::Bytes; +use sctp_proto::{ + Association, AssociationHandle, DatagramEvent, Endpoint, Event, Payload, StreamEvent, +}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::{Duration, Instant}; + +pub struct SctpReceiver { + pub log_sink: Arc, + pub tx: Sender, + pub rx: Receiver, + pub streams: Arc>>, + pub endpoint: Arc>, + pub association: Arc>>, + pub association_handle: Arc>>, +} + +impl SctpReceiver { + pub fn new( + log_sink: Arc, + tx: Sender, + rx: Receiver, + streams: Arc>>, + endpoint: Arc>, + association: Arc>>, + association_handle: Arc>>, + ) -> Self { + Self { + log_sink, + tx, + rx, + streams, + endpoint, + association, + association_handle, + } + } + + #[allow(clippy::expect_used)] + pub fn run(&self) { + loop { + // Determine timeout for sctp + let timeout = { + let mut assoc_guard = self.association.lock().expect("association lock poisoned"); + if let Some(assoc) = assoc_guard.as_mut() { + assoc + .poll_timeout() + .map(|inst| inst.saturating_duration_since(Instant::now())) + } else { + None + } + }; + + // Wait for event or timeout + let wait_duration = timeout.unwrap_or(Duration::from_millis(100)); + + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] wait_duration before: {:?}", + wait_duration + ); + + // Cap wait duration to check stream timeouts frequently (e.g. every 1 sec) + let wait_duration = wait_duration.min(Duration::from_secs(2)); + + let event = self.rx.recv_timeout(wait_duration); + + match event { + Ok(SctpEvents::ReadableSctpPacket { sctp_packet }) => { + let start = Instant::now(); + self.handle_packet(sctp_packet); + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Processed ReadableSctpPacket in {:?}", + start.elapsed() + ); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + sink_trace!(self.log_sink, "[SCTP_RECEIVER] Timeout",); + // Handle SCTP timeout if needed + let mut assoc_guard = + self.association.lock().expect("association lock poisoned"); + if let Some(assoc) = assoc_guard.as_mut() { + // Check if it was really an SCTP timeout or just our loop cap + if let Some(next_timeout) = assoc.poll_timeout() + && Instant::now() >= next_timeout + { + assoc.handle_timeout(Instant::now()); + } + } + drop(assoc_guard); + self.poll_association(); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + break; + } + _ => {} + } + + self.check_stream_timeouts(); + } + } + + #[allow(clippy::expect_used)] + fn check_stream_timeouts(&self) { + let mut timed_out_ids = Vec::new(); + { + let streams = self.streams.read().expect("streams lock poisoned"); + for (id, stream) in streams.iter() { + if stream.is_timed_out() { + timed_out_ids.push(*id); + } + } + } + + for id in timed_out_ids { + sink_warn!( + self.log_sink, + "[SCTP_RECEIVER] Stream {} timed out, sending Cancel", + id + ); + let _ = self.tx.send(SctpEvents::SendCancel { id }); + } + } + + #[allow(clippy::expect_used)] + fn handle_packet(&self, packet: Vec) { + let start = Instant::now(); + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Handling incoming SCTP packet of size {}", + packet.len() + ); + crate::sctp_log!( + self.log_sink, + "SCTP_PACKET_IN: {}", + crate::sctp::debug_utils::parse_sctp_packet_summary(&packet) + ); + sink_debug!( + self.log_sink, + "[SCTP_RECEIVER] SCTP bytes received via DTLS: {}", + packet.len() + ); + let mut endpoint = self.endpoint.lock().expect("Failed to lock endpoint"); + let now = Instant::now(); + // Use a dummy address as we are tunneling over DTLS + let remote: SocketAddr = "192.168.1.1:5000" + .parse() + .expect("Invalid dummy IP address"); + + let bytes = Bytes::from(packet); + + match endpoint.handle(now, remote, None, None, bytes) { + Some((handle, DatagramEvent::NewAssociation(assoc))) => { + sink_info!( + self.log_sink, + "[SCTP_RECEIVER] New SCTP Association created" + ); + { + let mut my_assoc = self.association.lock().expect("association lock poisoned"); + *my_assoc = Some(assoc); + let mut my_handle = self + .association_handle + .lock() + .expect("association handle lock poisoned"); + *my_handle = Some(handle); + } + } + Some((_handle, DatagramEvent::AssociationEvent(event))) => { + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Endpoint returned AssociationEvent: {:?}", + event + ); + let mut my_assoc_guard = + self.association.lock().expect("association lock poisoned"); + if let Some(assoc) = my_assoc_guard.as_mut() { + assoc.handle_event(event); + sink_trace!(self.log_sink, "[SCTP_RECEIVER] Association handled event"); + } else { + sink_warn!( + self.log_sink, + "[SCTP_RECEIVER] Received AssociationEvent but no association exists" + ); + } + drop(my_assoc_guard); // unlock to poll + } + None => { + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Packet processed by Endpoint (No User Event)" + ); + // Packet consumed, no event. + } + } + drop(endpoint); + self.poll_association(); + let _ = self.tx.send(SctpEvents::KickSender); + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] handle_packet took {:?}", + start.elapsed() + ); + } + + #[allow(clippy::expect_used)] + fn poll_association(&self) { + let start = Instant::now(); + let mut assoc_guard = self.association.lock().expect("association lock poisoned"); + if let Some(assoc) = assoc_guard.as_mut() { + let now = Instant::now(); + + // Poll transmit + while let Some(transmit) = assoc.poll_transmit(now) { + if let Payload::RawEncode(bytes_vec) = transmit.payload { + for b in bytes_vec { + let payload = b.to_vec(); + crate::sctp_log!( + self.log_sink, + "SCTP_PACKET_OUT: {}", + crate::sctp::debug_utils::parse_sctp_packet_summary(&payload) + ); + let _ = self.tx.send(SctpEvents::TransmitSctpPacket { payload }); + } + } + } + + // Poll events + while let Some(event) = assoc.poll() { + match event { + Event::Connected => { + sink_info!(self.log_sink, "[SCTP_RECEIVER] SCTP Association connected"); + let _ = self.tx.send(SctpEvents::SctpConnected); + } + Event::AssociationLost { reason } => { + sink_error!( + self.log_sink, + "[SCTP_RECEIVER] SCTP Association lost: {:?}", + reason + ); + } + Event::Stream(StreamEvent::Readable { id }) => { + // Read from stream + if let Ok(mut stream) = assoc.stream(id) { + match stream.read_sctp() { + Ok(Some(chunks)) => { + let mut buf = vec![0u8; 65535]; + match chunks.read(&mut buf) { + Ok(len) => { + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Stream {} readable. Read {} bytes.", + id, + len + ); + let data = Bytes::copy_from_slice(&buf[..len]); + self.handle_chunk_data(data); + } + Err(e) => { + sink_warn!( + self.log_sink, + "[SCTP_RECEIVER] Error reading chunks: {:?}", + e + ); + } + } + } + Ok(None) => {} + Err(e) => { + sink_warn!( + self.log_sink, + "[SCTP_RECEIVER] Error reading from stream {}: {:?}", + id, + e + ); + } + } + } else { + sink_warn!( + self.log_sink, + "[SCTP_RECEIVER] Stream {} readable but failed to get stream handle", + id + ); + } + } + _ => {} + } + } + } + let elapsed = start.elapsed(); + if elapsed.as_micros() > 100 { + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] poll_association took {:?}", + elapsed + ); + } + } + + #[allow(clippy::expect_used)] + fn handle_chunk_data(&self, data: Bytes) { + use crate::sctp::protocol::SctpProtocolMessage; + + match SctpProtocolMessage::deserialize(&data) { + Ok(msg) => { + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Deserialized message: {:?}", + msg + ); + match msg { + SctpProtocolMessage::Offer { + id, + filename, + file_size, + } => { + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Received Offer for file_id: {}", + id + ); + let props = crate::sctp::events::SctpFileProperties { + file_name: filename, + file_size, + transaction_id: id, + }; + let _ = self.tx.send(SctpEvents::ReceivedOffer { + file_properties: props, + }); + } + SctpProtocolMessage::Accept { id } => { + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Received Accept for file_id: {}", + id + ); + let _ = self.tx.send(SctpEvents::ReceivedAccept { id }); + } + SctpProtocolMessage::Reject { id } => { + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Received Reject for file_id: {}", + id + ); + let _ = self.tx.send(SctpEvents::ReceivedReject { id }); + } + SctpProtocolMessage::Cancel { id } => { + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Received Cancel for file_id: {}", + id + ); + let _ = self.tx.send(SctpEvents::ReceivedCancel { id }); + } + SctpProtocolMessage::Chunk { id, seq, payload } => { + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Received Chunk for file_id: {} seq: {}", + id, + seq + ); + crate::sctp_log!( + self.log_sink, + "ReceiveChunk: FileID:{} Seq:{} Size:{}", + id, + seq, + payload.len() + ); + sink_debug!( + self.log_sink, + "[SCTP_RECEIVER] File bytes received: {}", + payload.len() + ); + { + let mut streams = self.streams.write().expect("streams lock poisoned"); + if let Some(stream) = streams.get_mut(&id) { + stream.update_activity(); + } + } + let _ = self.tx.send(SctpEvents::ReceivedChunk { + id, + seq: seq as u32, + payload, + }); + } + SctpProtocolMessage::EndFile { id } => { + sink_trace!( + self.log_sink, + "[SCTP_RECEIVER] Received EndFile for file_id: {}", + id + ); + let _ = self.tx.send(SctpEvents::ReceivedEndFile { id }); + } + } + } + Err(e) => { + sink_warn!( + self.log_sink, + "[SCTP_RECEIVER] Failed to deserialize SCTP message: {:?}", + e + ); + } + } + } +} diff --git a/src/sctp/sctp_session.rs b/src/sctp/sctp_session.rs new file mode 100644 index 0000000..84d664f --- /dev/null +++ b/src/sctp/sctp_session.rs @@ -0,0 +1,157 @@ +use crate::dtls::buffered_udp_channel::BufferedUdpChannel; +use crate::log::log_sink::LogSink; +use crate::sctp::events::SctpEvents; +use crate::sctp::receiver::SctpReceiver; +use crate::sctp::sender::SctpSender; +use crate::sctp::stream::SctpStream; +use crate::sctp::transport::SctpTransport; +use openssl::ssl::SslStream; +use sctp_proto::{Association, AssociationHandle, Endpoint, EndpointConfig, ServerConfig}; +use std::collections::HashMap; +use std::sync::mpsc::{Sender, channel}; +use std::sync::{Arc, Mutex, RwLock}; +use std::thread; + +pub struct SctpSession { + pub tx: Sender, + association: Arc>>, +} + +impl SctpSession { + pub fn new( + log_sink: Arc, + parent_tx: Sender, + ssl_stream: SslStream, + is_client: bool, + ) -> Self { + let (tx, rx) = channel(); + + // Channels for internal threads + let (tx_receiver, rx_receiver) = channel(); + let (tx_sender, rx_sender) = channel(); + let (tx_transport, rx_transport) = channel(); + + // Shared state + let streams = Arc::new(RwLock::new(HashMap::::new())); + let association = Arc::new(Mutex::new(None::)); + let association_handle = Arc::new(Mutex::new(None::)); + + // Init Endpoint + let mut config = EndpointConfig::default(); + config.max_payload_size(1200); + let server_config = ServerConfig::default(); + // Wrap config in Arc as required by Endpoint::new + let endpoint = Endpoint::new(Arc::new(config), Some(Arc::new(server_config))); + let endpoint = Arc::new(Mutex::new(endpoint)); + + // Receiver + let receiver = SctpReceiver::new( + log_sink.clone(), + tx.clone(), // Receiver sends events back to Router via main tx + rx_receiver, + streams.clone(), + endpoint.clone(), + association.clone(), + association_handle.clone(), + ); + + // Sender + let sender = SctpSender::new( + log_sink.clone(), + tx.clone(), // Sender sends TransmitSctpPacket back to Router via main tx + rx_sender, + association.clone(), + association_handle.clone(), + streams.clone(), + endpoint.clone(), + is_client, + ); + + // Transport + let transport = SctpTransport::new( + ssl_stream, + log_sink.clone(), + tx.clone(), // Transport sends ReadableSctpPacket back to Router via main tx + rx_transport, + ); + + // Spawn threads + thread::spawn(move || receiver.run()); + thread::spawn(move || sender.run()); + thread::spawn(move || transport.run()); + + // Router Thread (Main) + let tx_receiver_clone = tx_receiver.clone(); + let tx_sender_clone = tx_sender.clone(); + let tx_transport_clone = tx_transport.clone(); + + thread::spawn(move || { + while let Ok(event) = rx.recv() { + match event { + SctpEvents::SctpConnected => { + let _ = tx_sender_clone.send(event); + } + SctpEvents::IncomingSctpPacket { .. } => { + let _ = tx_transport_clone.send(event); + } + SctpEvents::ReadableSctpPacket { .. } => { + let _ = tx_receiver_clone.send(event); + } + SctpEvents::SendOffer { .. } + | SctpEvents::SendAccept { .. } + | SctpEvents::SendReject { .. } + | SctpEvents::SendCancel { .. } + | SctpEvents::SendChunk { .. } + | SctpEvents::SendEndFile { .. } + | SctpEvents::KickSender => { + let _ = tx_sender_clone.send(event); + } + SctpEvents::TransmitSctpPacket { .. } => { + let _ = tx_transport_clone.send(event); + } + SctpEvents::ReceivedAccept { id } => { + // Router redirects to Sender + let _ = tx_sender_clone.send(SctpEvents::ReceivedAccept { id }); + // And potentially parent? + let _ = parent_tx.send(SctpEvents::ReceivedAccept { id }); + } + SctpEvents::ReceivedOffer { .. } + | SctpEvents::ReceivedReject { .. } + | SctpEvents::ReceivedCancel { .. } + | SctpEvents::ReceivedChunk { .. } + | SctpEvents::ReceivedEndFile { .. } + | SctpEvents::SctpErr(_) => { + // Forward to parent + let _ = parent_tx.send(event); + } + SctpEvents::Shutdown => { + break; + } + } + } + }); + + Self { tx, association } + } + + pub fn shutdown(&self) { + let _ = self.tx.send(SctpEvents::Shutdown); + } + + pub fn handle_sctp_packet(&self, packet: Vec) { + let _ = self.tx.send(SctpEvents::IncomingSctpPacket { + sctp_packet: packet, + }); + } + + pub fn buffered_amount(&self) -> usize { + if let Ok(mut guard) = self.association.lock() { + if let Some(assoc) = guard.as_mut() { + if let Ok(stream) = assoc.stream(0) { + return stream.buffered_amount().unwrap_or(0); + } + } + } + 0 + } +} diff --git a/src/sctp/sender.rs b/src/sctp/sender.rs new file mode 100644 index 0000000..54749ad --- /dev/null +++ b/src/sctp/sender.rs @@ -0,0 +1,412 @@ +use crate::log::log_sink::LogSink; +use crate::sctp::events::SctpEvents; +use crate::sctp::protocol::SctpProtocolMessage; +use crate::sctp::stream::SctpStream; +use crate::{sink_debug, sink_error, sink_info, sink_trace, sink_warn}; +use bytes::Bytes; +use sctp_proto::{ + Association, AssociationHandle, ClientConfig, Endpoint, Error, Payload, + PayloadProtocolIdentifier, +}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Instant; + +pub struct SctpSender { + pub log_sink: Arc, + pub tx: Sender, + pub rx: Receiver, + pub association: Arc>>, + pub association_handle: Arc>>, + pub streams: Arc>>, + pub endpoint: Arc>, + pub is_client: bool, +} + +impl SctpSender { + pub fn new( + log_sink: Arc, + tx: Sender, + rx: Receiver, + association: Arc>>, + association_handle: Arc>>, + streams: Arc>>, + endpoint: Arc>, + is_client: bool, + ) -> Self { + Self { + log_sink, + tx, + rx, + association, + association_handle, + streams, + endpoint, + is_client, + } + } + + #[allow(clippy::expect_used)] + pub fn run(&self) { + let mut pending_messages = Vec::new(); + use std::time::Duration; + + // Ensure SCTP association is started immediately + self.ensure_connection(); + + loop { + // Determine timeout for sctp + let timeout = { + let mut assoc_guard = self.association.lock().expect("association lock poisoned"); + if let Some(assoc) = assoc_guard.as_mut() { + assoc + .poll_timeout() + .map(|inst| inst.saturating_duration_since(Instant::now())) + } else { + None + } + }; + + // Wait for event or timeout + let wait_duration = timeout.unwrap_or(Duration::from_millis(100)); + // Cap wait duration + let wait_duration = wait_duration.min(Duration::from_secs(1)); + + let event = self.rx.recv_timeout(wait_duration); + + match event { + Ok(SctpEvents::SendOffer { file_properties }) => { + sink_trace!( + self.log_sink, + "[SCTP_SENDER] Processing SendOffer for id: {}", + file_properties.transaction_id + ); + // Create Stream entry for tracking chunks + { + let stream = SctpStream::new(file_properties.clone()); + let mut streams = self.streams.write().expect("streams lock poisoned"); + streams.insert(file_properties.transaction_id, stream); + } + + let msg = SctpProtocolMessage::Offer { + id: file_properties.transaction_id, + filename: file_properties.file_name, + file_size: file_properties.file_size, + }; + self.send_message(msg, &mut pending_messages); + } + Ok(SctpEvents::SendAccept { id }) => { + sink_trace!( + self.log_sink, + "[SCTP_SENDER] Processing SendAccept for id: {}", + id + ); + // Create Stream + { + let props = crate::sctp::events::SctpFileProperties { + file_name: "".to_string(), + file_size: 0, + transaction_id: id, + }; + let stream = SctpStream::new(props); + let mut streams = self.streams.write().expect("streams lock poisoned"); + streams.insert(id, stream); + } + self.send_message(SctpProtocolMessage::Accept { id }, &mut pending_messages); + } + Ok(SctpEvents::ReceivedAccept { id }) => { + sink_trace!( + self.log_sink, + "[SCTP_SENDER] Processing ReceivedAccept for id: {}", + id + ); + let props = crate::sctp::events::SctpFileProperties { + file_name: "".to_string(), + file_size: 0, + transaction_id: id, + }; + let stream = SctpStream::new(props); + let mut streams = self.streams.write().expect("streams lock poisoned"); + streams.insert(id, stream); + } + Ok(SctpEvents::SendReject { id }) => { + sink_trace!( + self.log_sink, + "[SCTP_SENDER] Processing SendReject for id: {}", + id + ); + self.send_message(SctpProtocolMessage::Reject { id }, &mut pending_messages); + } + Ok(SctpEvents::SendCancel { id }) => { + sink_trace!( + self.log_sink, + "[SCTP_SENDER] Processing SendCancel for id: {}", + id + ); + { + let mut streams = self.streams.write().expect("streams lock poisoned"); + streams.remove(&id); + } + self.send_message(SctpProtocolMessage::Cancel { id }, &mut pending_messages); + } + Ok(SctpEvents::KickSender) => { + sink_trace!( + self.log_sink, + "[SCTP_SENDER] KickSender received, waking up" + ); + } + Ok(SctpEvents::SendChunk { file_id, payload }) => { + let start_chunk = Instant::now(); + let seq = { + let mut streams = self.streams.write().expect("streams lock poisoned"); + if let Some(stream) = streams.get_mut(&file_id) { + let s = stream.next_seq; + stream.next_seq += 1; + stream.update_activity(); + Some(s) + } else { + None + } + }; + + if let Some(s) = seq { + sink_trace!( + self.log_sink, + "[SCTP_SENDER] Sending Chunk seq {} for file_id: {}", + s, + file_id + ); + let payload_len = payload.len(); + crate::sctp_log!( + self.log_sink, + "SendChunk: FileID:{} Seq:{} Size:{}", + file_id, + s, + payload_len + ); + self.send_message( + SctpProtocolMessage::Chunk { + id: file_id, + seq: s, + payload, + }, + &mut pending_messages, + ); + sink_debug!( + self.log_sink, + "[SCTP_SENDER] File bytes sent to SCTP: {}", + payload_len + ); + sink_trace!( + self.log_sink, + "[SCTP_SENDER] Processed SendChunk in {:?}", + start_chunk.elapsed() + ); + } else { + sink_warn!( + self.log_sink, + "[SCTP_SENDER] Attempted to send chunk for unknown stream {}", + file_id + ); + } + } + Ok(SctpEvents::SendEndFile { id }) => { + sink_trace!( + self.log_sink, + "[SCTP_SENDER] Processing SendEndFile for id: {}", + id + ); + { + let mut streams = self.streams.write().expect("streams lock poisoned"); + streams.remove(&id); + } + self.send_message(SctpProtocolMessage::EndFile { id }, &mut pending_messages); + } + Ok(SctpEvents::SctpConnected) => { + sink_info!( + self.log_sink, + "[SCTP_SENDER] SCTP Connected, flushing {} pending messages", + pending_messages.len() + ); + let messages_to_send = std::mem::take(&mut pending_messages); + for msg in messages_to_send { + self.send_message(msg, &mut pending_messages); + } + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // Timeout expired, poll association below + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + break; + } + _ => {} + } + + // Periodic poll for timeouts and retransmissions + { + let mut assoc_guard = self.association.lock().expect("association lock poisoned"); + if let Some(assoc) = assoc_guard.as_mut() { + let now = Instant::now(); + + // Handle timeouts + if let Some(next_timeout) = assoc.poll_timeout() + && now >= next_timeout + { + assoc.handle_timeout(now); + } + + // Poll transmit + let start_poll = Instant::now(); + while let Some(transmit) = assoc.poll_transmit(now) { + if let Payload::RawEncode(bytes_vec) = transmit.payload { + for b in bytes_vec { + let payload = b.to_vec(); + crate::sctp_log!( + self.log_sink, + "SCTP_PACKET_OUT: {}", + crate::sctp::debug_utils::parse_sctp_packet_summary(&payload) + ); + let _ = self.tx.send(SctpEvents::TransmitSctpPacket { payload }); + } + } + } + let elapsed_poll = start_poll.elapsed(); + if elapsed_poll.as_micros() > 100 { + sink_trace!( + self.log_sink, + "[SCTP_SENDER] poll_transmit took {:?}", + elapsed_poll + ); + } + } + } + } + } + + #[allow(clippy::expect_used)] + fn ensure_connection(&self) { + let mut assoc_guard = self.association.lock().expect("association lock poisoned"); + if assoc_guard.is_none() { + if !self.is_client { + // If we are server, we wait for incoming connection (handled by Receiver) + return; + } + sink_info!( + self.log_sink, + "[SCTP_SENDER] Initiating SCTP association (ensure_connection)..." + ); + let mut endpoint = self.endpoint.lock().expect("endpoint lock poisoned"); + let remote: SocketAddr = "192.168.1.1:5000" + .parse() + .expect("Invalid dummy IP address"); + let mut config = ClientConfig::default(); + match endpoint.connect(config, remote) { + Ok((handle, assoc)) => { + *assoc_guard = Some(assoc); + let mut handle_guard = self + .association_handle + .lock() + .expect("association handle lock poisoned"); + *handle_guard = Some(handle); + } + Err(e) => { + sink_error!( + self.log_sink, + "[SCTP_SENDER] Failed to initiate SCTP association: {:?}", + e + ); + } + } + } + } + + #[allow(clippy::expect_used)] + fn send_message(&self, msg: SctpProtocolMessage, pending: &mut Vec) { + let start = Instant::now(); + let payload = match msg.serialize() { + Ok(p) => p, + Err(e) => { + sink_error!( + self.log_sink, + "[SCTP_SENDER] Failed to serialize SCTP message: {:?}", + e + ); + return; + } + }; + + self.ensure_connection(); + + let mut assoc_guard = self.association.lock().expect("association lock poisoned"); + if let Some(assoc) = assoc_guard.as_mut() { + // Use Stream 0 for all messages. + let stream_id = 0; + + let bytes = Bytes::from(payload); + + // Try to get stream, if not, open it + let stream_handle = match assoc.stream(stream_id) { + Ok(s) => Ok(s), + Err(_) => assoc.open_stream(stream_id, PayloadProtocolIdentifier::Binary), + }; + + if let Ok(mut stream) = stream_handle { + if let Err(e) = stream.write(&bytes) { + if e == Error::ErrPayloadDataStateNotExist { + sink_info!( + self.log_sink, + "[SCTP_SENDER] Connection not ready, queuing message" + ); + pending.push(msg); + } else { + sink_warn!( + self.log_sink, + "[SCTP_SENDER] Error writing to SCTP stream: {:?}", + e + ); + } + } + } else { + sink_warn!( + self.log_sink, + "[SCTP_SENDER] Failed to get or open SCTP stream {}", + stream_id + ); + } + + // Poll transmit to send the packet + let now = Instant::now(); + while let Some(transmit) = assoc.poll_transmit(now) { + if let Payload::RawEncode(bytes_vec) = transmit.payload { + for b in bytes_vec { + let payload = b.to_vec(); + sink_debug!( + self.log_sink, + "[SCTP_SENDER] SCTP bytes sent to DTLS: {}", + payload.len() + ); + crate::sctp_log!( + self.log_sink, + "SCTP_PACKET_OUT: {}", + crate::sctp::debug_utils::parse_sctp_packet_summary(&payload) + ); + let _ = self.tx.send(SctpEvents::TransmitSctpPacket { payload }); + } + } + } + } else { + sink_warn!( + self.log_sink, + "[SCTP_SENDER] Attempted to send message but no SCTP association exists" + ); + pending.push(msg); + } + sink_trace!( + self.log_sink, + "[SCTP_SENDER] send_message took {:?}", + start.elapsed() + ); + } +} diff --git a/src/sctp/stream.rs b/src/sctp/stream.rs new file mode 100644 index 0000000..4476c59 --- /dev/null +++ b/src/sctp/stream.rs @@ -0,0 +1,29 @@ +use crate::sctp::events::SctpFileProperties; +use std::time::{Duration, Instant}; + +#[derive(Debug)] +pub struct SctpStream { + pub properties: SctpFileProperties, + pub last_activity: Instant, + pub next_seq: u64, + pub timeout: Duration, +} + +impl SctpStream { + pub fn new(properties: SctpFileProperties) -> Self { + Self { + properties, + last_activity: Instant::now(), + next_seq: 0, + timeout: Duration::from_secs(10), // Default timeout + } + } + + pub fn update_activity(&mut self) { + self.last_activity = Instant::now(); + } + + pub fn is_timed_out(&self) -> bool { + self.last_activity.elapsed() > self.timeout + } +} diff --git a/src/sctp/transport.rs b/src/sctp/transport.rs new file mode 100644 index 0000000..2ab4fc8 --- /dev/null +++ b/src/sctp/transport.rs @@ -0,0 +1,159 @@ +use crate::dtls::buffered_udp_channel::BufferedUdpChannel; +use crate::log::log_sink::LogSink; +use crate::sctp::events::SctpEvents; +use crate::{sink_debug, sink_error, sink_trace}; +use openssl::ssl::SslStream; +use std::io::{Read, Write}; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, Sender}; + +pub struct SctpTransport { + ssl_stream: SslStream, + log_sink: Arc, + router_tx: Sender, + rx: Receiver, +} + +impl SctpTransport { + pub fn new( + ssl_stream: SslStream, + log_sink: Arc, + router_tx: Sender, + rx: Receiver, + ) -> Self { + // Set manual mode on the channel so we don't race with Session's socket reading + let mut stream = ssl_stream; + stream.get_mut().set_manual_mode(true); + Self { + ssl_stream: stream, + log_sink, + router_tx, + rx, + } + } + + pub fn run(mut self) { + sink_debug!(self.log_sink, "[SctpTransport] Started"); + let mut buf = [0u8; 65535]; + + loop { + // Determine if we need to busy-wait (poll) or block + // Note: We check buffered_udp_channel's queue. + // ssl_stream -> BufferedUdpChannel + let has_pending = self.ssl_stream.get_mut().has_pending_writes(); + + let event_result = if has_pending { + // If we have pending writes, we don't want to block forever. + // We use a small timeout to allow flushing retries. + self.rx.recv_timeout(std::time::Duration::from_millis(1)) + } else { + // No pending writes, we can block until new events arrive + self.rx + .recv() + .map_err(|_| std::sync::mpsc::RecvTimeoutError::Disconnected) + }; + + let first_event = match event_result { + Ok(ev) => Some(ev), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => None, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + }; + + if let Some(event) = first_event { + let mut batch = Vec::with_capacity(16); + batch.push(event); + batch.extend(self.rx.try_iter()); + + // Bulk Injection & Processing + for event in batch { + match event { + SctpEvents::IncomingSctpPacket { sctp_packet } => { + // Packet from UDP socket (via Session) + sink_trace!( + self.log_sink, + "[SctpTransport] Received IncomingSctpPacket len={}", + sctp_packet.len() + ); + // Push to internal queue (Bulk Injection) + self.ssl_stream.get_mut().push_incoming(sctp_packet); + } + SctpEvents::TransmitSctpPacket { payload } => { + // Encrypt and send + let start_write = std::time::Instant::now(); + if let Err(e) = self.ssl_stream.write_all(&payload) { + sink_error!( + self.log_sink, + "[SctpTransport] DTLS write error: {}", + e + ); + } + sink_trace!( + self.log_sink, + "[SCTP_TRANSPORT] DTLS write time: {:?}", + start_write.elapsed() + ); + crate::sctp_log!( + self.log_sink, + "DTLS_ENCRYPT/SEND_START: {}", + payload.len() + ); + } + _ => {} + } + } + } + + // Always try to read/flush after event processing (or timeout) + // Optimized Read Loop & Flush + let mut read_count = 0; + loop { + // Try to flush outgoing queue first + if let Err(e) = self.ssl_stream.get_mut().flush() { + sink_error!(self.log_sink, "[SctpTransport] Flush error: {}", e); + } + + if read_count >= 20 { + // Yield to event loop to allow sending responses (SACKs) + break; + } + + let start = std::time::Instant::now(); + match self.ssl_stream.read(&mut buf) { + Ok(n) => { + read_count += 1; + let elapsed = start.elapsed(); + if n > 0 { + sink_trace!( + self.log_sink, + "[SCTP_TRANSPORT] DTLS decryption time: {:?} (decrypted {} bytes)", + elapsed, + n + ); + crate::sctp_log!( + self.log_sink, + "DTLS_DECRYPT/RECV_END: {} (Time: {:?})", + n, + elapsed + ); + let decrypted = buf[..n].to_vec(); + // Send to Router + let _ = self.router_tx.send(SctpEvents::ReadableSctpPacket { + sctp_packet: decrypted, + }); + } else { + break; + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + break; + } + Err(e) => { + sink_error!(self.log_sink, "[SctpTransport] DTLS read error: {}", e); + break; + } + } + } + } + sink_debug!(self.log_sink, "[SctpTransport] Stopped"); + } +} diff --git a/src/sdp/sdpc.rs b/src/sdp/sdpc.rs index bf65fa4..e555437 100644 --- a/src/sdp/sdpc.rs +++ b/src/sdp/sdpc.rs @@ -42,7 +42,7 @@ use crate::sdp::time_desc::TimeDesc; /// - `i=`, `c=`, `b=`, `a=` lines are applied to the **current media** if we are /// inside an `m=` section; otherwise they apply at the **session** level. /// - `r=` and `z=` are attached to the **last** `t=` block. -/// - Any unknown session-level lines are preserved verbatim in [`extra_lines`]. +/// - Any unknown session-level lines are preserved verbatim in [`Sdp::extra_lines`]. #[derive(Debug, Clone)] pub struct Sdp { /// `v=` — SDP version (per spec this is always `0`). diff --git a/src/signaling/protocol/framing.rs b/src/signaling/protocol/framing.rs index 4d6f123..0fda3f7 100644 --- a/src/signaling/protocol/framing.rs +++ b/src/signaling/protocol/framing.rs @@ -1,7 +1,7 @@ use super::{PROTO_VERSION, ProtoError, errors::FrameError, msg_type::MsgType}; use std::io::{self, Read, Write}; -/// Write a single frame: [ver][type][reserved u16=0][len u32][body...] +/// Write a single frame: `[ver][type][reserved u16=0][len u32][body...]` /// /// # Errors /// diff --git a/tests/repro_sctp.rs b/tests/repro_sctp.rs new file mode 100644 index 0000000..9015d17 --- /dev/null +++ b/tests/repro_sctp.rs @@ -0,0 +1,66 @@ +use byteorder::{BigEndian, WriteBytesExt}; +use rustyrtc::sctp::debug_utils::parse_sctp_packet_summary; +use std::io::Write; + +#[test] +fn test_repro_missing_padding_theory() { + let mut packet = Vec::new(); + + // 1. Common Header (12 bytes) + packet.write_u16::(5000).unwrap(); + packet.write_u16::(5000).unwrap(); + packet.write_u32::(0).unwrap(); + packet.write_u32::(0).unwrap(); + + // 2. Chunk 1: DATA + // We want Length % 4 == 1, so padding is 3. + // Let's say payload data is 5 bytes. + // Header(4) + TSN(4)+SID(2)+SSN(2)+PPI(4) + Data(5) = 21 bytes. + // Length field = 21 (0x0015). + // 21 % 4 = 1. Padding needed = 3. + let chunk_len = 21; + packet.write_u8(0).unwrap(); // Type DATA + packet.write_u8(0).unwrap(); // Flags + packet.write_u16::(chunk_len).unwrap(); // Length + + packet.write_u32::(2092334635).unwrap(); // TSN from log + packet.write_u16::(0).unwrap(); // SID + packet.write_u16::(1).unwrap(); // SSN + packet.write_u32::(0).unwrap(); // PPI + + packet.write_all(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE]).unwrap(); // 5 bytes data + + // CRITICAL: DO NOT WRITE PADDING. + // Spec requires 3 bytes of padding here. We skip it to simulate the bug. + + // 3. Chunk 2: DATA (Simulated) + // We want Header bytes to be misread. + // Expected Header: Type(0) Flags(0) Len(1043=0x0413) ... + // Bytes: 00 00 04 13 ... + // Parser seeks 3 bytes (thinking they are padding). + // Parser skips 00 00 04. + // Parser reads 13 as Type. 13 hex = 19 decimal. + packet.write_u8(0).unwrap(); // Type + packet.write_u8(0).unwrap(); // Flags + packet.write_u16::(1043).unwrap(); // Length 0x0413 + packet.write_u32::(123456).unwrap(); // TSN + packet.write_u16::(0).unwrap(); // SID + packet.write_u16::(2).unwrap(); // SSN + packet.write_u32::(0).unwrap(); // PPI + // Payload... + packet.write_all(&vec![0u8; 1000]).unwrap(); + + let summary = parse_sctp_packet_summary(&packet); + println!("Summary: {}", summary); + + // Verify if we reproduced the Type:19 error + assert!( + summary.contains("Type:19"), + "Did not reproduce Type:19. Got: {}", + summary + ); + assert!( + summary.contains("DATA:TSN=2092334635"), + "First chunk not parsed?" + ); +}