Skip to main content

tear_types/
wire.rs

1//! Wire-format types for the tear-daemon ↔ tear-client RPC.
2//!
3//! One [`Request`] variant per [`MultiplexerControl`] method; the
4//! daemon dispatches on the variant and replies with a [`Response`]
5//! whose shape matches the trait's return type. The framing is
6//! 4-byte big-endian length-prefixed CBOR (RFC 8949) via `ciborium`.
7//! CBOR was chosen over bincode because `LayoutNode` uses an
8//! internally-tagged enum representation (`#[serde(tag = "kind")]`)
9//! that bincode rejects — CBOR handles every serde tagging style.
10//! The size + speed difference is negligible at IPC scale (single
11//! Request/Response per call, not a streaming hot path).
12//!
13//! ## Why this lives in `tear-types`
14//!
15//! Both `tear-daemon` (server) and `tear-client` (client) need to
16//! agree on the on-wire shape. Putting it here means there's one
17//! source of truth — no risk of the two crates drifting because each
18//! re-declared the Request enum. Pure types only; the framing
19//! helpers ([`read_msg`] / [`write_msg`]) take any `Read`/`Write` so
20//! transports beyond UDS (stdio pipes for embedded use, TCP for
21//! future remote modes) compose trivially.
22//!
23//! ## Versioning
24//!
25//! The wire is **CBOR + serde** (see the framing note above — an
26//! earlier revision of this paragraph said "bincode", which was
27//! stale: bincode was evaluated and rejected for the tagging reason
28//! stated above, and never shipped). Adding a new variant to either
29//! enum at the *end* is backwards-compatible (older clients ignore
30//! variants they don't understand because they never emit them).
31//! Removing or reordering variants is a breaking wire change — bump
32//! the workspace minor version when that happens.
33//!
34//! **Field-level compatibility.** `Request` is externally tagged and
35//! its struct variants are encoded as *field-name-keyed CBOR maps*.
36//! No type here sets `deny_unknown_fields`, so a `#[serde(default)]`
37//! field is compatible in BOTH directions: an old daemon decoding a
38//! new client's frame ignores the key it doesn't know, and a new
39//! daemon decoding an old client's frame fills the missing key from
40//! `Default`. That is what makes a new field *safe*, and equally
41//! what makes it **silent**: an old daemon drops the key and does
42//! the old thing. For `args` that reads as "the program spawned
43//! without its arguments", with no error anywhere.
44//!
45//! ## Capability negotiation
46//!
47//! [`Request::Hello`] / [`Response::Hello`] close that hole. A
48//! client probes once at connect time and gets back a
49//! [`crate::capability::DaemonHello`] naming every field/behaviour
50//! the daemon implements; a call site that needs one refuses with
51//! [`crate::ControlError::Unsupported`] instead of sending a frame
52//! that will be half-ignored. Read
53//! [`crate::capability`] for why this is a capability **set** and
54//! not a protocol version integer.
55//!
56//! **An unknown variant does not have to end the connection.** A
57//! frame whose length prefix was honoured and whose bytes were all
58//! consumed leaves the stream aligned at the next frame boundary
59//! even when the payload names a variant the peer has never heard
60//! of — measured, not assumed. [`read_frame`] surfaces that as
61//! [`Framed::Undecodable`] so a server can answer
62//! `Response::Err(Rejected(..))` and keep serving, which is what
63//! `tear-daemon` now does. Before that, `serve_connection_full`'s
64//! read loop did `Err(e) => return Err(e)`, so the *first* client
65//! to send a variant the daemon didn't know got a bare connection
66//! close. A daemon built before this change still behaves that way,
67//! which is why the client treats a lost connection during the
68//! probe as "protocol 0 / no capabilities" and re-dials rather than
69//! failing.
70
71use std::io::{self, Read, Write};
72
73use serde::{Deserialize, Serialize};
74
75use crate::{
76    ControlError, Direction, LayoutKind, PaneId, PaneSnapshot, SessionId, TearPane, TearSession,
77    TearWindow, WindowId,
78};
79
80/// Every [`MultiplexerControl`] operation, encoded as a single
81/// tagged enum so the daemon can `match` on the variant once and
82/// dispatch.
83#[derive(Clone, Debug, Serialize, Deserialize)]
84pub enum Request {
85    // ── Discovery ────────────────────────────────────────────────
86    ListSessions,
87    GetSession(SessionId),
88    GetWindow(WindowId),
89    GetPane(PaneId),
90    // ── Sessions ─────────────────────────────────────────────────
91    NewSession {
92        name: String,
93        shell: String,
94        /// Optional provenance tag — defaults to None on pre-#6
95        /// wire bytes (serde's default). When present, mado MCP /
96        /// CLI sets it to `Some(Agent)` / `Some(Human)` / `Some(Named(...))`
97        /// so `tear list` can group by source.
98        #[serde(default)]
99        source: Option<crate::session::SessionSource>,
100        /// Optional initial pane size in cells. Defaults to None
101        /// for backwards-compat (older clients omit this field;
102        /// daemon falls back to 80×24). mado attaches at known
103        /// geometry — passing Some((cols, rows)) here means the
104        /// shell's TIOCGWINSZ returns the right size on first
105        /// query, no resize-flicker on attach.
106        #[serde(default)]
107        size_cells: Option<(u16, u16)>,
108        /// Arguments passed to `shell` as argv[1..]. Defaults to
109        /// empty on pre-args wire bytes. Because there is no
110        /// protocol negotiation, a *stale daemon* decoding this
111        /// frame drops the key and spawns the bare program — the
112        /// failure is silent and looks like "my arguments were
113        /// ignored", so restart the daemon after upgrading.
114        #[serde(default)]
115        args: Vec<String>,
116    },
117    RenameSession {
118        id: SessionId,
119        new_name: String,
120    },
121    KillSession(SessionId),
122    // ── Windows ──────────────────────────────────────────────────
123    NewWindow {
124        session: SessionId,
125        name: String,
126        shell: String,
127        /// Arguments passed to `shell` as argv[1..]. See the
128        /// `NewSession::args` note on stale-daemon behaviour.
129        #[serde(default)]
130        args: Vec<String>,
131    },
132    KillWindow(WindowId),
133    SelectWindow(WindowId),
134    // ── Panes ────────────────────────────────────────────────────
135    SplitPane {
136        origin: PaneId,
137        direction: Direction,
138        shell: String,
139        /// Arguments passed to `shell` as argv[1..]. See the
140        /// `NewSession::args` note on stale-daemon behaviour.
141        #[serde(default)]
142        args: Vec<String>,
143    },
144    KillPane(PaneId),
145    SelectPane(PaneId),
146    ResizePane {
147        id: PaneId,
148        direction: Direction,
149        delta_cells: i16,
150    },
151    ApplyLayout {
152        window: WindowId,
153        kind: LayoutKind,
154    },
155    SendKeys {
156        id: PaneId,
157        bytes: Vec<u8>,
158    },
159    // ── Rendering (Phase 2) ──────────────────────────────────────
160    PaneSnapshot(PaneId),
161    /// Promote this connection to a push-mode byte stream from the
162    /// named pane. The daemon responds with `Response::Ok` then a
163    /// continuous stream of `Response::PaneBytes(...)` frames as
164    /// the pane's PTY produces output. The connection is consumed
165    /// — no further Requests are accepted on it. Use a fresh
166    /// connection for control-plane work.
167    Subscribe(PaneId),
168    /// Set the pane's PTY to an absolute size. Fires SIGWINCH at
169    /// the child shell. Used by GPU consumers (mado at Phase 3.1)
170    /// when their window resizes.
171    PaneResizeAbsolute {
172        id: PaneId,
173        cols: u16,
174        rows: u16,
175    },
176    // ── Config (Phase 5 — shikumi-style live reload) ─────────────
177    /// Snapshot the daemon's current `TearConfig` as YAML. Lets
178    /// mado (or any consumer) introspect the live config without
179    /// racing the notify-driven hot-reload + without parsing the
180    /// YAML file directly.
181    GetConfig,
182    /// Force the daemon to re-read its config file from disk. The
183    /// notify watcher normally picks file changes up within ms;
184    /// this is the manual escape hatch for filesystems where
185    /// inotify-equivalents are unreliable (some network mounts).
186    ReloadConfig,
187    /// Push a typed `TearConfig` (serialised as YAML) to the
188    /// daemon — replaces the daemon's live config snapshot
189    /// in-place via the same `LiveConfig::replace` path the
190    /// notify watcher uses. Lets mado (or any client) impose a
191    /// config when it first attaches AND mutate the config
192    /// dynamically over the lifetime of a session (per the M5
193    /// destination — mado is the canonical author of the tear
194    /// config when it's the front-end). Daemon-side config file
195    /// on disk is NOT touched; the next reload reverts.
196    SetConfig(String),
197    /// Push a typed [`SpawnEnv`](crate::SpawnEnv) (the embedder's
198    /// capability env + cwd override) to the daemon. The daemon applies
199    /// it to its `InProcess` so every SUBSEQUENT `NewSession` spawn's
200    /// child PTY sees the embedder's `TERM`/`COLORTERM`/`TERMINFO`/
201    /// `TERM_PROGRAM` (and a stamped `PWD`) AFTER the inherited +
202    /// fallback env — closing the gap where a daemon-spawned child only
203    /// saw the daemon's own env, so a truecolor capability set never
204    /// projected. The embedded path already calls
205    /// `InProcess::set_spawn_env` directly; this is the daemon-transport
206    /// equivalent. Idempotent; the last push wins. Replies
207    /// `Response::Ok`.
208    SetSpawnEnv(crate::SpawnEnv),
209    /// #4 — start daemon-native recording for `pane`. Subsequent
210    /// PTY chunks are captured into a per-pane ring buffer; the
211    /// buffer can later be exported as asciinema v2 .cast via
212    /// `ExportPaneRecording`.
213    StartPaneRecording(PaneId),
214    /// #4 — stop recording. The captured buffer is retained so a
215    /// follow-up `ExportPaneRecording` still works.
216    StopPaneRecording(PaneId),
217    /// #4 — export the pane's captured recording as asciinema
218    /// v2 .cast (JSON-lines string). Returns
219    /// `Response::CastJson(string)`.
220    ExportPaneRecording(PaneId),
221    /// #4 — `(is_enabled, event_count)` for the pane. Returns
222    /// `Response::RecordingStatus { enabled, events }`.
223    PaneRecordingStatus(PaneId),
224    /// Pane-as-block (warp-class UX): list captured OSC 133
225    /// blocks for a pane. `since_index` filters older blocks;
226    /// `limit` caps the response size.
227    PaneBlocksList {
228        pane: PaneId,
229        since_index: u64,
230        limit: u32,
231    },
232    /// Pane-as-block: fetch one block by per-pane index.
233    PaneBlockAt {
234        pane: PaneId,
235        index: u64,
236    },
237    /// Pane-as-block: `(total_completed, in_progress)` summary
238    /// for the pane. Cheap; `tear top` polls this each refresh.
239    PaneBlocksStatus(PaneId),
240    /// Probe how many subscribers (byte-stream consumers) are
241    /// currently attached to a pane. Used by the migration
242    /// ergonomic — `tear pane-info` surfaces the count so an
243    /// operator knows whether they're stepping into an
244    /// already-shared pane, and by the auto-detect path so a new
245    /// renderer can decide between "attach to existing" and
246    /// "start new session".
247    PaneSubscriberCount(PaneId),
248    /// Set a pane's input policy. `InputPolicy::Locked` rejects
249    /// every subsequent `SendKeys` for that pane with
250    /// `WireError::Rejected`; `InputPolicy::Free` re-opens it.
251    /// Useful for demo / observer sessions, agent-only panes
252    /// where human input would interleave, and the migration
253    /// handoff window.
254    SetInputPolicy {
255        id: PaneId,
256        policy: crate::pane::InputPolicy,
257    },
258    /// Engage or release the operator's brake — see [`crate::freio`].
259    ///
260    /// `None` for `session` means EVERY session: the one-gesture panic
261    /// ergonomics live here, in the verb, rather than in a daemon-global
262    /// flag that could drift out of sync with the per-session records it
263    /// is supposed to describe.
264    ///
265    /// **A `bool`, deliberately not a `Freio`.** `Freio::Engaged` carries
266    /// `at_unix`, and a peer must not be able to supply it — the daemon
267    /// stamps the time. The same discipline that made `SessionSource`
268    /// derived rather than declared: if this variant carried a `Freio`,
269    /// a backdated brake would have a wire syntax.
270    SetFreio {
271        session: Option<SessionId>,
272        engaged: bool,
273    },
274    /// Read the brake state of every session.
275    GetFreio,
276    /// Promote this connection to a config-change subscription.
277    /// The daemon responds with `Response::Ok` then emits one
278    /// `Response::ConfigChanged(yaml)` frame every time the live
279    /// config is replaced (by `Request::SetConfig`, by a
280    /// `LiveConfig.reload()`, or by the notify-driven watcher
281    /// catching a file change). Connection is consumed — no
282    /// further Requests are accepted on it. Lets every attached
283    /// renderer react to a theme/keybind change at the same
284    /// moment, broadcast-style: typed config hot-reload to every
285    /// connected client.
286    SubscribeConfigChange,
287    /// #5 — authenticate this connection. Only used when the
288    /// daemon was started with `auth_token_env` set in its
289    /// `TearConfig`. Must be the first request on the connection;
290    /// every other request returns `WireError::Rejected(...)` until
291    /// authentication succeeds. Sending an Authenticate to a daemon
292    /// that does not require auth is silently accepted (forward-
293    /// compatible).
294    Authenticate(String),
295    /// #2 — tag this connection with a 64-bit client identity. Used
296    /// by `InputPolicy::Leader(id)` to gate `SendKeys`: only the
297    /// connection whose IdentifyClient matches the pane's leader id
298    /// may send keys; all other clients get `WireError::Rejected`.
299    /// Sending to a daemon with no Leader-policy pane is a silent
300    /// Ok. Idempotent — calling again overwrites the connection's
301    /// identity. Default identity is `None` (anonymous).
302    IdentifyClient(u64),
303    /// Capability probe. Replies [`Response::Hello`] carrying the
304    /// daemon's own version and every capability it implements.
305    ///
306    /// **This variant is the one the compatibility story hangs on,
307    /// so be precise about how an older peer sees it.** A daemon
308    /// built before this variant existed cannot decode the frame —
309    /// serde reports `unknown variant \`Hello\``. What happens next
310    /// depends on the daemon's read loop:
311    ///
312    /// - built **before** the [`read_frame`] fix: the loop returns
313    ///   the decode error and the connection closes. The client
314    ///   observes EOF and reads that as protocol 0.
315    /// - built **at or after** it: the loop answers
316    ///   `Response::Err(Rejected("unknown request …"))` and stays
317    ///   up. The client reads that as protocol 0 too.
318    ///
319    /// Both land on the same verdict, so the client needs no
320    /// version knowledge to interpret the outcome — which is the
321    /// property that makes this probe safe to send blind.
322    ///
323    /// Sent **after** `Authenticate` on an auth-required daemon,
324    /// because the auth gate rejects everything else first.
325    Hello {
326        /// The client binary's own version. Purely informational —
327        /// the daemon logs it so a "my args were ignored" report can
328        /// be matched to a build. No decision is made on it.
329        #[serde(default)]
330        client_version: String,
331    },
332}
333
334/// Reply shape for every [`Request`] variant. The daemon always
335/// emits exactly one Response per Request — there is no streaming
336/// or multi-frame reply at this layer (subscription / event streams
337/// will land in a separate `Notification` type in Phase 2).
338#[derive(Clone, Debug, Serialize, Deserialize)]
339pub enum Response {
340    Sessions(Vec<TearSession>),
341    Session(TearSession),
342    Window {
343        session: SessionId,
344        window: TearWindow,
345    },
346    Pane(TearPane),
347    SessionId(SessionId),
348    WindowId(WindowId),
349    PaneId(PaneId),
350    PaneSnapshot(PaneSnapshot),
351    /// Pushed by the daemon after a successful Subscribe — one
352    /// frame per PTY chunk. Bytes are exactly what the PTY master
353    /// reader delivered; consumers feed them into their own vte
354    /// parser (or into a tear-core PaneGrid client-side).
355    PaneBytes(Vec<u8>),
356    /// Pushed by the daemon when the subscribed pane is destroyed.
357    /// Subscribers should disconnect after observing this.
358    PaneClosed(PaneId),
359    /// Reply to `Request::GetConfig` — the daemon's current live
360    /// TearConfig serialised as YAML (the same on-disk format
361    /// operators author at `~/.config/tear/tear.yaml`). Wire stays
362    /// in `tear-types`; deserialization back to a typed TearConfig
363    /// happens in tear-client / consumer code which already
364    /// depends on tear-config. YAML over the wire (vs typed CBOR)
365    /// avoids the cycle tear-types ↔ tear-config and keeps the
366    /// daemon's config inspectable with any text tool.
367    ConfigYaml(String),
368    /// Reply to `Request::ExportPaneRecording` — asciinema v2
369    /// .cast (JSON-lines) string ready to write to disk or pipe
370    /// to `asciinema play`.
371    CastJson(String),
372    /// Reply to `Request::PaneRecordingStatus`.
373    RecordingStatus {
374        enabled: bool,
375        events: u32,
376    },
377    /// Reply to `Request::PaneBlocksList`.
378    Blocks(Vec<crate::block::Block>),
379    /// Reply to `Request::PaneBlockAt`.
380    Block(crate::block::Block),
381    /// Reply to `Request::PaneBlocksStatus`.
382    BlocksStatus {
383        total: u32,
384        in_progress: bool,
385    },
386    /// Reply to `Request::PaneSubscriberCount` — number of
387    /// currently-attached byte-stream subscribers for that pane.
388    /// Includes the requester if it has an outstanding subscribe.
389    SubscriberCount(u32),
390    /// Reply to `Request::SetFreio` / `Request::GetFreio`.
391    Freio {
392        /// Every session's brake state after the call.
393        sessions: Vec<(SessionId, crate::freio::Freio)>,
394        /// Panes this call actually braked.
395        braked: Vec<PaneId>,
396        /// ★ Panes the brake could NOT reach, because their provenance is
397        /// unknown (a tmux-backend pane, a pane from a pre-yurai daemon).
398        ///
399        /// **Never elided and never empty-by-convention.** An operator who
400        /// pressed a panic button must be told what it did not stop;
401        /// silence here would let them believe everything halted. This is
402        /// the honest cost of not braking `Unknown` panes — see
403        /// [`crate::session::TearSession::admits`].
404        unbrakable: Vec<PaneId>,
405    },
406    /// Pushed by the daemon on every live-config replace, to
407    /// every connection that issued `Request::SubscribeConfigChange`.
408    /// Payload is the new config as YAML — same shape as
409    /// `Response::ConfigYaml`. The first frame after subscription
410    /// is `Response::Ok`; subsequent frames are `ConfigChanged`
411    /// until the connection is dropped.
412    ConfigChanged(String),
413    /// Reply to [`Request::Hello`] — the daemon's own version plus
414    /// the wire names of every capability it implements.
415    ///
416    /// Adding a `Response` variant is safe in a way adding a
417    /// `Request` variant is not: only a *new* daemon emits this, and
418    /// only in reply to a probe an *old* client never sends. The
419    /// asymmetry is why the negotiation could be added at all
420    /// without a flag day.
421    Hello(crate::capability::DaemonHello),
422    Ok,
423    Err(WireError),
424}
425
426/// Serializable mirror of [`ControlError`]. The trait's `Internal`
427/// variant carries `anyhow::Error` which doesn't serialize; we lose
428/// the typed downcast across the wire but keep the message — which
429/// is fine because clients can only ever treat `Internal` as
430/// opaque-and-fatal anyway.
431#[derive(Clone, Debug, Serialize, Deserialize)]
432pub enum WireError {
433    NoSuchSession(SessionId),
434    NoSuchWindow(WindowId),
435    NoSuchPane(PaneId),
436    Transport(String),
437    Rejected(String),
438    Internal(String),
439}
440
441impl From<ControlError> for WireError {
442    fn from(e: ControlError) -> Self {
443        match e {
444            ControlError::NoSuchSession(id) => WireError::NoSuchSession(id),
445            ControlError::NoSuchWindow(id) => WireError::NoSuchWindow(id),
446            ControlError::NoSuchPane(id) => WireError::NoSuchPane(id),
447            ControlError::Transport(s) => WireError::Transport(s),
448            ControlError::Rejected(s) => WireError::Rejected(s),
449            // Degrades to `Rejected` on purpose. A `WireError`
450            // variant would be a wire change an older client could
451            // not decode, and this error is produced client-side
452            // before a frame is ever written — so the lossy edge is
453            // unreachable in practice, and the message survives.
454            ControlError::Unsupported { capability, detail } => {
455                WireError::Rejected(format!("unsupported capability `{capability}`: {detail}"))
456            }
457            ControlError::Internal(e) => WireError::Internal(e.to_string()),
458        }
459    }
460}
461
462impl From<WireError> for ControlError {
463    fn from(e: WireError) -> Self {
464        match e {
465            WireError::NoSuchSession(id) => ControlError::NoSuchSession(id),
466            WireError::NoSuchWindow(id) => ControlError::NoSuchWindow(id),
467            WireError::NoSuchPane(id) => ControlError::NoSuchPane(id),
468            WireError::Transport(s) => ControlError::Transport(s),
469            WireError::Rejected(s) => ControlError::Rejected(s),
470            WireError::Internal(s) => ControlError::Internal(anyhow::anyhow!(s)),
471        }
472    }
473}
474
475/// Maximum frame size we'll deserialize. Caps allocation on a
476/// malformed length-prefix (16 MiB is far above any real Request
477/// or Response — `ListSessions` reply with thousands of sessions
478/// is still well under a megabyte).
479pub const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
480
481/// Default UDS socket path. Resolves at call time so a daemon
482/// started with `XDG_RUNTIME_DIR=/foo` and a client started later
483/// without the var both look in the same place (the XDG fallback).
484#[must_use]
485pub fn default_socket_path() -> std::path::PathBuf {
486    if let Some(dir) = std::env::var_os("XDG_RUNTIME_DIR") {
487        let mut p = std::path::PathBuf::from(dir);
488        p.push("tear.sock");
489        return p;
490    }
491    if let Some(home) = std::env::var_os("HOME") {
492        let mut p = std::path::PathBuf::from(home);
493        p.push(".local");
494        p.push("share");
495        p.push("tear");
496        p.push("tear.sock");
497        return p;
498    }
499    std::path::PathBuf::from("/tmp/tear.sock")
500}
501
502/// Write a length-prefixed CBOR-encoded message.
503pub fn write_msg<W: Write, T: Serialize>(w: &mut W, msg: &T) -> io::Result<()> {
504    let mut bytes = Vec::new();
505    ciborium::ser::into_writer(msg, &mut bytes)
506        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
507    let len = u32::try_from(bytes.len())
508        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "frame too large"))?;
509    w.write_all(&len.to_be_bytes())?;
510    w.write_all(&bytes)?;
511    w.flush()?;
512    Ok(())
513}
514
515/// Outcome of reading one frame off the wire.
516///
517/// The distinction this type draws is the load-bearing one: a
518/// **payload** we could not understand is not the same event as a
519/// **stream** we could not read. The first leaves the connection
520/// perfectly usable and deserves an answer; the second does not.
521/// Collapsing them into one `io::Error` is what made an unknown
522/// request variant hang up the socket.
523#[derive(Debug)]
524pub enum Framed<T> {
525    /// A complete frame that decoded into `T`.
526    Msg(T),
527    /// A complete frame — length prefix honoured, every one of its
528    /// bytes consumed — whose payload did not decode into `T`. The
529    /// canonical cause is a variant from a newer peer's vocabulary.
530    ///
531    /// **The stream is still aligned at the next frame boundary**,
532    /// so the reader may reply and keep reading. Verified by
533    /// `an_undecodable_frame_leaves_the_stream_aligned`.
534    Undecodable {
535        /// serde's own message, e.g. ``unknown variant `Hello` ``.
536        reason: String,
537        /// Payload length that was consumed.
538        len: usize,
539    },
540}
541
542/// Read one length-prefixed CBOR frame, distinguishing an
543/// undecodable *payload* from an unreadable *stream*.
544///
545/// Caps the frame at [`MAX_FRAME_BYTES`] so a malformed prefix can't
546/// trigger an unbounded allocation. An oversized prefix stays a hard
547/// `io::Error` rather than an [`Framed::Undecodable`], because those
548/// bytes were never counted off the stream — the connection really
549/// is desynchronised at that point.
550///
551/// # Errors
552/// `io::Error` for anything that leaves the stream unusable: EOF,
553/// a short read, a transport failure, or an oversized length prefix.
554pub fn read_frame<R: Read, T: for<'de> Deserialize<'de>>(r: &mut R) -> io::Result<Framed<T>> {
555    let mut len_buf = [0u8; 4];
556    r.read_exact(&mut len_buf)?;
557    let len = u32::from_be_bytes(len_buf) as usize;
558    if len > MAX_FRAME_BYTES {
559        return Err(io::Error::new(
560            io::ErrorKind::InvalidData,
561            format!("frame size {len} exceeds MAX_FRAME_BYTES {MAX_FRAME_BYTES}"),
562        ));
563    }
564    let mut buf = vec![0u8; len];
565    r.read_exact(&mut buf)?;
566    match ciborium::de::from_reader(&buf[..]) {
567        Ok(v) => Ok(Framed::Msg(v)),
568        Err(e) => Ok(Framed::Undecodable {
569            reason: e.to_string(),
570            len,
571        }),
572    }
573}
574
575/// Read a length-prefixed CBOR-encoded message. Caps the frame at
576/// [`MAX_FRAME_BYTES`] so a malformed prefix can't trigger an
577/// unbounded allocation.
578///
579/// Thin wrapper over [`read_frame`] that collapses
580/// [`Framed::Undecodable`] back into `io::ErrorKind::InvalidData`
581/// with serde's message — byte-identical to what this function
582/// returned before `read_frame` existed, so every existing caller
583/// keeps its behaviour. A caller that wants to *answer* an unknown
584/// variant instead of hanging up should call [`read_frame`]
585/// directly; `tear-daemon`'s serve loop does.
586///
587/// # Errors
588/// `io::Error` on transport failure, EOF, an oversized length
589/// prefix, or a payload that does not decode into `T`.
590pub fn read_msg<R: Read, T: for<'de> Deserialize<'de>>(r: &mut R) -> io::Result<T> {
591    match read_frame(r)? {
592        Framed::Msg(v) => Ok(v),
593        Framed::Undecodable { reason, .. } => {
594            Err(io::Error::new(io::ErrorKind::InvalidData, reason))
595        }
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use std::io::Cursor;
603
604    #[test]
605    fn roundtrip_list_sessions_request() {
606        let mut buf = Vec::new();
607        write_msg(&mut buf, &Request::ListSessions).unwrap();
608        let mut cur = Cursor::new(&buf);
609        let got: Request = read_msg(&mut cur).unwrap();
610        assert!(matches!(got, Request::ListSessions));
611    }
612
613    #[test]
614    fn roundtrip_send_keys_request() {
615        let pane = PaneId::from_seed("pane");
616        let req = Request::SendKeys {
617            id: pane,
618            bytes: vec![1, 2, 3, 4],
619        };
620        let mut buf = Vec::new();
621        write_msg(&mut buf, &req).unwrap();
622        let mut cur = Cursor::new(&buf);
623        let got: Request = read_msg(&mut cur).unwrap();
624        match got {
625            Request::SendKeys { id, bytes } => {
626                assert_eq!(id, pane);
627                assert_eq!(bytes, vec![1, 2, 3, 4]);
628            }
629            _ => panic!("wrong variant"),
630        }
631    }
632
633    #[test]
634    fn roundtrip_apply_layout_request() {
635        let window = WindowId::from_seed("win");
636        let req = Request::ApplyLayout {
637            window,
638            kind: LayoutKind::MainVertical,
639        };
640        let mut buf = Vec::new();
641        write_msg(&mut buf, &req).unwrap();
642        let mut cur = Cursor::new(&buf);
643        let got: Request = read_msg(&mut cur).unwrap();
644        match got {
645            Request::ApplyLayout { window: w, kind } => {
646                assert_eq!(w, window);
647                assert_eq!(kind, LayoutKind::MainVertical);
648            }
649            _ => panic!("wrong variant"),
650        }
651    }
652
653    /// **Old client → new daemon.** A frame written WITHOUT the `args`
654    /// key must still decode, filling `args` from `Default`. This is the
655    /// half of the compat verdict that lets a new daemon accept traffic
656    /// from a client built before `args` existed.
657    ///
658    /// The pre-args frame is reconstructed structurally rather than
659    /// checked in as a byte blob: `Request` is externally tagged with
660    /// field-name-keyed struct variants, so a CBOR map carrying only the
661    /// old keys IS exactly what an old client emitted.
662    #[test]
663    fn new_daemon_decodes_a_pre_args_new_session_frame() {
664        use ciborium::value::Value;
665        // { "NewSession": { "name": …, "shell": … } } — no args, and no
666        // source/size_cells either (those are the older `#[serde(default)]`
667        // fields, which is the precedent this follows).
668        let old = Value::Map(vec![(
669            Value::Text("NewSession".into()),
670            Value::Map(vec![
671                (Value::Text("name".into()), Value::Text("work".into())),
672                (Value::Text("shell".into()), Value::Text("/bin/sh".into())),
673            ]),
674        )]);
675        let mut bytes = Vec::new();
676        ciborium::ser::into_writer(&old, &mut bytes).unwrap();
677        let got: Request = ciborium::de::from_reader(&bytes[..])
678            .expect("a pre-args frame must still decode");
679        match got {
680            Request::NewSession { name, shell, source, size_cells, args } => {
681                assert_eq!(name, "work");
682                assert_eq!(shell, "/bin/sh");
683                assert!(source.is_none());
684                assert!(size_cells.is_none());
685                assert!(args.is_empty(), "missing args must default to empty");
686            }
687            other => panic!("wrong variant: {other:?}"),
688        }
689    }
690
691    /// **New client → old daemon.** No type here sets
692    /// `deny_unknown_fields`, so a frame carrying an EXTRA key decodes
693    /// cleanly against a struct that has never heard of it — the key is
694    /// ignored. That is what makes the new `args` field safe to send at
695    /// a stale daemon, and equally what makes the failure SILENT: the
696    /// daemon does not reject the request, it spawns without the
697    /// arguments. Restart the daemon to get the feature.
698    ///
699    /// Modelled by taking a real frame, splicing in a bogus future key,
700    /// and decoding it back: `args` plays exactly that role for a binary
701    /// built before it existed.
702    #[test]
703    fn unknown_fields_are_ignored_so_a_stale_peer_never_errors() {
704        use ciborium::value::Value;
705        let req = Request::SplitPane {
706            origin: PaneId::from_seed("p"),
707            direction: Direction::Right,
708            shell: "/bin/sh".into(),
709            args: vec!["-l".to_string()],
710        };
711        // Round-trip through Value so the id/direction encodings are
712        // whatever serde really produces, not a guess.
713        let mut bytes = Vec::new();
714        ciborium::ser::into_writer(&req, &mut bytes).unwrap();
715        let mut val: Value = ciborium::de::from_reader(&bytes[..]).unwrap();
716        // Splice a key no version of this enum has ever declared into the
717        // variant's field map.
718        let Value::Map(outer) = &mut val else {
719            panic!("externally-tagged variant must encode as a map")
720        };
721        let Value::Map(fields) = &mut outer[0].1 else {
722            panic!("struct variant must encode as a field map")
723        };
724        fields.push((Value::Text("not_a_field_we_know".into()), Value::Bool(true)));
725        let mut spliced = Vec::new();
726        ciborium::ser::into_writer(&val, &mut spliced).unwrap();
727        let got: Request = ciborium::de::from_reader(&spliced[..])
728            .expect("an unknown key must be ignored, not rejected");
729        match got {
730            Request::SplitPane { shell, args, .. } => {
731                assert_eq!(shell, "/bin/sh");
732                assert_eq!(args, vec!["-l".to_string()]);
733            }
734            other => panic!("wrong variant: {other:?}"),
735        }
736    }
737
738    /// `args` survives a real write→read round-trip on all three
739    /// arg-bearing variants.
740    #[test]
741    fn args_roundtrip_on_every_arg_bearing_variant() {
742        let args = vec!["-u".to_string(), "NONE".to_string()];
743        let reqs = vec![
744            Request::NewSession {
745                name: "w".into(),
746                shell: "/bin/nvim".into(),
747                source: None,
748                size_cells: None,
749                args: args.clone(),
750            },
751            Request::NewWindow {
752                session: SessionId::from_seed("s"),
753                name: "w".into(),
754                shell: "/bin/nvim".into(),
755                args: args.clone(),
756            },
757            Request::SplitPane {
758                origin: PaneId::from_seed("p"),
759                direction: Direction::Right,
760                shell: "/bin/nvim".into(),
761                args: args.clone(),
762            },
763        ];
764        for req in reqs {
765            let mut buf = Vec::new();
766            write_msg(&mut buf, &req).unwrap();
767            let got: Request = read_msg(&mut Cursor::new(&buf)).unwrap();
768            let seen = match got {
769                Request::NewSession { args, .. }
770                | Request::NewWindow { args, .. }
771                | Request::SplitPane { args, .. } => args,
772                other => panic!("wrong variant: {other:?}"),
773            };
774            assert_eq!(seen, args);
775        }
776    }
777
778    #[test]
779    fn wire_error_roundtrip_through_control_error() {
780        let pane = PaneId::from_seed("pane");
781        let ce = ControlError::NoSuchPane(pane);
782        let we: WireError = ce.into();
783        let ce2: ControlError = we.into();
784        assert!(matches!(ce2, ControlError::NoSuchPane(p) if p == pane));
785    }
786
787    #[test]
788    fn frame_size_cap_enforced() {
789        // 32 MiB length prefix — must reject without allocating.
790        let len: u32 = 32 * 1024 * 1024;
791        let mut buf = Vec::new();
792        buf.extend_from_slice(&len.to_be_bytes());
793        let mut cur = Cursor::new(&buf);
794        let err = read_msg::<_, Request>(&mut cur).unwrap_err();
795        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
796    }
797
798    #[test]
799    fn default_socket_path_resolves() {
800        let p = default_socket_path();
801        assert!(p.to_string_lossy().ends_with("tear.sock"));
802    }
803
804    #[test]
805    fn roundtrip_pane_resize_absolute_request() {
806        let pane = PaneId::from_seed("resize-pane");
807        let req = Request::PaneResizeAbsolute {
808            id: pane,
809            cols: 132,
810            rows: 50,
811        };
812        let mut buf = Vec::new();
813        write_msg(&mut buf, &req).unwrap();
814        let mut cur = Cursor::new(buf);
815        let got: Request = read_msg(&mut cur).unwrap();
816        match got {
817            Request::PaneResizeAbsolute { id, cols, rows } => {
818                assert_eq!(id, pane);
819                assert_eq!(cols, 132);
820                assert_eq!(rows, 50);
821            }
822            _ => panic!("wrong variant"),
823        }
824    }
825
826    #[test]
827    fn roundtrip_set_spawn_env_request() {
828        let env = crate::SpawnEnv::from_overrides(vec![
829            ("TERM".to_owned(), "xterm-ghostty".to_owned()),
830            ("COLORTERM".to_owned(), "truecolor".to_owned()),
831        ])
832        .with_cwd(Some("/work/dir".to_owned()));
833        let req = Request::SetSpawnEnv(env.clone());
834        let mut buf = Vec::new();
835        write_msg(&mut buf, &req).unwrap();
836        let mut cur = Cursor::new(buf);
837        let got: Request = read_msg(&mut cur).unwrap();
838        match got {
839            Request::SetSpawnEnv(decoded) => assert_eq!(decoded, env),
840            _ => panic!("wrong variant"),
841        }
842    }
843
844    #[test]
845    fn roundtrip_subscribe_request() {
846        let pane = PaneId::from_seed("sub-pane");
847        let req = Request::Subscribe(pane);
848        let mut buf = Vec::new();
849        write_msg(&mut buf, &req).unwrap();
850        let mut cur = Cursor::new(buf);
851        let got: Request = read_msg(&mut cur).unwrap();
852        assert!(matches!(got, Request::Subscribe(p) if p == pane));
853    }
854
855    #[test]
856    fn roundtrip_pane_bytes_response() {
857        let resp = Response::PaneBytes(b"hello\xff\x00 mixed bytes".to_vec());
858        let mut buf = Vec::new();
859        write_msg(&mut buf, &resp).unwrap();
860        let mut cur = Cursor::new(buf);
861        let got: Response = read_msg(&mut cur).unwrap();
862        match got {
863            Response::PaneBytes(b) => {
864                assert_eq!(b, b"hello\xff\x00 mixed bytes");
865            }
866            _ => panic!("wrong variant"),
867        }
868    }
869
870    #[test]
871    fn roundtrip_pane_closed_response() {
872        let pane = PaneId::from_seed("closed-pane");
873        let resp = Response::PaneClosed(pane);
874        let mut buf = Vec::new();
875        write_msg(&mut buf, &resp).unwrap();
876        let mut cur = Cursor::new(buf);
877        let got: Response = read_msg(&mut cur).unwrap();
878        assert!(matches!(got, Response::PaneClosed(p) if p == pane));
879    }
880
881    #[test]
882    fn every_wire_error_variant_roundtrips() {
883        let sid = SessionId::from_seed("s");
884        let wid = WindowId::from_seed("w");
885        let pid = PaneId::from_seed("p");
886        let cases: Vec<ControlError> = vec![
887            ControlError::NoSuchSession(sid),
888            ControlError::NoSuchWindow(wid),
889            ControlError::NoSuchPane(pid),
890            ControlError::Transport("bad pipe".into()),
891            ControlError::Rejected("not allowed".into()),
892            ControlError::Internal(anyhow::anyhow!("boom")),
893        ];
894        for orig in cases {
895            let we: WireError = (orig).into();
896            let ce2: ControlError = we.into();
897            // Type stays in the same variant family. (Internal
898            // collapses to the same kind even though the inner
899            // anyhow chain is opaque after serialise.)
900            assert_eq!(
901                std::mem::discriminant(&ce2_to_kind_marker(&ce2)),
902                std::mem::discriminant(&ce2_to_kind_marker(&ce2)),
903                "discriminant preserved"
904            );
905        }
906    }
907
908    // Helper for the wire-error roundtrip test: erases the inner
909    // payload so we can compare variant tags only.
910    enum Kind {
911        S,
912        W,
913        P,
914        T,
915        R,
916        U,
917        I,
918    }
919    fn ce2_to_kind_marker(e: &ControlError) -> Kind {
920        match e {
921            ControlError::NoSuchSession(_) => Kind::S,
922            ControlError::NoSuchWindow(_) => Kind::W,
923            ControlError::NoSuchPane(_) => Kind::P,
924            ControlError::Transport(_) => Kind::T,
925            ControlError::Rejected(_) => Kind::R,
926            ControlError::Unsupported { .. } => Kind::U,
927            ControlError::Internal(_) => Kind::I,
928        }
929    }
930
931    /// `Unsupported` has no `WireError` mirror by design — it
932    /// degrades to `Rejected` and keeps its message. Pin that so a
933    /// later "let's add a WireError::Unsupported" is a deliberate
934    /// wire decision rather than a drive-by.
935    #[test]
936    fn unsupported_degrades_to_rejected_on_the_wire_keeping_its_message() {
937        let ce = ControlError::Unsupported {
938            capability: "spawn-args",
939            detail: "new_window was given 2 argument(s)".into(),
940        };
941        let we: WireError = ce.into();
942        match we {
943            WireError::Rejected(msg) => {
944                assert!(msg.contains("spawn-args"));
945                assert!(msg.contains("new_window was given 2 argument(s)"));
946            }
947            other => panic!("wrong variant: {other:?}"),
948        }
949    }
950
951    // ── Capability negotiation ───────────────────────────────────
952
953    #[test]
954    fn roundtrip_hello_request_and_response() {
955        let req = Request::Hello {
956            client_version: "1.2.3".into(),
957        };
958        let mut buf = Vec::new();
959        write_msg(&mut buf, &req).unwrap();
960        let got: Request = read_msg(&mut Cursor::new(&buf)).unwrap();
961        match got {
962            Request::Hello { client_version } => assert_eq!(client_version, "1.2.3"),
963            other => panic!("wrong variant: {other:?}"),
964        }
965
966        let hello = crate::capability::DaemonHello::for_this_build("0.1.8");
967        let resp = Response::Hello(hello.clone());
968        let mut buf = Vec::new();
969        write_msg(&mut buf, &resp).unwrap();
970        let got: Response = read_msg(&mut Cursor::new(&buf)).unwrap();
971        match got {
972            Response::Hello(h) => assert_eq!(h, hello),
973            other => panic!("wrong variant: {other:?}"),
974        }
975    }
976
977    /// `client_version` is `#[serde(default)]`, so a Hello frame
978    /// without it still decodes — the same field-level tolerance the
979    /// rest of the wire relies on.
980    #[test]
981    fn a_hello_without_client_version_decodes_to_empty() {
982        use ciborium::value::Value;
983        let bare = Value::Map(vec![(Value::Text("Hello".into()), Value::Map(vec![]))]);
984        let mut bytes = Vec::new();
985        ciborium::ser::into_writer(&bare, &mut bytes).unwrap();
986        let got: Request = ciborium::de::from_reader(&bytes[..]).unwrap();
987        match got {
988            Request::Hello { client_version } => assert!(client_version.is_empty()),
989            other => panic!("wrong variant: {other:?}"),
990        }
991    }
992
993    /// **The measurement the whole negotiation design rests on.**
994    ///
995    /// A frame naming a variant the reader has never heard of does
996    /// NOT desynchronise the stream: the length prefix was honoured,
997    /// every payload byte was consumed, and the very next frame
998    /// decodes normally. So a server hanging up on an unknown
999    /// variant is a *choice*, not a necessity — which is what let
1000    /// `serve_connection_full` be changed to answer instead.
1001    ///
1002    /// The undecodable frame here is a real `Request::Hello`
1003    /// serialised against a reader (`OldRequest`) that predates it —
1004    /// the exact shape of a new client meeting an old daemon.
1005    #[test]
1006    fn an_undecodable_frame_leaves_the_stream_aligned() {
1007        /// A structural stand-in for the `Request` enum as it existed
1008        /// before `Hello` — two variants is enough to prove the
1009        /// point, since serde rejects on the tag before it looks at
1010        /// anything else.
1011        #[derive(Debug, Deserialize)]
1012        #[allow(dead_code)]
1013        enum OldRequest {
1014            ListSessions,
1015            GetConfig,
1016        }
1017
1018        let mut stream = Vec::new();
1019        write_msg(
1020            &mut stream,
1021            &Request::Hello {
1022                client_version: "0.1.9".into(),
1023            },
1024        )
1025        .unwrap();
1026        write_msg(&mut stream, &Request::ListSessions).unwrap();
1027
1028        let mut cur = Cursor::new(stream);
1029        match read_frame::<_, OldRequest>(&mut cur).unwrap() {
1030            Framed::Undecodable { reason, len } => {
1031                assert!(
1032                    reason.contains("unknown variant") && reason.contains("Hello"),
1033                    "expected an unknown-variant reason, got: {reason}"
1034                );
1035                assert!(len > 0);
1036            }
1037            Framed::Msg(m) => panic!("Hello must not decode as OldRequest, got {m:?}"),
1038        }
1039        // The whole point: the reader is still aligned.
1040        match read_frame::<_, OldRequest>(&mut cur).unwrap() {
1041            Framed::Msg(OldRequest::ListSessions) => {}
1042            other => panic!("stream desynchronised after an undecodable frame: {other:?}"),
1043        }
1044    }
1045
1046    /// `read_msg` must stay byte-identical in behaviour to what it
1047    /// was before it was rebuilt on `read_frame` — same error kind,
1048    /// serde's own message. Every existing caller depends on this.
1049    #[test]
1050    fn read_msg_still_collapses_an_undecodable_payload_into_invalid_data() {
1051        #[derive(Debug, Deserialize)]
1052        enum OldRequest {
1053            ListSessions,
1054        }
1055        let mut stream = Vec::new();
1056        write_msg(&mut stream, &Request::GetConfig).unwrap();
1057        let err = read_msg::<_, OldRequest>(&mut Cursor::new(stream)).unwrap_err();
1058        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1059        assert!(err.to_string().contains("unknown variant"));
1060        // Silence the never-constructed warning without weakening
1061        // the enum: it exists purely as a decode target.
1062        let _ = OldRequest::ListSessions;
1063    }
1064
1065    /// An oversized length prefix is NOT an undecodable payload —
1066    /// those bytes were never counted off the stream, so the
1067    /// connection really is lost. Must stay a hard `io::Error`.
1068    #[test]
1069    fn an_oversized_prefix_stays_a_hard_error_not_an_undecodable_frame() {
1070        let len: u32 = 32 * 1024 * 1024;
1071        let mut buf = Vec::new();
1072        buf.extend_from_slice(&len.to_be_bytes());
1073        let err = read_frame::<_, Request>(&mut Cursor::new(buf)).unwrap_err();
1074        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1075        assert!(err.to_string().contains("MAX_FRAME_BYTES"));
1076    }
1077
1078    #[test]
1079    fn truncated_frame_errors_cleanly() {
1080        // 100-byte length-prefix, only 4 bytes of payload — read_msg
1081        // should return io::Error rather than panic.
1082        let mut buf = Vec::new();
1083        let len: u32 = 100;
1084        buf.extend_from_slice(&len.to_be_bytes());
1085        buf.extend_from_slice(&[1, 2, 3, 4]);
1086        let mut cur = Cursor::new(buf);
1087        let err = read_msg::<_, Request>(&mut cur).unwrap_err();
1088        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
1089    }
1090
1091    /// No `Request` variant may carry a `TearPane` — the guard `yurai.rs`
1092    /// says exists.
1093    ///
1094    /// `Shutai` closed the payload-identity door by having no `Deserialize`,
1095    /// so a peer cannot hand the daemon an identity. `Yurai` **must** derive
1096    /// `Deserialize` — it rides outbound inside `TearPane` inside a
1097    /// `Response` that clients decode — which structurally reopens that door
1098    /// one tier lower. What holds it shut is only this: no inbound `Request`
1099    /// carries a `TearPane`, so a peer's bytes have no route into the
1100    /// daemon's pane records.
1101    ///
1102    /// `yurai.rs` names that ceiling exactly — *"adding any request that
1103    /// carries a `TearPane` silently restores the payload path"* — and
1104    /// states it is *"guarded by a source scan, not by the type."* **It was
1105    /// not.** The only source scan in this crate was `shutai.rs`'s; this one
1106    /// was claimed in prose and never written (found 2026-08-01 while
1107    /// checking an adversarial review's hit against the actual tree). A
1108    /// ceiling documented but unguarded is worse than one left undocumented,
1109    /// because a reader budgets trust against the claim.
1110    ///
1111    /// Tier: **CI-caught**, and it cannot be otherwise — "this enum does not
1112    /// mention that type" is not a property Rust can state about itself.
1113    #[test]
1114    fn no_request_variant_carries_a_tearpane() {
1115        let src = include_str!("wire.rs");
1116        let body = src
1117            .split_once("pub enum Request {")
1118            .expect("Request enum must exist")
1119            .1
1120            .split_once("\n}")
1121            .expect("Request enum must terminate")
1122            .0;
1123        assert!(
1124            body.len() > 200,
1125            "scan found only {} bytes of Request — the parser has broken, and \
1126             a broken parser here reports FALSE SAFETY",
1127            body.len()
1128        );
1129        assert!(
1130            !body.contains("TearPane"),
1131            "a Request variant now carries a TearPane, which restores the \
1132             peer-supplied-provenance path Shutai's missing Deserialize \
1133             closed: a client could hand the daemon a pane whose `yurai` it \
1134             chose. Carry a PaneId and let the daemon resolve it."
1135        );
1136    }
1137}