Commit Graph

10 Commits

Author SHA1 Message Date
opencode controller
87fe496671 fix(slice-1): F4 — idle timer keys on MediaData not every datagram
Adversarial review F4 (Med, confirmed): spec §4.5 specifies '60 s of no
RTP packets.' The implementation bumped `last_rx` on every datagram
`Receive::new` classified — including STUN consent checks and DTLS
keepalives. A peer that kept ICE alive but sent no audio (muted mic,
revoked permission) was never reaped within 60 s.

Move the bump to `handle_event::Event::MediaData` only — only
depacketized audio resets the timer. Spec §4.5 wording was already
correct (clarified in 8680f2b); the code now matches.

TDD via the new PacketIo harness:
- FakePackets (VecDeque-backed) injects an RTP-header-shaped datagram
  (classifiable but not MediaData-bearing) without a network round-trip.
- RED: `f4_non_rtp_datagram_does_not_reset_idle_timer` asserted
  `last_rx == initial`; pre-fix the bump in Step 1 on every classified
  datagram made the test fail.
- GREEN: removed the Step-1 bump; added the bump in
  `handle_event::Event::MediaData`. Test passes.

(Fixture rationale: STUN bytes don't work — `StunMessage::parse` needs
a USERNAME attribute that doesn't exist without a real handshake, so
`Receive::new` returns Err and the bump is skipped → false-pass.
RTP-header bytes multiplex as RTP without further validation, so they
reach `handle_input` — exactly the bug shape.)
2026-06-29 20:33:55 -04:00
opencode controller
e70e784835 refactor(slice-1): PacketIo trait — test seam for RtcSession.socket
Adversarial review's test-coverage gap finding: no test drives
`loop_driver::drive()`, the central function of the slice. F1, F2,
and F4 would have been caught by one. The obstacle was that `drive()`
called `session.socket.recv_from` on a concrete `UdpSocket`, so
unit tests couldn't feed synthetic STUN/RTP/DTLS bytes without a real
UDP loopback pair + hand-rolled valid packets on the wire.

Retrofit a sans-IO test seam shaped the way str0m itself is built:

- New `crates/rutster-media/src/packet_io.rs` defines `PacketIo`
  (`recv_from`, `send_to`) with a blanket impl for `UdpSocket`.
- `RtcSession.socket` goes from `std::net::UdpSocket` to
  `Box<dyn PacketIo + Send>`. Dispatch cost: a vtable call (~1 ns)
  on a 20 ms tick at single-digit pps; ~10,000× headroom vs the 200 ms
  latency bar. If step 4's dedicated timing thread measures it, swap
  `Box<dyn>` → a generic `RtcSession<S>` parameterization (same
  trait interface).
- `RtcSession::new()` keeps its behavior; new hidden `new_with_socket`
  is the test constructor (injects a fake + a synthetic local_addr).

This is the infrastructural refactor only — no behavior change. F4
and F1+F3 land on top of it (separate commits, TDD-style: failing
test first, then fix).
2026-06-29 20:33:55 -04:00
opencode controller
19cbe4b5e3 fix(slice-1): F7 — remove unreachable needs_redrain arm in Step 2
Adversarial review F7 (Low, confirmed): the Step-2 Output::Timeout arm
checked `needs_redrain` but the flag could only be set inside Step 3
(which runs after Step 2 exits). The arm was unreachable dead code that
misled readers into thinking Step 2 re-drains inter-iteration.

The flag itself remains load-bearing for the bottom re-drain after
Step 3's Writer::write (str0m's mutation → drain-to-Timeout invariant).
Only the inner-loop check is removed; production behavior unchanged.

Refactor phase — existing tests stay green.
2026-06-29 20:31:09 -04:00
opencode controller
3f79a81dcf fix(slice-1): F2 — second offer returns 409 Conflict not panic
Adversarial review F2 (Med-High, confirmed): accept_offer asserted
audio_mid.is_none() on a path fed by unauthenticated external input.
A second POST /v1/sessions/:id/offer panicked the handler task instead
of returning a clean 4xx.

Replace the assert! with a typed RtcSessionError::AlreadyNegotiated.
routes.rs maps the variant to 409 Conflict, distinct from 400 Bad
Request for malformed SDP. debug_assert! documents the invariant for
tests; the production path returns Err, never panics.

TDD: unit test second_accept_offer_returns_already_negotiated_not_panic
red on the panic message; green after the typed error. Integration test
double_post_offer_returns_409_conflict_not_panic red on 400; green after
routes.rs is updated.
2026-06-29 20:31:09 -04:00
opencode controller
0d666b5ba7 fix(slice-2): §5.1 teardown seq + §5.3 playout flush + §8.4 reconnect test + §7.2 index.html
Code-review findings from the slice-2 final whole-branch review:

- tap_client.rs: close arm sends session_end (not bye) per §5.2, awaits
  brain bye with bounded 500ms timeout, then ws.close(). Promotes the
  Task-7-documented Minor to Important's required spec compliance.
- session_map.rs: AppState::close() now clears channel.tap = None before
  Closing->Closed, sends close signal, bounded-awaits engine task for the
  teardown handshake (500ms cap), aborts as fallback. Per §5.1 step 5.
- pcm.rs + tap_audio_pipe.rs: AudioPipe trait gains clear_playout_ring()
  (default no-op for EchoAudioPipe); TapAudioPipe clears the VecDeque.
  RtcSession::clear_playout_ring delegates. Per §5.3 step 4 playout-on-
  disconnect-flush contract.
- tap_engine.rs: spawn_tap_engine creates + shares a flush_tx mpsc
  channel; run_engine_loop signals flush after each failed pump loop
  (before backoff); TapConn.flush_rx is drained by drive_all_sessions
  to call RtcSession::clear_playout_ring.
- tests/tap_integration.rs: new reconnect-path test asserts Channel
  stays Connected, metrics.reconnect_attempts increments, playout
  returns None during outage, playout resumes after EchoServer
  restart with fresh frames (no stale bleed-through). Per §8.4.
- static/index.html: one static line acknowledging external-brain
  routing per §7.2 (defer real-time tap status to step-3 GUI).

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.1 step 5, §5.2, §5.3 step 4, §7.2, §8.4.
2026-06-28 18:17:31 -04:00
opencode controller
6bc6f6426a feat(binary): wire TapEngine into session lifecycle (spec §5.1, §7)
- tap_engine.rs: spawn_tap_engine(session_id, tap_url) — dials the brain,
  runs TapClient with bounded exponential backoff (250ms→5s cap, infinite
  retries). Cold-path network I/O on tokio's pool, NOT a timing thread
  (spec §4.3 deviation boundary).
- session_map.rs: +tap_registry (DashMap<ChannelId, TapConn>); +default_tap_url.
  drive_all_sessions observes Connected+tap.is_none() → spawn TapEngine, set
  channel.tap = Some(TapHandle). Observes Closing+tap.is_some() → fire close
  oneshot, abort task, clear tap BEFORE state advances to Closed.
- routes.rs: POST /v1/sessions accepts optional {"tap_url":...} body;
  resolve_tap_url validates ws:// 127.0.0.1/localhost, rejects wss:// + non-
  loopback ws:// + bad schemes with 400 Bad Request (fail-fast per spec §4.4).
- main.rs: reads RUTSTER_TAP_URL env (default ws://127.0.0.1:8081/echo).
- rtc_session.rs (rutster-media): pipe field changed from EchoAudioPipe to
  Box<dyn AudioPipe + Send> — the seam test (§8.5 #6):
  loop_driver's call sites (sink.on_pcm_frame / source.next_pcm_frame)
  are byte-identical; only the field type widens. RtcSession::new keeps
  EchoAudioPipe default (slice-1 tests unchanged); binary uses
  RtcSession::set_pipe(tap_audio_pipe) to swap on the Connected transition.
- 4 unit tests for tap_url validation (loopback accepted, non-loopback
  rejected, wss rejected fail-fast, default fallback).

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.3, §4.4, §5.1, §7.
2026-06-28 17:39:52 -04:00
adlee-was-taken
3041036d94 fix: restore doc bar on RtcSession accessors; refresh stale README status
Some checks failed
CI / fmt (pull_request) Failing after 42s
CI / clippy (pull_request) Failing after 1m19s
CI / test (1.85) (pull_request) Failing after 49s
CI / test (stable) (pull_request) Failing after 29s
CI / deny (pull_request) Failing after 46s
Two Important findings from the final whole-branch review:

T4-M3: RtcSession::channel_id/channel_state/is_closed lacked /// docs
(AGENTS.md mandates /// on every public item; learner-facing code is a
primary product). One-line docs added per existing /// style in rtc_session.rs.

T7-M2: README "Status" section still read "Scoping. No code yet" — stale
post-slice-1. Updated to reflect slice-1 implementation status; minimal
edit, surrounding content preserved.
2026-06-28 13:13:27 -04:00
adlee-was-taken
1f0f64c1c6 binary: axum signaling + DashMap session store + browser test client
Four routes on axum 0.7 per spec §4.1: POST /v1/sessions (mint),
POST /v1/sessions/:id/offer (str0m-native SDP accept), DELETE
/v1/sessions/:id (close), GET / (static HTML client). Session store is
a DashMap<ChannelId, Arc<Mutex<RtcSession>>> (spec §4.5); one tokio
task drives all session poll loops — per-session tasks would pre-pave
the wrong pattern for step 4's dedicated thread. Graceful shutdown
drops the DashMap on Ctrl-C / SIGTERM. Integration test exercises the
REST surface; manual browser e2e per README §6.5.
2026-06-28 12:48:02 -04:00
adlee-was-taken
34c590b48a media: RtcSession + str0m poll loop (the media core)
RtcSession owns a str0m::Rtc + Opus decoder/encoder + EchoAudioPipe +
a bound UDP socket (spec §3.1, §4.5). accept_offer calls str0m 0.21's
sdp_api().accept_offer() natively — no hand-rolled SDP munger; str0m
fills DTLS fingerprint + ICE creds + Opus codec. The loop driver
drains poll_output per str0m's single-mutation invariant, routes
inbound MediaData through Opus decode + EchoAudioPipe sink, sends
Transmit packets on the UDP socket, and checks the 60 s idle timeout.

DEV-DEVIATION: loop runs on tokio (spec §3.4); step 4 replaces with
a dedicated timing thread per ARCHITECTURE.md.

str0m 0.21 API adjustments from the brief's sketch (verified against
src/str0m-0.21.0):
- SdpAnswer has no .mid() accessor; use answer.media_lines[0].mid()
  via the Deref<Target=Sdp> impl.
- Input::Receive carries the Instant as the first tuple element
  (Input::Receive(now, recv)); handle_input takes a single Input arg.
- Receive constructed via Receive::new(proto, src, dst, buf) (the
  DatagramRecv field is private).
- UDP socket binds 127.0.0.1:0 (Candidate::host rejects the
  unspecified address 0.0.0.0).
- BROWSER_SDP_OFFER fixture restored a=group:BUNDLE 0 (str0m's
  Sdp::assert_consistency rejects SDP without a session group).
2026-06-28 12:39:57 -04:00
adlee-was-taken
e09af55e00 media: PcmFrame + AudioSource/Sink + Opus codec pair
PcmFrame is the canonical tap format (16-bit mono @ 24 kHz, 480 samples
per 20 ms frame — ARCHITECTURE.md). AudioSource/AudioSink are the seam
step 2 splices the tap client into (spec §3.3); EchoAudioPipe is the
slice-1 wiring of that seam. OpusDecoder/OpusEncoder wrap the opus
crate's libopus FFI with hot-path match-and-continue (no ? on the 20 ms
loop, spec §3.8); decode/encode return Option<PcmFrame>/Option<Vec<u8>>
so a dropped frame is logged + counted, never propagated to crash the
peer.
2026-06-28 12:22:29 -04:00