Skip to main content

ringo_agent/
proto.rs

1//! The wire protocol between the parent and a `ringo-flow agent` worker process.
2//!
3//! Length-prefixed frames (`[kind][len][payload]`, see [`write_frame`]) so JSON
4//! control and raw binary audio share one stream per direction:
5//!   * **parent → child** (worker stdin): control frames carrying [`ToWorker`]
6//!     (a [`Command`] or an id-correlated [`Query`]), plus raw audio frames (TTS
7//!     PCM played into the call after `StartTxAudio`).
8//!   * **child → parent** (worker stdout): control frames carrying [`FromWorker`]
9//!     (events, query replies, header pushes, readiness), plus raw audio frames
10//!     (received-call PCM after `StartRxAudio`, preceded by `RxAudioStarted`).
11//!
12//! The worker's **stderr** carries logs / SIP traces and is never part of the
13//! protocol, so the framed stream stays clean.
14
15use ringo_core::account::{Account, BackendOptions};
16use ringo_core::event::{AppEvent, InviteHeaders, MediaStats};
17use serde::{Deserialize, Serialize};
18use std::io::{self, Read, Write};
19
20// ── wire framing ──────────────────────────────────────────────────────────--
21//
22// The wire is length-prefixed frames (not line-delimited), so raw binary audio
23// and JSON control can share one stream without escaping: `[kind:u8][len:u32
24// LE][payload]`. A control frame's payload is JSON (a `ToWorker`/`FromWorker`);
25// an audio frame's payload is raw mono s16le PCM.
26
27/// Frame kind: JSON control message.
28pub(crate) const FRAME_CONTROL: u8 = 0;
29/// Frame kind: raw mono s16le PCM audio.
30pub(crate) const FRAME_AUDIO: u8 = 1;
31
32/// Reject absurd frame lengths from a corrupt stream (far above any real frame).
33const MAX_FRAME_LEN: usize = 16 * 1024 * 1024;
34
35/// Write one frame: `[kind][len][payload]`, flushed.
36pub(crate) fn write_frame<W: Write>(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> {
37    if payload.len() > MAX_FRAME_LEN {
38        return Err(io::Error::new(
39            io::ErrorKind::InvalidInput,
40            "frame too large",
41        ));
42    }
43    w.write_all(&[kind])?;
44    w.write_all(&(payload.len() as u32).to_le_bytes())?;
45    w.write_all(payload)?;
46    w.flush()
47}
48
49/// Read one frame; `Ok(None)` only on a *clean* EOF at a frame boundary (no
50/// bytes of a new frame seen). EOF partway through the header or payload is a
51/// torn frame and surfaces as an error, not a clean close.
52pub(crate) fn read_frame<R: Read>(r: &mut R) -> io::Result<Option<(u8, Vec<u8>)>> {
53    let mut hdr = [0u8; 5];
54    // Read the header byte-by-byte so we can tell "nothing arrived" (clean EOF)
55    // from "EOF mid-header" (torn frame). read_exact can't distinguish them.
56    let mut filled = 0;
57    while filled < hdr.len() {
58        match r.read(&mut hdr[filled..]) {
59            Ok(0) if filled == 0 => return Ok(None), // clean frame-boundary EOF
60            Ok(0) => {
61                return Err(io::Error::new(
62                    io::ErrorKind::UnexpectedEof,
63                    "EOF in frame header",
64                ));
65            }
66            Ok(n) => filled += n,
67            Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
68            Err(e) => return Err(e),
69        }
70    }
71    let kind = hdr[0];
72    let len = u32::from_le_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize;
73    if len > MAX_FRAME_LEN {
74        return Err(io::Error::new(
75            io::ErrorKind::InvalidData,
76            "frame too large",
77        ));
78    }
79    let mut payload = vec![0u8; len];
80    r.read_exact(&mut payload)?;
81    Ok(Some((kind, payload)))
82}
83
84/// Mono s16 samples → little-endian audio-frame bytes.
85pub(crate) fn pcm_to_bytes(samples: &[i16]) -> Vec<u8> {
86    let mut v = Vec::with_capacity(samples.len() * 2);
87    for &s in samples {
88        v.extend_from_slice(&s.to_le_bytes());
89    }
90    v
91}
92
93/// Little-endian audio-frame bytes → mono s16 samples (trailing odd byte ignored).
94pub(crate) fn bytes_to_pcm(bytes: &[u8]) -> Vec<i16> {
95    bytes
96        .chunks_exact(2)
97        .map(|c| i16::from_le_bytes([c[0], c[1]]))
98        .collect()
99}
100
101// ── handshake ─────────────────────────────────────────────────────────────--
102
103/// Wire-protocol version. Parent and worker are normally the same binary, but a
104/// stale worker (old binary on `$PATH`, a half-finished upgrade) would otherwise
105/// desync silently on the framed stream. The worker rejects a mismatching
106/// version at the handshake instead. Bump on any incompatible wire change.
107pub(crate) const PROTO_VERSION: u32 = 2;
108
109/// The first frame the parent sends: the protocol version plus the agent config.
110/// Versioning lives here, on the wire envelope, so the public [`AgentConfig`]
111/// stays a plain user-facing struct.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub(crate) struct Handshake {
114    /// Sender's [`PROTO_VERSION`]; the worker bails if it doesn't match its own.
115    pub proto_version: u32,
116    /// The agent configuration to spawn.
117    pub config: AgentConfig,
118}
119
120/// The full agent configuration the parent sends as the worker's first stdin
121/// line (before any command/query). Carrying it on stdin rather than argv keeps
122/// credentials out of the process table. The `Account`/`BackendOptions` are
123/// embedded verbatim (they are `Serialize`/`Deserialize` in ringo-core), so the
124/// worker rebuilds the *exact* account the in-process path would — adding a
125/// field to either struct can't silently drift out of sync.
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct AgentConfig {
128    /// Label for the agent (used in logs / per-agent log file names).
129    pub name: String,
130    /// The SIP account to register.
131    pub account: Account,
132    /// Backend options (audio driver, timeouts, recording, …).
133    pub options: BackendOptions,
134}
135
136// ── parent → child ──────────────────────────────────────────────────────────
137
138/// A fire-and-forget phone action.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(tag = "cmd", rename_all = "snake_case")]
141pub enum Command {
142    Register {
143        aor: String,
144        regint: u32,
145    },
146    Dial {
147        number: String,
148    },
149    Hangup,
150    HangupAll,
151    Accept,
152    Hold,
153    Resume,
154    Mute,
155    Dtmf {
156        digit: char,
157    },
158    SwitchLine {
159        line: usize,
160    },
161    Transfer {
162        uri: String,
163    },
164    AttendedTransferStart {
165        uri: String,
166    },
167    AttendedTransferExec,
168    AttendedTransferAbort,
169    AddHeader {
170        key: String,
171        value: String,
172    },
173    RmHeader {
174        key: String,
175    },
176    SetAudioSource {
177        spec: String,
178    },
179    /// Arm or disarm full-call recording for this UA (see
180    /// `ringo_core::set_recording`): arming clears the previous recording.
181    SetRecording {
182        on: bool,
183    },
184    ArmInviteResponse {
185        scode: u16,
186        reason: String,
187        headers: Vec<String>,
188    },
189    DisarmInviteResponse,
190    /// Switch the agent's audio source to live-streamed PCM at `rate` Hz; raw
191    /// audio frames (kind=1) on stdin are then played into the call (TTS).
192    StartTxAudio {
193        rate: u32,
194    },
195    /// Begin streaming the agent's received audio: the worker emits an
196    /// `RxAudioStarted` control message then raw audio frames (kind=1) on stdout.
197    StartRxAudio,
198    /// Graceful shutdown request (also triggered by stdin EOF).
199    Shutdown,
200}
201
202/// A request that expects exactly one [`Reply`] with the matching `id`.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(tag = "query", rename_all = "snake_case")]
205pub enum Query {
206    /// RTP media stats for the active/last call.
207    MediaStats,
208    /// DTMF digits received so far.
209    ReceivedDtmf,
210    /// Write the agent's captured sent/received audio as WAV files named
211    /// `<prefix>-sent.wav` / `<prefix>-recv.wav`; returns what was written
212    /// (paths, side, size, and whether the retention cap truncated it).
213    SaveAudio { prefix: String },
214    /// Number of active calls (for teardown's BYE-flush wait).
215    CallCount,
216}
217
218/// A [`Query`] tagged with a correlation id.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct QueryEnvelope {
221    pub id: u64,
222    #[serde(flatten)]
223    pub query: Query,
224}
225
226/// Anything the parent writes to the worker's stdin. Untagged — disjoint by the
227/// distinguishing key `cmd` (Command) vs `query` (QueryEnvelope). Do NOT add a
228/// variant whose payload reuses either key.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230#[serde(untagged)]
231pub enum ToWorker {
232    Cmd(Command),
233    Query(QueryEnvelope),
234}
235
236// ── child → parent ──────────────────────────────────────────────────────────
237
238/// RTP media stats, mirroring [`MediaStats`] (which carries no serde derives).
239#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
240pub struct WireMediaStats {
241    pub rtt_ms: f64,
242    pub jitter_ms: f64,
243    pub rx_lost: i32,
244    pub packet_loss_pct: f64,
245    pub mos: f64,
246}
247
248impl From<MediaStats> for WireMediaStats {
249    fn from(s: MediaStats) -> Self {
250        Self {
251            rtt_ms: s.rtt_ms,
252            jitter_ms: s.jitter_ms,
253            rx_lost: s.rx_lost,
254            packet_loss_pct: s.packet_loss_pct,
255            mos: s.mos,
256        }
257    }
258}
259
260impl From<WireMediaStats> for MediaStats {
261    fn from(s: WireMediaStats) -> Self {
262        Self {
263            rtt_ms: s.rtt_ms,
264            jitter_ms: s.jitter_ms,
265            rx_lost: s.rx_lost,
266            packet_loss_pct: s.packet_loss_pct,
267            mos: s.mos,
268        }
269    }
270}
271
272/// One WAV written by a `SaveAudio` query: which side of the call it holds,
273/// how much audio it contains, and whether the recording hit the retention
274/// cap (only the newest `ringo_core::RECORDING_MAX_SECS` seconds are kept).
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct WireSavedAudio {
277    pub path: String,
278    /// `"sent"` or `"recv"`.
279    pub kind: String,
280    /// Captured mono samples at `srate`.
281    pub samples: usize,
282    /// Sample rate in Hz.
283    pub srate: u32,
284    /// Whether the recording reached the retention cap.
285    pub truncated: bool,
286}
287
288/// The payload of a [`Reply`], one variant per [`Query`]. Externally tagged
289/// (e.g. `{"call_count":2}`) so newtype variants over primitives/sequences
290/// round-trip — internal tagging can't carry those.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum ReplyResult {
294    MediaStats(Option<WireMediaStats>),
295    Dtmf(String),
296    SavedAudio(Vec<WireSavedAudio>),
297    CallCount(u32),
298}
299
300/// A response to a [`Query`], correlated by `id` (e.g.
301/// `{"reply":7,"result":{"call_count":2}}`).
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct Reply {
304    pub reply: u64,
305    pub result: ReplyResult,
306}
307
308/// A push of received INVITE headers (keyed by SIP Call-ID).
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct Headers {
311    pub headers: InviteHeaders,
312}
313
314/// Readiness handshake: the worker emits this as its FIRST stdout message once
315/// the backend is up, so [`crate::client::ProcessClient::spawn`] can surface a
316/// bad config / failed backend at spawn time instead of as a later query timeout.
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct Ready {
319    pub ready: bool,
320}
321
322/// Sent once after `StartRxAudio`, before the raw RX audio frames, so the parent
323/// knows the sample rate of the mono s16 frames that follow.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct RxAudioStarted {
326    pub rx_audio_rate: u32,
327}
328
329/// A control message the worker writes to its stdout (the `control`-kind frame
330/// payload). Untagged — the variants are disjoint by their distinguishing key:
331/// `ready` / `event` / `reply` / `headers` / `rx_audio_rate`. Do NOT add a
332/// variant whose payload reuses one of those keys.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(untagged)]
335pub enum FromWorker {
336    Ready(Ready),
337    Event(WireEvent),
338    Reply(Reply),
339    Headers(Headers),
340    RxAudioStarted(RxAudioStarted),
341}
342
343/// A serializable mirror of [`AppEvent`] (which lives in `ringo-core` and
344/// carries no serde derives).
345#[derive(Debug, Clone, Serialize, Deserialize)]
346#[serde(tag = "event", rename_all = "snake_case")]
347pub enum WireEvent {
348    Registering {
349        account: String,
350    },
351    RegisterOk {
352        account: String,
353    },
354    RegisterFailed {
355        reason: String,
356    },
357    Unregistered {
358        account: String,
359    },
360    CallIncoming {
361        call_id: String,
362        number: String,
363        display_name: Option<String>,
364    },
365    CallOutgoing {
366        call_id: String,
367        number: String,
368    },
369    CallRinging {
370        call_id: String,
371    },
372    CallEstablished {
373        call_id: String,
374    },
375    CallClosed {
376        call_id: String,
377        reason: String,
378        error: bool,
379    },
380    CallDeflected {
381        from: String,
382        display_name: Option<String>,
383        target: String,
384    },
385    CallHold {
386        call_id: String,
387    },
388    CallResume {
389        call_id: String,
390    },
391    CallTransferFailed {
392        call_id: String,
393    },
394    VoicemailStatus {
395        waiting: bool,
396        new_count: u32,
397    },
398    Response {
399        ok: bool,
400        data: String,
401    },
402    Unknown {
403        class: String,
404        type_: String,
405    },
406    BackendConnectFailed {
407        reason: String,
408    },
409}
410
411impl From<&AppEvent> for WireEvent {
412    fn from(e: &AppEvent) -> Self {
413        match e {
414            AppEvent::Registering { account } => Self::Registering {
415                account: account.clone(),
416            },
417            AppEvent::RegisterOk { account } => Self::RegisterOk {
418                account: account.clone(),
419            },
420            AppEvent::RegisterFailed { reason } => Self::RegisterFailed {
421                reason: reason.clone(),
422            },
423            AppEvent::Unregistered { account } => Self::Unregistered {
424                account: account.clone(),
425            },
426            AppEvent::CallIncoming {
427                call_id,
428                number,
429                display_name,
430            } => Self::CallIncoming {
431                call_id: call_id.clone(),
432                number: number.clone(),
433                display_name: display_name.clone(),
434            },
435            AppEvent::CallOutgoing { call_id, number } => Self::CallOutgoing {
436                call_id: call_id.clone(),
437                number: number.clone(),
438            },
439            AppEvent::CallRinging { call_id } => Self::CallRinging {
440                call_id: call_id.clone(),
441            },
442            AppEvent::CallEstablished { call_id } => Self::CallEstablished {
443                call_id: call_id.clone(),
444            },
445            AppEvent::CallClosed {
446                call_id,
447                reason,
448                error,
449            } => Self::CallClosed {
450                call_id: call_id.clone(),
451                reason: reason.clone(),
452                error: *error,
453            },
454            AppEvent::CallHold { call_id } => Self::CallHold {
455                call_id: call_id.clone(),
456            },
457            AppEvent::CallResume { call_id } => Self::CallResume {
458                call_id: call_id.clone(),
459            },
460            AppEvent::CallTransferFailed { call_id } => Self::CallTransferFailed {
461                call_id: call_id.clone(),
462            },
463            AppEvent::CallDeflected {
464                from,
465                display_name,
466                target,
467            } => Self::CallDeflected {
468                from: from.clone(),
469                display_name: display_name.clone(),
470                target: target.clone(),
471            },
472            AppEvent::VoicemailStatus { waiting, new_count } => Self::VoicemailStatus {
473                waiting: *waiting,
474                new_count: *new_count,
475            },
476            AppEvent::Response { ok, data } => Self::Response {
477                ok: *ok,
478                data: data.clone(),
479            },
480            AppEvent::Unknown { class, type_ } => Self::Unknown {
481                class: class.clone(),
482                type_: type_.clone(),
483            },
484            AppEvent::BackendConnectFailed { reason } => Self::BackendConnectFailed {
485                reason: reason.clone(),
486            },
487        }
488    }
489}
490
491impl From<WireEvent> for AppEvent {
492    fn from(w: WireEvent) -> Self {
493        match w {
494            WireEvent::Registering { account } => AppEvent::Registering { account },
495            WireEvent::RegisterOk { account } => AppEvent::RegisterOk { account },
496            WireEvent::RegisterFailed { reason } => AppEvent::RegisterFailed { reason },
497            WireEvent::Unregistered { account } => AppEvent::Unregistered { account },
498            WireEvent::CallIncoming {
499                call_id,
500                number,
501                display_name,
502            } => AppEvent::CallIncoming {
503                call_id,
504                number,
505                display_name,
506            },
507            WireEvent::CallOutgoing { call_id, number } => {
508                AppEvent::CallOutgoing { call_id, number }
509            }
510            WireEvent::CallRinging { call_id } => AppEvent::CallRinging { call_id },
511            WireEvent::CallEstablished { call_id } => AppEvent::CallEstablished { call_id },
512            WireEvent::CallClosed {
513                call_id,
514                reason,
515                error,
516            } => AppEvent::CallClosed {
517                call_id,
518                reason,
519                error,
520            },
521            WireEvent::CallHold { call_id } => AppEvent::CallHold { call_id },
522            WireEvent::CallResume { call_id } => AppEvent::CallResume { call_id },
523            WireEvent::CallTransferFailed { call_id } => AppEvent::CallTransferFailed { call_id },
524            WireEvent::CallDeflected {
525                from,
526                display_name,
527                target,
528            } => AppEvent::CallDeflected {
529                from,
530                display_name,
531                target,
532            },
533            WireEvent::VoicemailStatus { waiting, new_count } => {
534                AppEvent::VoicemailStatus { waiting, new_count }
535            }
536            WireEvent::Response { ok, data } => AppEvent::Response { ok, data },
537            WireEvent::Unknown { class, type_ } => AppEvent::Unknown { class, type_ },
538            WireEvent::BackendConnectFailed { reason } => AppEvent::BackendConnectFailed { reason },
539        }
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn command_roundtrips_as_to_worker() {
549        let line = serde_json::to_string(&ToWorker::Cmd(Command::Dial {
550            number: "sip:1@example.com".into(),
551        }))
552        .unwrap();
553        assert_eq!(line, r#"{"cmd":"dial","number":"sip:1@example.com"}"#);
554        assert!(matches!(
555            serde_json::from_str::<ToWorker>(&line).unwrap(),
556            ToWorker::Cmd(Command::Dial { .. })
557        ));
558    }
559
560    #[test]
561    fn query_envelope_roundtrips_as_to_worker() {
562        let line = serde_json::to_string(&ToWorker::Query(QueryEnvelope {
563            id: 7,
564            query: Query::SaveAudio {
565                prefix: "rec".into(),
566            },
567        }))
568        .unwrap();
569        let back: ToWorker = serde_json::from_str(&line).unwrap();
570        match back {
571            ToWorker::Query(QueryEnvelope {
572                id,
573                query: Query::SaveAudio { prefix },
574            }) => {
575                assert_eq!((id, prefix.as_str()), (7, "rec"));
576            }
577            _ => panic!("expected save_audio query"),
578        }
579    }
580
581    #[test]
582    fn event_and_reply_disambiguate_as_from_worker() {
583        let ev = serde_json::to_string(&FromWorker::Event(WireEvent::RegisterOk {
584            account: "a".into(),
585        }))
586        .unwrap();
587        assert!(matches!(
588            serde_json::from_str::<FromWorker>(&ev).unwrap(),
589            FromWorker::Event(WireEvent::RegisterOk { .. })
590        ));
591
592        let rep = serde_json::to_string(&FromWorker::Reply(Reply {
593            reply: 7,
594            result: ReplyResult::CallCount(2),
595        }))
596        .unwrap();
597        match serde_json::from_str::<FromWorker>(&rep).unwrap() {
598            FromWorker::Reply(Reply {
599                reply,
600                result: ReplyResult::CallCount(n),
601            }) => {
602                assert_eq!((reply, n), (7, 2));
603            }
604            _ => panic!("expected call_count reply"),
605        }
606    }
607
608    #[test]
609    fn app_event_mirrors_both_ways() {
610        let ev = AppEvent::CallIncoming {
611            call_id: "abc".into(),
612            number: "sip:1@x".into(),
613            display_name: Some("Bob".into()),
614        };
615        let line = serde_json::to_string(&WireEvent::from(&ev)).unwrap();
616        let back: AppEvent = serde_json::from_str::<WireEvent>(&line).unwrap().into();
617        assert!(matches!(back, AppEvent::CallIncoming { .. }));
618    }
619
620    #[test]
621    fn ready_disambiguates_from_an_event() {
622        let ready = serde_json::to_string(&FromWorker::Ready(Ready { ready: true })).unwrap();
623        assert!(matches!(
624            serde_json::from_str::<FromWorker>(&ready).unwrap(),
625            FromWorker::Ready(Ready { ready: true })
626        ));
627        // An event must NOT be mis-parsed as Ready (disjoint keys).
628        let ev = serde_json::to_string(&FromWorker::Event(WireEvent::RegisterOk {
629            account: "a".into(),
630        }))
631        .unwrap();
632        assert!(matches!(
633            serde_json::from_str::<FromWorker>(&ev).unwrap(),
634            FromWorker::Event(_)
635        ));
636    }
637
638    #[test]
639    fn handshake_carries_version_and_config() {
640        let mut cfg = AgentConfig {
641            name: "A".into(),
642            ..Default::default()
643        };
644        cfg.account.username = "alice".into();
645        let hs = Handshake {
646            proto_version: PROTO_VERSION,
647            config: cfg,
648        };
649        let line = serde_json::to_string(&hs).unwrap();
650        let back: Handshake = serde_json::from_str(&line).unwrap();
651        assert_eq!(back.proto_version, PROTO_VERSION);
652        assert_eq!(back.config.account.username, "alice");
653    }
654
655    #[test]
656    fn agent_config_roundtrips_with_embedded_account() {
657        let mut cfg = AgentConfig {
658            name: "A".into(),
659            ..Default::default()
660        };
661        cfg.account.username = "alice".into();
662        cfg.account.catchall = true;
663        cfg.options.audio_driver = Some("aubridge".into());
664        let line = serde_json::to_string(&cfg).unwrap();
665        let back: AgentConfig = serde_json::from_str(&line).unwrap();
666        assert_eq!(back.name, "A");
667        assert_eq!(back.account.username, "alice");
668        assert!(back.account.catchall);
669        assert_eq!(back.options.audio_driver.as_deref(), Some("aubridge"));
670    }
671
672    #[test]
673    fn frames_roundtrip_control_then_audio() {
674        let mut buf: Vec<u8> = Vec::new();
675        write_frame(&mut buf, FRAME_CONTROL, b"{\"cmd\":\"accept\"}").unwrap();
676        write_frame(&mut buf, FRAME_AUDIO, &pcm_to_bytes(&[1, -2, 3])).unwrap();
677        let mut r = &buf[..];
678        let (k1, p1) = read_frame(&mut r).unwrap().unwrap();
679        assert_eq!(k1, FRAME_CONTROL);
680        assert_eq!(p1, b"{\"cmd\":\"accept\"}");
681        let (k2, p2) = read_frame(&mut r).unwrap().unwrap();
682        assert_eq!(k2, FRAME_AUDIO);
683        assert_eq!(bytes_to_pcm(&p2), vec![1, -2, 3]);
684        assert!(read_frame(&mut r).unwrap().is_none()); // clean EOF
685    }
686
687    #[test]
688    fn audio_frame_can_contain_newline_bytes() {
689        // 0x0A (\n) in PCM must NOT break framing (the whole point of length-prefix).
690        let samples = vec![0x0A0Ai16, 0x000A, -1];
691        let mut buf = Vec::new();
692        write_frame(&mut buf, FRAME_AUDIO, &pcm_to_bytes(&samples)).unwrap();
693        let (_k, p) = read_frame(&mut &buf[..]).unwrap().unwrap();
694        assert_eq!(bytes_to_pcm(&p), samples);
695    }
696
697    #[test]
698    fn rx_audio_started_disambiguates_from_event_and_reply() {
699        let started = serde_json::to_string(&FromWorker::RxAudioStarted(RxAudioStarted {
700            rx_audio_rate: 8000,
701        }))
702        .unwrap();
703        assert!(matches!(
704            serde_json::from_str::<FromWorker>(&started).unwrap(),
705            FromWorker::RxAudioStarted(RxAudioStarted {
706                rx_audio_rate: 8000
707            })
708        ));
709        // Neither an event nor a reply carries the `rx_audio_rate` key.
710        let ev = serde_json::to_string(&FromWorker::Event(WireEvent::RegisterOk {
711            account: "a".into(),
712        }))
713        .unwrap();
714        let rep = serde_json::to_string(&FromWorker::Reply(Reply {
715            reply: 1,
716            result: ReplyResult::CallCount(0),
717        }))
718        .unwrap();
719        assert!(matches!(
720            serde_json::from_str::<FromWorker>(&ev).unwrap(),
721            FromWorker::Event(_)
722        ));
723        assert!(matches!(
724            serde_json::from_str::<FromWorker>(&rep).unwrap(),
725            FromWorker::Reply(_)
726        ));
727    }
728
729    #[test]
730    fn read_frame_distinguishes_clean_eof_from_torn_header() {
731        // Nothing buffered → clean frame-boundary EOF.
732        assert!(read_frame(&mut &b""[..]).unwrap().is_none());
733        // A partial header (2 of 5 bytes) is a torn frame, not a clean close.
734        let err = read_frame(&mut &[FRAME_CONTROL, 0x01][..]).unwrap_err();
735        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
736    }
737
738    #[test]
739    fn read_frame_rejects_oversized_length() {
740        // Header claiming a payload above MAX_FRAME_LEN must be rejected, not allocated.
741        let mut buf = vec![FRAME_AUDIO];
742        buf.extend_from_slice(&(MAX_FRAME_LEN as u32 + 1).to_le_bytes());
743        let err = read_frame(&mut &buf[..]).unwrap_err();
744        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
745    }
746
747    #[test]
748    fn read_frame_errors_on_truncated_payload() {
749        // Full header but a short payload (EOF mid-payload) is a torn frame.
750        let mut buf = vec![FRAME_AUDIO];
751        buf.extend_from_slice(&4u32.to_le_bytes()); // claims 4 bytes
752        buf.extend_from_slice(&[1, 2]); // only 2 follow
753        let err = read_frame(&mut &buf[..]).unwrap_err();
754        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
755    }
756
757    #[test]
758    fn audio_commands_disambiguate_as_to_worker_cmd() {
759        // The audio control commands must parse as Cmd, not Query (untagged split).
760        for cmd in [
761            Command::StartRxAudio,
762            Command::StartTxAudio { rate: 8000 },
763            Command::SetRecording { on: true },
764        ] {
765            let line = serde_json::to_string(&ToWorker::Cmd(cmd)).unwrap();
766            assert!(
767                matches!(
768                    serde_json::from_str::<ToWorker>(&line).unwrap(),
769                    ToWorker::Cmd(_)
770                ),
771                "expected Cmd for {line}"
772            );
773        }
774    }
775
776    #[test]
777    fn set_recording_roundtrips() {
778        let line =
779            serde_json::to_string(&ToWorker::Cmd(Command::SetRecording { on: true })).unwrap();
780        assert_eq!(line, r#"{"cmd":"set_recording","on":true}"#);
781        match serde_json::from_str::<ToWorker>(&line).unwrap() {
782            ToWorker::Cmd(Command::SetRecording { on }) => assert!(on),
783            _ => panic!("expected set_recording"),
784        }
785    }
786
787    #[test]
788    fn saved_audio_reply_roundtrips() {
789        let file = WireSavedAudio {
790            path: "rec-recv.wav".into(),
791            kind: "recv".into(),
792            samples: 8000,
793            srate: 8000,
794            truncated: false,
795        };
796        let rep = serde_json::to_string(&FromWorker::Reply(Reply {
797            reply: 7,
798            result: ReplyResult::SavedAudio(vec![file]),
799        }))
800        .unwrap();
801        match serde_json::from_str::<FromWorker>(&rep).unwrap() {
802            FromWorker::Reply(Reply {
803                reply,
804                result: ReplyResult::SavedAudio(files),
805            }) => {
806                assert_eq!(reply, 7);
807                assert_eq!(files.len(), 1);
808                assert_eq!(files[0].path, "rec-recv.wav");
809                assert_eq!(files[0].kind, "recv");
810                assert_eq!(files[0].samples, 8000);
811                assert_eq!(files[0].srate, 8000);
812                assert!(!files[0].truncated);
813            }
814            _ => panic!("expected saved_audio reply"),
815        }
816    }
817}