Skip to main content

forge_server/transport/
frame.rs

1//! The binary frames on the socket.
2//!
3//! One WebSocket binary message per frame: a one-byte kind tag, then the
4//! payload. Three kinds, and what tells them apart is the tag alone:
5//!
6//! - [`Kind::Dictation`] - a client's mono PCM at [`SAMPLE_RATE`], the rate
7//!   the dictation models read. The server decodes this one.
8//! - [`Kind::BrowserFrame`] - the server's relay of one encoded browser
9//!   frame: the profile id, sequence, view size, format, key flag, the page's
10//!   own viewport and the WebP payload, exactly as the host encoded it. **The
11//!   bytes are never re-encoded or decoded here**; the server only moves them.
12//! - [`Kind::BrowserAudio`] - the viewed page's audio, relayed to the
13//!   controlling client with its profile, sequence, sample rate and channels.
14//!
15//! **Tag 3 is not a kind here.** A whole `ServerMessage` body, gzipped for a
16//! client that declared `deflate=1`, is built by the connection's encoder and
17//! read by that client's decoder, so it crosses as a server-only frame rather
18//! than as a variant of [`Kind`].
19//!
20//! **The kind tag is why the shape can grow**: a new client frame is a variant
21//! here plus a line in the recorded contract, not a new message type.
22//!
23//! **The server decodes what clients send and encodes what it sends.** A
24//! browser frame arriving from a client is refused: the host is the only
25//! encoder.
26//!
27//! **A frame leaves the server through one interface.** [`FrameTransport`]
28//! is what the connection asks to deliver a viewer's frame; the socket is its
29//! only implementation today, and a WebRTC backend later is another
30//! implementation rather than a change to input, tools or the draw path.
31
32use axum::extract::ws::Message;
33use forge_primitives::browser::{BrowserAudio, BrowserFrame};
34use futures_util::SinkExt as _;
35
36use super::{WRITE_BOUND, write_within};
37
38/// The rate every dictation frame's samples are at. Pinned against the
39/// dictation crate's own constant by a test rather than imported: the wire's
40/// rate is this crate's rule, and the transport does not need the audio crate
41/// to state it.
42pub const SAMPLE_RATE: u32 = 16_000;
43
44/// One dictation frame's header: the kind tag and nothing else.
45pub const HEADER_BYTES: usize = 1;
46
47/// The largest dictation payload a frame may carry. Twenty-five times the
48/// 20 ms frame a client sends, so a malformed or hostile length cannot make
49/// the server hold a large allocation per message.
50pub const MAX_PAYLOAD_BYTES: usize = 16 * 1024;
51
52/// What a binary frame is.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Kind {
55    /// Mono 16-bit little-endian PCM: what a browser's own i16 conversion
56    /// produces, with no encoder on either side.
57    Dictation,
58    /// One encoded browser frame, relayed from the host.
59    BrowserFrame,
60    /// One packet of the viewed page's audio, relayed to the controlling
61    /// client: the profile id, sequence, sample rate and channels, then
62    /// interleaved 16-bit samples.
63    BrowserAudio,
64    /// One terminal's output: the terminal id, its sequence, then the raw
65    /// bytes the shell wrote.
66    TerminalOutput,
67    /// One client's terminal input: the terminal id, then the raw bytes.
68    TerminalInput,
69}
70
71impl Kind {
72    /// The tag byte this kind crosses as.
73    pub const fn tag(self) -> u8 {
74        match self {
75            Self::Dictation => 0,
76            Self::BrowserFrame => 1,
77            Self::BrowserAudio => 2,
78            Self::TerminalOutput => forge_primitives::terminal::TERMINAL_OUTPUT_TAG,
79            Self::TerminalInput => forge_primitives::terminal::TERMINAL_INPUT_TAG,
80        }
81    }
82
83    const fn from_tag(tag: u8) -> Option<Self> {
84        match tag {
85            0 => Some(Self::Dictation),
86            1 => Some(Self::BrowserFrame),
87            2 => Some(Self::BrowserAudio),
88            forge_primitives::terminal::TERMINAL_OUTPUT_TAG => Some(Self::TerminalOutput),
89            forge_primitives::terminal::TERMINAL_INPUT_TAG => Some(Self::TerminalInput),
90            _ => None,
91        }
92    }
93}
94
95/// How one viewer receives a browser's frames.
96///
97/// **The connection never formats a frame itself**: it hands one to this
98/// and the transport decides how the person sees it. The socket transport
99/// below is the only implementation now.
100#[async_trait::async_trait]
101pub trait FrameTransport: Send {
102    /// Deliver one encoded frame to this viewer. `Err` is a connection that
103    /// is gone, and the caller ends it.
104    async fn deliver(&mut self, frame: &BrowserFrame) -> anyhow::Result<()>;
105
106    /// Deliver one packet of the page's audio to this viewer.
107    async fn deliver_audio(&mut self, audio: &BrowserAudio) -> anyhow::Result<()>;
108
109    /// Deliver one terminal's output to this viewer: the id, the sequence and
110    /// the shell's own bytes.
111    async fn deliver_terminal(&mut self, id: u16, seq: u32, bytes: &[u8]) -> anyhow::Result<()>;
112}
113
114/// The socket's own transport: one tagged binary message per frame.
115pub struct SocketFrames<'a, S> {
116    pub socket: &'a mut S,
117}
118
119#[async_trait::async_trait]
120impl<S> FrameTransport for SocketFrames<'_, S>
121where
122    S: futures_util::Sink<Message, Error = axum::Error> + Unpin + Send,
123{
124    async fn deliver(&mut self, frame: &BrowserFrame) -> anyhow::Result<()> {
125        let mut bytes = Vec::with_capacity(HEADER_BYTES + frame.encoded.len());
126        bytes.push(Kind::BrowserFrame.tag());
127        bytes.extend_from_slice(&frame.encoded);
128        write_within(WRITE_BOUND, async {
129            self.socket.send(Message::Binary(bytes.into())).await.map_err(anyhow::Error::from)
130        })
131        .await
132    }
133
134    async fn deliver_audio(&mut self, audio: &BrowserAudio) -> anyhow::Result<()> {
135        let bytes = audio_bytes(audio);
136        write_within(WRITE_BOUND, async {
137            self.socket.send(Message::Binary(bytes.into())).await.map_err(anyhow::Error::from)
138        })
139        .await
140    }
141
142    async fn deliver_terminal(&mut self, id: u16, seq: u32, bytes: &[u8]) -> anyhow::Result<()> {
143        let frame = terminal_output_bytes(id, seq, bytes);
144        write_within(WRITE_BOUND, async {
145            self.socket.send(Message::Binary(frame.into())).await.map_err(anyhow::Error::from)
146        })
147        .await
148    }
149}
150
151/// One audio packet's bytes: the kind tag, the profile, tab, sequence, sample
152/// rate and channels, then the interleaved little-endian i16 samples.
153fn audio_bytes(audio: &BrowserAudio) -> Vec<u8> {
154    // One tag byte plus the viewer's 13-byte header.
155    let mut bytes = Vec::with_capacity(14 + audio.samples.len() * 2);
156    bytes.push(Kind::BrowserAudio.tag());
157    bytes.extend_from_slice(&audio.profile.to_le_bytes());
158    bytes.extend_from_slice(&audio.tab.to_le_bytes());
159    bytes.extend_from_slice(&audio.seq.to_le_bytes());
160    bytes.extend_from_slice(&audio.sample_rate.to_le_bytes());
161    bytes.push(audio.channels);
162    for sample in &audio.samples {
163        bytes.extend_from_slice(&sample.to_le_bytes());
164    }
165    bytes
166}
167
168/// Why a binary message was not a frame this server takes.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum Refusal {
171    /// A message with no tag at all.
172    ShortHeader,
173    /// A kind tag this server does not know.
174    UnknownKind(u8),
175    /// A dictation payload past [`MAX_PAYLOAD_BYTES`].
176    Oversized(usize),
177    /// A dictation payload that is not a whole number of samples.
178    UnevenPayload(usize),
179    /// A browser or terminal output frame, which only the server sends.
180    ServerOnly(u8),
181    /// A terminal input frame shorter than its two-byte id.
182    ShortTerminalInput(usize),
183}
184
185impl Refusal {
186    /// One line for the debug record.
187    pub fn reason(self) -> String {
188        match self {
189            Self::ShortHeader => "a binary message with no kind byte".to_owned(),
190            Self::UnknownKind(tag) => format!("kind tag {tag} is not one this server knows"),
191            Self::Oversized(bytes) => format!("{bytes} bytes of payload is past the frame cap"),
192            Self::UnevenPayload(bytes) => {
193                format!("{bytes} bytes is not a whole number of samples")
194            }
195            Self::ServerOnly(tag) => {
196                format!("kind tag {tag} is a frame the server sends, not one a client does")
197            }
198            Self::ShortTerminalInput(bytes) => {
199                format!("{bytes} bytes is shorter than a terminal input frame's two-byte id")
200            }
201        }
202    }
203}
204
205/// One decoded frame.
206#[derive(Debug, PartialEq)]
207pub enum Frame {
208    /// A dictation frame: the samples its bytes carry.
209    Audio(Vec<f32>),
210    /// One client's terminal input: the terminal id and the bytes.
211    TerminalInput { id: u16, bytes: Vec<u8> },
212}
213
214/// Decode one binary message a client sent.
215pub fn decode(bytes: &[u8]) -> Result<Frame, Refusal> {
216    let Some((&tag, payload)) = bytes.split_first() else {
217        return Err(Refusal::ShortHeader);
218    };
219    let Some(kind) = Kind::from_tag(tag) else {
220        return Err(Refusal::UnknownKind(tag));
221    };
222    match kind {
223        Kind::Dictation => {
224            if payload.len() > MAX_PAYLOAD_BYTES {
225                return Err(Refusal::Oversized(payload.len()));
226            }
227            if !payload.len().is_multiple_of(2) {
228                return Err(Refusal::UnevenPayload(payload.len()));
229            }
230            let samples = payload
231                .as_chunks::<2>()
232                .0
233                .iter()
234                .map(|pair| f32::from(i16::from_le_bytes(*pair)) / 32768.0)
235                .collect();
236            Ok(Frame::Audio(samples))
237        }
238        Kind::TerminalInput => {
239            let Some((header, bytes)) = payload.split_at_checked(2) else {
240                return Err(Refusal::ShortTerminalInput(payload.len()));
241            };
242            Ok(Frame::TerminalInput {
243                id: u16::from_le_bytes([header[0], header[1]]),
244                bytes: bytes.to_vec(),
245            })
246        }
247        Kind::BrowserFrame | Kind::BrowserAudio | Kind::TerminalOutput => {
248            Err(Refusal::ServerOnly(tag))
249        }
250    }
251}
252
253/// One terminal output frame's bytes: the tag, the id, the sequence, then the
254/// shell's own bytes.
255pub fn terminal_output_bytes(id: u16, seq: u32, bytes: &[u8]) -> Vec<u8> {
256    let mut out = Vec::with_capacity(7 + bytes.len());
257    out.push(Kind::TerminalOutput.tag());
258    out.extend_from_slice(&id.to_le_bytes());
259    out.extend_from_slice(&seq.to_le_bytes());
260    out.extend_from_slice(bytes);
261    out
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    /// A dictation payload built from i16 samples, the wire's own vocabulary.
269    fn payload(samples: &[i16]) -> Vec<u8> {
270        let mut bytes = vec![Kind::Dictation.tag()];
271        for sample in samples {
272            bytes.extend_from_slice(&sample.to_le_bytes());
273        }
274        bytes
275    }
276
277    /// The dictation wire shape: little-endian i16, scaled by 2^15. The two
278    /// ends pin byte order and scaling together - a big-endian read of the
279    /// same bytes trades 0.5 for 128.0, and a /32767 scale moves the
280    /// full-scale sample.
281    #[test]
282    fn a_dictation_frame_decodes_to_the_samples_the_bytes_carry() {
283        let frame = decode(&payload(&[0, 32767, -32768, 16384])).expect("a well-formed frame");
284        assert_eq!(
285            frame,
286            Frame::Audio(vec![0.0, 32767.0 / 32768.0, -1.0, 0.5]),
287            "little-endian i16 over 2^15, in order"
288        );
289    }
290
291    /// **The audio wire carries the tab.** The viewer keys every packet to
292    /// its tab, so a header without it shifts every field after the profile
293    /// and every viewer misreads the packet.
294    #[test]
295    fn an_audio_packet_carries_the_profile_tab_and_samples() {
296        let audio = BrowserAudio {
297            profile: 3,
298            tab: 7,
299            seq: 9,
300            sample_rate: 48_000,
301            channels: 2,
302            samples: vec![1, -2],
303        };
304        let mut expected = vec![Kind::BrowserAudio.tag()];
305        expected.extend_from_slice(&3_u16.to_le_bytes());
306        expected.extend_from_slice(&7_u16.to_le_bytes());
307        expected.extend_from_slice(&9_u32.to_le_bytes());
308        expected.extend_from_slice(&48_000_u32.to_le_bytes());
309        expected.push(2);
310        expected.extend_from_slice(&1_i16.to_le_bytes());
311        expected.extend_from_slice(&(-2_i16).to_le_bytes());
312        let bytes = audio_bytes(&audio);
313        assert_eq!(
314            bytes, expected,
315            "the tag, profile, tab, sequence, sample rate, channels, then samples"
316        );
317        assert_eq!(
318            bytes.len() - audio.samples.len() * 2,
319            1 + 13,
320            "one tag byte plus the viewer's 13-byte SOUND_HEADER_LEN"
321        );
322    }
323
324    /// **A browser frame is the server's to send, never a client's.** The
325    /// host is the only encoder, so a client sending one is refused rather
326    /// than read as something the server could have produced.
327    #[test]
328    fn a_browser_frame_from_a_client_is_refused() {
329        assert_eq!(decode(&[Kind::BrowserFrame.tag(), 1, 2, 3]), Err(Refusal::ServerOnly(1)));
330        assert_eq!(Kind::BrowserFrame.tag(), 1, "the tag the client decodes as a frame");
331        assert_eq!(Kind::Dictation.tag(), 0, "and the audio stream keeps its own");
332    }
333
334    #[test]
335    fn a_message_with_no_header_is_refused() {
336        assert_eq!(decode(&[]), Err(Refusal::ShortHeader), "an empty message is not a frame");
337    }
338
339    /// A tag this server does not know is refused naming it, so a client
340    /// speaking a newer kind is legible in the log rather than silent.
341    #[test]
342    fn an_unknown_kind_tag_is_refused_naming_it() {
343        assert_eq!(decode(&[7, 0, 0]), Err(Refusal::UnknownKind(7)));
344    }
345
346    /// The cap is checked before the payload is walked, so a hostile length
347    /// cannot make the server allocate or scan on its say-so.
348    #[test]
349    fn an_oversized_payload_is_refused_with_its_length() {
350        let mut bytes = vec![Kind::Dictation.tag()];
351        bytes.extend(std::iter::repeat_n(0u8, MAX_PAYLOAD_BYTES + 2));
352        assert_eq!(decode(&bytes), Err(Refusal::Oversized(MAX_PAYLOAD_BYTES + 2)));
353    }
354
355    /// Half a sample is a malformed frame, not a sample to drop quietly.
356    #[test]
357    fn an_uneven_payload_is_refused_rather_than_truncated() {
358        assert_eq!(decode(&[Kind::Dictation.tag(), 1, 2, 3]), Err(Refusal::UnevenPayload(3)));
359    }
360
361    /// The wire's rate is the one the models read. Not imported, because
362    /// the transport states its own contract - so a drift between the two
363    /// has to fail here rather than ride a shared constant.
364    #[test]
365    fn the_wire_rate_is_the_rate_the_models_read() {
366        assert_eq!(
367            SAMPLE_RATE,
368            forge_dictate::SAMPLE_RATE,
369            "a frame's samples are what the dictation models consume, at their rate"
370        );
371    }
372
373    /// **A frame crosses the seam whole, and the seam needs no socket.**
374    /// The connection's only coupling to delivery is this trait, so another
375    /// backend implements one method; the frame it receives is the host's
376    /// own bytes, untouched.
377    struct Memory {
378        frames: Vec<BrowserFrame>,
379        audio: Vec<BrowserAudio>,
380        terminal: Vec<(u16, u32, Vec<u8>)>,
381    }
382
383    #[async_trait::async_trait]
384    impl FrameTransport for Memory {
385        async fn deliver(&mut self, frame: &BrowserFrame) -> anyhow::Result<()> {
386            self.frames.push(frame.clone());
387            Ok(())
388        }
389
390        async fn deliver_audio(&mut self, audio: &BrowserAudio) -> anyhow::Result<()> {
391            self.audio.push(audio.clone());
392            Ok(())
393        }
394
395        async fn deliver_terminal(
396            &mut self,
397            id: u16,
398            seq: u32,
399            bytes: &[u8],
400        ) -> anyhow::Result<()> {
401            self.terminal.push((id, seq, bytes.to_vec()));
402            Ok(())
403        }
404    }
405
406    #[tokio::test]
407    async fn a_frame_crosses_the_transport_seam_whole() {
408        let mut transport = Memory { frames: Vec::new(), audio: Vec::new(), terminal: Vec::new() };
409        let frame = BrowserFrame { profile: 5, tab: 2, seq: 3, encoded: vec![9, 8, 7] };
410        transport.deliver(&frame).await.expect("a memory transport never fails");
411        assert_eq!(
412            transport.frames,
413            vec![frame],
414            "the transport receives the whole frame: the profile id, sequence and the host's own bytes"
415        );
416    }
417
418    /// **A terminal input frame is the id, then the bytes, and nothing
419    /// else**: a client's keystrokes reach the PTY untouched.
420    #[test]
421    fn a_terminal_input_frame_carries_its_id_and_bytes() {
422        let mut bytes = vec![Kind::TerminalInput.tag()];
423        bytes.extend_from_slice(&7_u16.to_le_bytes());
424        bytes.extend_from_slice(b"ls\r");
425        assert_eq!(decode(&bytes), Ok(Frame::TerminalInput { id: 7, bytes: b"ls\r".to_vec() }),);
426        assert_eq!(
427            decode(&[Kind::TerminalInput.tag(), 1]),
428            Err(Refusal::ShortTerminalInput(1)),
429            "an id cut in half is refused rather than read as a terminal",
430        );
431    }
432
433    /// A terminal output frame is the server's: the id, the sequence and the
434    /// shell's own bytes, and a client sending one is refused.
435    #[test]
436    fn a_terminal_output_frame_is_the_servers() {
437        let frame = terminal_output_bytes(3, 9, b"hi");
438        assert_eq!(
439            frame,
440            [Kind::TerminalOutput.tag(), 3, 0, 9, 0, 0, 0, b'h', b'i',],
441            "the tag, the id, the sequence and the bytes",
442        );
443        assert_eq!(decode(&frame), Err(Refusal::ServerOnly(4)));
444    }
445
446    /// Every refusal has its own sentence, so a log line says what the
447    /// frame was rather than that something was wrong.
448    #[test]
449    fn every_refusal_names_what_it_refused() {
450        for refusal in [
451            Refusal::ShortHeader,
452            Refusal::UnknownKind(9),
453            Refusal::ServerOnly(1),
454            Refusal::Oversized(MAX_PAYLOAD_BYTES + 1),
455            Refusal::UnevenPayload(3),
456        ] {
457            assert!(!refusal.reason().is_empty(), "{refusal:?} has no line");
458        }
459    }
460}