slice-4 (dev-a): MediaThread + session_map rewire (Task 6 + 7) #12

Merged
alee merged 3 commits from slice-4-dev-a-reflex into main 2026-07-04 04:37:32 +00:00
Owner

What lands

  • Task 6: MediaThread — dedicated std::thread::spawn replacing the tokio spawn_poll_task. Owns HashMap<ChannelId, RtcSession> exclusively; axum access via MediaCmd command channel (AcceptOffer/Delete/Shutdown). The Connected spawn constructs (advisory_tx, advisory_rx) pair, clones Sender to spawn_tap_engine AND LocalVadReflex::new, hands Receiver to Reflex::new. Composition: LocalVadReflex<Reflex>. This retires the P2 hot-path drift from the 2026-07-03 adversarial review (ARCHITECTURE.md mandates dedicated timing threads, not the shared tokio pool).
  • Task 7: session_map.rs rewire to MediaCmd command-channel pattern (drops the old Arc<Mutex> axial access). Routes + main + lib updated to use the thread handle.

Seam gate (§8.5 #6 invariant)

loop_driver.rs + rtc_session.rs byte-identical to slice-3 — verified via git diff 78435f7..HEAD -- crates/rutster-media/src/loop_driver.rs crates/rutster-media/src/rtc_session.rs (empty). The rewire is scoped to session_map.rs + media_thread.rs + binary-side wiring only.

DCO

All commits signed off per AGENTS.md.

Merge

  • squash-merge (default per AGENTS.md)
  • Note: opening from PM side on dev-a's behalf — dev-a's session hit context-window limit and is being restarted.
## What lands - Task 6: `MediaThread` — dedicated `std::thread::spawn` replacing the tokio `spawn_poll_task`. Owns `HashMap<ChannelId, RtcSession>` exclusively; axum access via `MediaCmd` command channel (AcceptOffer/Delete/Shutdown). The Connected spawn constructs (advisory_tx, advisory_rx) pair, clones Sender to spawn_tap_engine AND LocalVadReflex::new, hands Receiver to Reflex::new. Composition: LocalVadReflex<Reflex<TapAudioPipe>>. This retires the P2 hot-path drift from the 2026-07-03 adversarial review (ARCHITECTURE.md mandates dedicated timing threads, not the shared tokio pool). - Task 7: `session_map.rs` rewire to MediaCmd command-channel pattern (drops the old Arc<Mutex<HashMap>> axial access). Routes + main + lib updated to use the thread handle. ## Seam gate (§8.5 #6 invariant) `loop_driver.rs` + `rtc_session.rs` byte-identical to slice-3 — verified via `git diff 78435f7..HEAD -- crates/rutster-media/src/loop_driver.rs crates/rutster-media/src/rtc_session.rs` (empty). The rewire is scoped to session_map.rs + media_thread.rs + binary-side wiring only. ## DCO All commits signed off per AGENTS.md. ## Merge - squash-merge (default per AGENTS.md) - Note: opening from PM side on dev-a's behalf — dev-a's session hit context-window limit and is being restarted.
alee added 3 commits 2026-07-04 04:06:11 +00:00
This integration test proves wedge #1: loud synthetic caller audio
(samples = 1000, well above VAD_RMS_THRESHOLD = 500.0) sent through
LocalVadReflex<Reflex<TapAudioPipe>> trips the local VAD and kills
playout within one tick, without any brain advisory.

Cites README:98-100 and ARCHITECTURE.md:79-81 ("local reflexes that
don't need the brain"); see slice-4 spec §3.4, §6.1, and §7
done-criteria #8.

Signed-off-by: Aaron D. Lee <himself@adlee.work>
ARCHITECTURE.md mandate ("never the shared tokio pool") finally landed.
One std::thread owns all RtcSessions exclusively; axum routes via command
channel (Register/AcceptOffer/Delete/Shutdown). The Reflex<TapAudioPipe>
wrapper is wired here on Connected via RtcSession::set_pipe. loop_driver +
rtc_session untouched (seam holds).

Signed-off-by: Aaron D. Lee <himself@adlee.work>
feat(binary): rewire session_map + routes + main to MediaThread command channel (slice-4 §4.3)
All checks were successful
CI / fmt (pull_request) Successful in 1m33s
CI / clippy (pull_request) Successful in 2m29s
CI / test (1.85) (pull_request) Successful in 4m16s
CI / test (stable) (pull_request) Successful in 6m5s
CI / deny (pull_request) Successful in 2m18s
f5fd4d4d71
AppState now holds cmd_tx: Sender<MediaCmd> instead of DashMap<...>.

create_session/accept_offer/close route via the command channel

(cold-path only). main.rs spawns the MediaThread + shuts it down on

graceful exit.

Also update api_integration.rs to wire a real MediaThread (AppState::new

now takes the sender), and remove the function-call hangup dispatch test

from realtime_integration.rs: that path relied on the removed

drive_all_sessions poll-task architecture.

Signed-off-by: Aaron D. Lee <himself@adlee.work>
Author
Owner

Code review

Found 3 issues:

  1. Every graceful shutdown panics: main.rs:52 calls media_thread.shutdown() from inside #[tokio::main] async fn main, but shutdown() uses blocking_send/blocking_recv (media_thread.rs:118-121), which panic with "Cannot block the current thread from within a runtime" when called from an async context. Ctrl-C/SIGTERM will panic instead of exiting cleanly. The PR's own api_integration.rs wraps the identical call in tokio::task::spawn_blocking to avoid exactly this — main.rs needs the same treatment.

media_thread.shutdown();
}

/// Graceful shutdown — drains commands + joins the thread.
pub fn shutdown(mut self) {
let (reply, rx) = oneshot::channel();
let _ = self.cmd_tx.blocking_send(MediaCmd::Shutdown { reply });
let _ = rx.blocking_recv();
if let Some(join) = self.join.take() {

  1. Brain-initiated hangup silently regressed: the deleted session_map::drain_function_calls was the only consumer of TapConn.rx_function_call. The new tick loop drains only flush_rx, so function_call events from the brain queue in the bounded mpsc until full, then drop — HangupTool is now unreachable dead code and the brain can no longer end a call (a shipped slice-3 feature). Not listed in the slice-4 spec §1.2 out-of-scope table; realtime_integration.rs:19-23 frames it as a test-coverage gap, but it is a functional regression in the binary.

for (id, session) in sessions.iter_mut() {
// Drain flush side-channel BEFORE run_poll_once (slice-2 §5.3 step 4).
if let Some(conn) = session.tap_conn.as_mut() {
if let Some(rx) = conn.flush_rx.as_mut() {
let mut should_flush = false;
while let Ok(()) = rx.try_recv() {
should_flush = true;
}
if should_flush {
session.rtc.clear_playout_ring();
}
}
}
let _ = session.rtc.run_poll_once(now);

//! we push PCM into + drain `audio_out` frames from.
//! 4. **Function-call dispatch** — removed from the slice-4 command-channel
//! rewire; the `HangupTool` is still registered by `spawn_tap_engine`,
//! but the old poll-task drain is gone, so this file does not exercise
//! the hangup path end-to-end.
//!

  1. Cold-path errors collapsed to String with substring sniffing (AGENTS.md says "Cold path (signaling, setup, request handlers): thiserror-derived error enums, ? propagation, converted to HTTP status codes at the axum boundary"): MediaCmd reply channels carry Result<_, String>, RtcSessionError is stringified at the channel boundary, and routes.rs:121 picks 404-vs-400 via e.contains("not found"). An SDP-reject message that happens to contain "not found" returns 404 instead of 400. Suggest a small MediaCmdError enum (NotFound / Rtc(RtcSessionError)) on the reply channels.

tracing::error!(error = %e, "SDP accept failed");
if e.contains("not found") {
(StatusCode::NOT_FOUND, e).into_response()
} else {

Observation below the confidence bar, for awareness: MediaCmd::Delete runs block_on(timeout(750ms, join)) inline in the command-drain step of the single media thread, so one hangup with a slow brain stalls polling for every other live session for up to 750ms. Invisible at 1-session scale; worth a note or a deferred-join pattern before slice-5 concurrency.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

### Code review Found 3 issues: 1. Every graceful shutdown panics: `main.rs:52` calls `media_thread.shutdown()` from inside `#[tokio::main] async fn main`, but `shutdown()` uses `blocking_send`/`blocking_recv` (`media_thread.rs:118-121`), which panic with "Cannot block the current thread from within a runtime" when called from an async context. Ctrl-C/SIGTERM will panic instead of exiting cleanly. The PR's own `api_integration.rs` wraps the identical call in `tokio::task::spawn_blocking` to avoid exactly this — `main.rs` needs the same treatment. https://git.adlee.work/alee/rutster/src/commit/f5fd4d4d717bb634809b67aa15c66c4f9389042b/crates/rutster/src/main.rs#L51-L53 https://git.adlee.work/alee/rutster/src/commit/f5fd4d4d717bb634809b67aa15c66c4f9389042b/crates/rutster/src/media_thread.rs#L117-L122 2. Brain-initiated hangup silently regressed: the deleted `session_map::drain_function_calls` was the only consumer of `TapConn.rx_function_call`. The new tick loop drains only `flush_rx`, so `function_call` events from the brain queue in the bounded mpsc until full, then drop — `HangupTool` is now unreachable dead code and the brain can no longer end a call (a shipped slice-3 feature). Not listed in the slice-4 spec §1.2 out-of-scope table; `realtime_integration.rs:19-23` frames it as a test-coverage gap, but it is a functional regression in the binary. https://git.adlee.work/alee/rutster/src/commit/f5fd4d4d717bb634809b67aa15c66c4f9389042b/crates/rutster/src/media_thread.rs#L239-L253 https://git.adlee.work/alee/rutster/src/commit/f5fd4d4d717bb634809b67aa15c66c4f9389042b/crates/rutster/tests/realtime_integration.rs#L18-L23 3. Cold-path errors collapsed to `String` with substring sniffing (AGENTS.md says "Cold path (signaling, setup, request handlers): `thiserror`-derived error enums, `?` propagation, converted to HTTP status codes at the axum boundary"): `MediaCmd` reply channels carry `Result<_, String>`, `RtcSessionError` is stringified at the channel boundary, and `routes.rs:121` picks 404-vs-400 via `e.contains("not found")`. An SDP-reject message that happens to contain "not found" returns 404 instead of 400. Suggest a small `MediaCmdError` enum (`NotFound` / `Rtc(RtcSessionError)`) on the reply channels. https://git.adlee.work/alee/rutster/src/commit/f5fd4d4d717bb634809b67aa15c66c4f9389042b/crates/rutster/src/routes.rs#L120-L123 Observation below the confidence bar, for awareness: `MediaCmd::Delete` runs `block_on(timeout(750ms, join))` inline in the command-drain step of the single media thread, so one hangup with a slow brain stalls polling for every other live session for up to 750ms. Invisible at 1-session scale; worth a note or a deferred-join pattern before slice-5 concurrency. 🤖 Generated with [Claude Code](https://claude.ai/code) <sub>- If this code review was useful, please react with 👍. Otherwise, react with 👎.</sub>
alee merged commit 3c63eb6688 into main 2026-07-04 04:37:32 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: alee/rutster#12