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///
485/// Every arm is ABSOLUTE or it is skipped. Both env arms used to take their
486/// variable verbatim, and only the literal `/tmp/tear.sock` third arm was safe.
487/// That defeats the guarantee the paragraph above is making: with
488/// `XDG_RUNTIME_DIR=""` the result is the bare relative `tear.sock`, so "the
489/// same place" becomes the process's cwd and a daemon started from `$HOME`
490/// and a client started from a repo bind and dial different sockets. Neither
491/// errors — they simply never meet.
492#[must_use]
493pub fn default_socket_path() -> std::path::PathBuf {
494    // okiba applies the spec rule to $XDG_RUNTIME_DIR: a relative or empty
495    // override is IGNORED rather than joined. Same resulting path as before
496    // for every valid value.
497    if let Ok(dir) = okiba::Okiba::for_app("tear").base(okiba::Tier::Runtime) {
498        return dir.join("tear.sock");
499    }
500    // $HOME deliberately stays a direct read rather than okiba's Tier::Data.
501    // Data would honour $XDG_DATA_HOME, which this function never did, and
502    // this is a SOCKET path shared between separately-started processes — a
503    // daemon on new code and a client on old would resolve different paths and
504    // silently stop finding each other. Only the absolute check is added.
505    if let Some(home) = std::env::var_os("HOME").map(std::path::PathBuf::from) {
506        if home.is_absolute() {
507            return home.join(".local/share/tear/tear.sock");
508        }
509    }
510    std::path::PathBuf::from("/tmp/tear.sock")
511}
512
513/// Write a length-prefixed CBOR-encoded message.
514pub fn write_msg<W: Write, T: Serialize>(w: &mut W, msg: &T) -> io::Result<()> {
515    let mut bytes = Vec::new();
516    ciborium::ser::into_writer(msg, &mut bytes)
517        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
518    let len = u32::try_from(bytes.len())
519        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "frame too large"))?;
520    w.write_all(&len.to_be_bytes())?;
521    w.write_all(&bytes)?;
522    w.flush()?;
523    Ok(())
524}
525
526/// Outcome of reading one frame off the wire.
527///
528/// The distinction this type draws is the load-bearing one: a
529/// **payload** we could not understand is not the same event as a
530/// **stream** we could not read. The first leaves the connection
531/// perfectly usable and deserves an answer; the second does not.
532/// Collapsing them into one `io::Error` is what made an unknown
533/// request variant hang up the socket.
534#[derive(Debug)]
535pub enum Framed<T> {
536    /// A complete frame that decoded into `T`.
537    Msg(T),
538    /// A complete frame — length prefix honoured, every one of its
539    /// bytes consumed — whose payload did not decode into `T`. The
540    /// canonical cause is a variant from a newer peer's vocabulary.
541    ///
542    /// **The stream is still aligned at the next frame boundary**,
543    /// so the reader may reply and keep reading. Verified by
544    /// `an_undecodable_frame_leaves_the_stream_aligned`.
545    Undecodable {
546        /// serde's own message, e.g. ``unknown variant `Hello` ``.
547        reason: String,
548        /// Payload length that was consumed.
549        len: usize,
550    },
551}
552
553/// Read one length-prefixed CBOR frame, distinguishing an
554/// undecodable *payload* from an unreadable *stream*.
555///
556/// Caps the frame at [`MAX_FRAME_BYTES`] so a malformed prefix can't
557/// trigger an unbounded allocation. An oversized prefix stays a hard
558/// `io::Error` rather than an [`Framed::Undecodable`], because those
559/// bytes were never counted off the stream — the connection really
560/// is desynchronised at that point.
561///
562/// # Errors
563/// `io::Error` for anything that leaves the stream unusable: EOF,
564/// a short read, a transport failure, or an oversized length prefix.
565pub fn read_frame<R: Read, T: for<'de> Deserialize<'de>>(r: &mut R) -> io::Result<Framed<T>> {
566    let mut len_buf = [0u8; 4];
567    r.read_exact(&mut len_buf)?;
568    let len = u32::from_be_bytes(len_buf) as usize;
569    if len > MAX_FRAME_BYTES {
570        return Err(io::Error::new(
571            io::ErrorKind::InvalidData,
572            format!("frame size {len} exceeds MAX_FRAME_BYTES {MAX_FRAME_BYTES}"),
573        ));
574    }
575    let mut buf = vec![0u8; len];
576    r.read_exact(&mut buf)?;
577    match ciborium::de::from_reader(&buf[..]) {
578        Ok(v) => Ok(Framed::Msg(v)),
579        Err(e) => Ok(Framed::Undecodable {
580            reason: e.to_string(),
581            len,
582        }),
583    }
584}
585
586/// Read a length-prefixed CBOR-encoded message. Caps the frame at
587/// [`MAX_FRAME_BYTES`] so a malformed prefix can't trigger an
588/// unbounded allocation.
589///
590/// Thin wrapper over [`read_frame`] that collapses
591/// [`Framed::Undecodable`] back into `io::ErrorKind::InvalidData`
592/// with serde's message — byte-identical to what this function
593/// returned before `read_frame` existed, so every existing caller
594/// keeps its behaviour. A caller that wants to *answer* an unknown
595/// variant instead of hanging up should call [`read_frame`]
596/// directly; `tear-daemon`'s serve loop does.
597///
598/// # Errors
599/// `io::Error` on transport failure, EOF, an oversized length
600/// prefix, or a payload that does not decode into `T`.
601pub fn read_msg<R: Read, T: for<'de> Deserialize<'de>>(r: &mut R) -> io::Result<T> {
602    match read_frame(r)? {
603        Framed::Msg(v) => Ok(v),
604        Framed::Undecodable { reason, .. } => {
605            Err(io::Error::new(io::ErrorKind::InvalidData, reason))
606        }
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use std::io::Cursor;
614
615    #[test]
616    fn roundtrip_list_sessions_request() {
617        let mut buf = Vec::new();
618        write_msg(&mut buf, &Request::ListSessions).unwrap();
619        let mut cur = Cursor::new(&buf);
620        let got: Request = read_msg(&mut cur).unwrap();
621        assert!(matches!(got, Request::ListSessions));
622    }
623
624    #[test]
625    fn roundtrip_send_keys_request() {
626        let pane = PaneId::from_seed("pane");
627        let req = Request::SendKeys {
628            id: pane,
629            bytes: vec![1, 2, 3, 4],
630        };
631        let mut buf = Vec::new();
632        write_msg(&mut buf, &req).unwrap();
633        let mut cur = Cursor::new(&buf);
634        let got: Request = read_msg(&mut cur).unwrap();
635        match got {
636            Request::SendKeys { id, bytes } => {
637                assert_eq!(id, pane);
638                assert_eq!(bytes, vec![1, 2, 3, 4]);
639            }
640            _ => panic!("wrong variant"),
641        }
642    }
643
644    #[test]
645    fn roundtrip_apply_layout_request() {
646        let window = WindowId::from_seed("win");
647        let req = Request::ApplyLayout {
648            window,
649            kind: LayoutKind::MainVertical,
650        };
651        let mut buf = Vec::new();
652        write_msg(&mut buf, &req).unwrap();
653        let mut cur = Cursor::new(&buf);
654        let got: Request = read_msg(&mut cur).unwrap();
655        match got {
656            Request::ApplyLayout { window: w, kind } => {
657                assert_eq!(w, window);
658                assert_eq!(kind, LayoutKind::MainVertical);
659            }
660            _ => panic!("wrong variant"),
661        }
662    }
663
664    /// **Old client → new daemon.** A frame written WITHOUT the `args`
665    /// key must still decode, filling `args` from `Default`. This is the
666    /// half of the compat verdict that lets a new daemon accept traffic
667    /// from a client built before `args` existed.
668    ///
669    /// The pre-args frame is reconstructed structurally rather than
670    /// checked in as a byte blob: `Request` is externally tagged with
671    /// field-name-keyed struct variants, so a CBOR map carrying only the
672    /// old keys IS exactly what an old client emitted.
673    #[test]
674    fn new_daemon_decodes_a_pre_args_new_session_frame() {
675        use ciborium::value::Value;
676        // { "NewSession": { "name": …, "shell": … } } — no args, and no
677        // source/size_cells either (those are the older `#[serde(default)]`
678        // fields, which is the precedent this follows).
679        let old = Value::Map(vec![(
680            Value::Text("NewSession".into()),
681            Value::Map(vec![
682                (Value::Text("name".into()), Value::Text("work".into())),
683                (Value::Text("shell".into()), Value::Text("/bin/sh".into())),
684            ]),
685        )]);
686        let mut bytes = Vec::new();
687        ciborium::ser::into_writer(&old, &mut bytes).unwrap();
688        let got: Request = ciborium::de::from_reader(&bytes[..])
689            .expect("a pre-args frame must still decode");
690        match got {
691            Request::NewSession { name, shell, source, size_cells, args } => {
692                assert_eq!(name, "work");
693                assert_eq!(shell, "/bin/sh");
694                assert!(source.is_none());
695                assert!(size_cells.is_none());
696                assert!(args.is_empty(), "missing args must default to empty");
697            }
698            other => panic!("wrong variant: {other:?}"),
699        }
700    }
701
702    /// **New client → old daemon.** No type here sets
703    /// `deny_unknown_fields`, so a frame carrying an EXTRA key decodes
704    /// cleanly against a struct that has never heard of it — the key is
705    /// ignored. That is what makes the new `args` field safe to send at
706    /// a stale daemon, and equally what makes the failure SILENT: the
707    /// daemon does not reject the request, it spawns without the
708    /// arguments. Restart the daemon to get the feature.
709    ///
710    /// Modelled by taking a real frame, splicing in a bogus future key,
711    /// and decoding it back: `args` plays exactly that role for a binary
712    /// built before it existed.
713    #[test]
714    fn unknown_fields_are_ignored_so_a_stale_peer_never_errors() {
715        use ciborium::value::Value;
716        let req = Request::SplitPane {
717            origin: PaneId::from_seed("p"),
718            direction: Direction::Right,
719            shell: "/bin/sh".into(),
720            args: vec!["-l".to_string()],
721        };
722        // Round-trip through Value so the id/direction encodings are
723        // whatever serde really produces, not a guess.
724        let mut bytes = Vec::new();
725        ciborium::ser::into_writer(&req, &mut bytes).unwrap();
726        let mut val: Value = ciborium::de::from_reader(&bytes[..]).unwrap();
727        // Splice a key no version of this enum has ever declared into the
728        // variant's field map.
729        let Value::Map(outer) = &mut val else {
730            panic!("externally-tagged variant must encode as a map")
731        };
732        let Value::Map(fields) = &mut outer[0].1 else {
733            panic!("struct variant must encode as a field map")
734        };
735        fields.push((Value::Text("not_a_field_we_know".into()), Value::Bool(true)));
736        let mut spliced = Vec::new();
737        ciborium::ser::into_writer(&val, &mut spliced).unwrap();
738        let got: Request = ciborium::de::from_reader(&spliced[..])
739            .expect("an unknown key must be ignored, not rejected");
740        match got {
741            Request::SplitPane { shell, args, .. } => {
742                assert_eq!(shell, "/bin/sh");
743                assert_eq!(args, vec!["-l".to_string()]);
744            }
745            other => panic!("wrong variant: {other:?}"),
746        }
747    }
748
749    /// `args` survives a real write→read round-trip on all three
750    /// arg-bearing variants.
751    #[test]
752    fn args_roundtrip_on_every_arg_bearing_variant() {
753        let args = vec!["-u".to_string(), "NONE".to_string()];
754        let reqs = vec![
755            Request::NewSession {
756                name: "w".into(),
757                shell: "/bin/nvim".into(),
758                source: None,
759                size_cells: None,
760                args: args.clone(),
761            },
762            Request::NewWindow {
763                session: SessionId::from_seed("s"),
764                name: "w".into(),
765                shell: "/bin/nvim".into(),
766                args: args.clone(),
767            },
768            Request::SplitPane {
769                origin: PaneId::from_seed("p"),
770                direction: Direction::Right,
771                shell: "/bin/nvim".into(),
772                args: args.clone(),
773            },
774        ];
775        for req in reqs {
776            let mut buf = Vec::new();
777            write_msg(&mut buf, &req).unwrap();
778            let got: Request = read_msg(&mut Cursor::new(&buf)).unwrap();
779            let seen = match got {
780                Request::NewSession { args, .. }
781                | Request::NewWindow { args, .. }
782                | Request::SplitPane { args, .. } => args,
783                other => panic!("wrong variant: {other:?}"),
784            };
785            assert_eq!(seen, args);
786        }
787    }
788
789    #[test]
790    fn wire_error_roundtrip_through_control_error() {
791        let pane = PaneId::from_seed("pane");
792        let ce = ControlError::NoSuchPane(pane);
793        let we: WireError = ce.into();
794        let ce2: ControlError = we.into();
795        assert!(matches!(ce2, ControlError::NoSuchPane(p) if p == pane));
796    }
797
798    #[test]
799    fn frame_size_cap_enforced() {
800        // 32 MiB length prefix — must reject without allocating.
801        let len: u32 = 32 * 1024 * 1024;
802        let mut buf = Vec::new();
803        buf.extend_from_slice(&len.to_be_bytes());
804        let mut cur = Cursor::new(&buf);
805        let err = read_msg::<_, Request>(&mut cur).unwrap_err();
806        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
807    }
808
809    #[test]
810    fn default_socket_path_resolves() {
811        let p = default_socket_path();
812        assert!(p.to_string_lossy().ends_with("tear.sock"));
813    }
814
815    #[test]
816    fn roundtrip_pane_resize_absolute_request() {
817        let pane = PaneId::from_seed("resize-pane");
818        let req = Request::PaneResizeAbsolute {
819            id: pane,
820            cols: 132,
821            rows: 50,
822        };
823        let mut buf = Vec::new();
824        write_msg(&mut buf, &req).unwrap();
825        let mut cur = Cursor::new(buf);
826        let got: Request = read_msg(&mut cur).unwrap();
827        match got {
828            Request::PaneResizeAbsolute { id, cols, rows } => {
829                assert_eq!(id, pane);
830                assert_eq!(cols, 132);
831                assert_eq!(rows, 50);
832            }
833            _ => panic!("wrong variant"),
834        }
835    }
836
837    #[test]
838    fn roundtrip_set_spawn_env_request() {
839        let env = crate::SpawnEnv::from_overrides(vec![
840            ("TERM".to_owned(), "xterm-ghostty".to_owned()),
841            ("COLORTERM".to_owned(), "truecolor".to_owned()),
842        ])
843        .with_cwd(Some("/work/dir".to_owned()));
844        let req = Request::SetSpawnEnv(env.clone());
845        let mut buf = Vec::new();
846        write_msg(&mut buf, &req).unwrap();
847        let mut cur = Cursor::new(buf);
848        let got: Request = read_msg(&mut cur).unwrap();
849        match got {
850            Request::SetSpawnEnv(decoded) => assert_eq!(decoded, env),
851            _ => panic!("wrong variant"),
852        }
853    }
854
855    #[test]
856    fn roundtrip_subscribe_request() {
857        let pane = PaneId::from_seed("sub-pane");
858        let req = Request::Subscribe(pane);
859        let mut buf = Vec::new();
860        write_msg(&mut buf, &req).unwrap();
861        let mut cur = Cursor::new(buf);
862        let got: Request = read_msg(&mut cur).unwrap();
863        assert!(matches!(got, Request::Subscribe(p) if p == pane));
864    }
865
866    #[test]
867    fn roundtrip_pane_bytes_response() {
868        let resp = Response::PaneBytes(b"hello\xff\x00 mixed bytes".to_vec());
869        let mut buf = Vec::new();
870        write_msg(&mut buf, &resp).unwrap();
871        let mut cur = Cursor::new(buf);
872        let got: Response = read_msg(&mut cur).unwrap();
873        match got {
874            Response::PaneBytes(b) => {
875                assert_eq!(b, b"hello\xff\x00 mixed bytes");
876            }
877            _ => panic!("wrong variant"),
878        }
879    }
880
881    #[test]
882    fn roundtrip_pane_closed_response() {
883        let pane = PaneId::from_seed("closed-pane");
884        let resp = Response::PaneClosed(pane);
885        let mut buf = Vec::new();
886        write_msg(&mut buf, &resp).unwrap();
887        let mut cur = Cursor::new(buf);
888        let got: Response = read_msg(&mut cur).unwrap();
889        assert!(matches!(got, Response::PaneClosed(p) if p == pane));
890    }
891
892    #[test]
893    fn every_wire_error_variant_roundtrips() {
894        let sid = SessionId::from_seed("s");
895        let wid = WindowId::from_seed("w");
896        let pid = PaneId::from_seed("p");
897        let cases: Vec<ControlError> = vec![
898            ControlError::NoSuchSession(sid),
899            ControlError::NoSuchWindow(wid),
900            ControlError::NoSuchPane(pid),
901            ControlError::Transport("bad pipe".into()),
902            ControlError::Rejected("not allowed".into()),
903            ControlError::Internal(anyhow::anyhow!("boom")),
904        ];
905        for orig in cases {
906            let we: WireError = (orig).into();
907            let ce2: ControlError = we.into();
908            // Type stays in the same variant family. (Internal
909            // collapses to the same kind even though the inner
910            // anyhow chain is opaque after serialise.)
911            assert_eq!(
912                std::mem::discriminant(&ce2_to_kind_marker(&ce2)),
913                std::mem::discriminant(&ce2_to_kind_marker(&ce2)),
914                "discriminant preserved"
915            );
916        }
917    }
918
919    // Helper for the wire-error roundtrip test: erases the inner
920    // payload so we can compare variant tags only.
921    enum Kind {
922        S,
923        W,
924        P,
925        T,
926        R,
927        U,
928        I,
929    }
930    fn ce2_to_kind_marker(e: &ControlError) -> Kind {
931        match e {
932            ControlError::NoSuchSession(_) => Kind::S,
933            ControlError::NoSuchWindow(_) => Kind::W,
934            ControlError::NoSuchPane(_) => Kind::P,
935            ControlError::Transport(_) => Kind::T,
936            ControlError::Rejected(_) => Kind::R,
937            ControlError::Unsupported { .. } => Kind::U,
938            ControlError::Internal(_) => Kind::I,
939        }
940    }
941
942    /// `Unsupported` has no `WireError` mirror by design — it
943    /// degrades to `Rejected` and keeps its message. Pin that so a
944    /// later "let's add a WireError::Unsupported" is a deliberate
945    /// wire decision rather than a drive-by.
946    #[test]
947    fn unsupported_degrades_to_rejected_on_the_wire_keeping_its_message() {
948        let ce = ControlError::Unsupported {
949            capability: "spawn-args",
950            detail: "new_window was given 2 argument(s)".into(),
951        };
952        let we: WireError = ce.into();
953        match we {
954            WireError::Rejected(msg) => {
955                assert!(msg.contains("spawn-args"));
956                assert!(msg.contains("new_window was given 2 argument(s)"));
957            }
958            other => panic!("wrong variant: {other:?}"),
959        }
960    }
961
962    // ── Capability negotiation ───────────────────────────────────
963
964    #[test]
965    fn roundtrip_hello_request_and_response() {
966        let req = Request::Hello {
967            client_version: "1.2.3".into(),
968        };
969        let mut buf = Vec::new();
970        write_msg(&mut buf, &req).unwrap();
971        let got: Request = read_msg(&mut Cursor::new(&buf)).unwrap();
972        match got {
973            Request::Hello { client_version } => assert_eq!(client_version, "1.2.3"),
974            other => panic!("wrong variant: {other:?}"),
975        }
976
977        let hello = crate::capability::DaemonHello::for_this_build("0.1.8");
978        let resp = Response::Hello(hello.clone());
979        let mut buf = Vec::new();
980        write_msg(&mut buf, &resp).unwrap();
981        let got: Response = read_msg(&mut Cursor::new(&buf)).unwrap();
982        match got {
983            Response::Hello(h) => assert_eq!(h, hello),
984            other => panic!("wrong variant: {other:?}"),
985        }
986    }
987
988    /// `client_version` is `#[serde(default)]`, so a Hello frame
989    /// without it still decodes — the same field-level tolerance the
990    /// rest of the wire relies on.
991    #[test]
992    fn a_hello_without_client_version_decodes_to_empty() {
993        use ciborium::value::Value;
994        let bare = Value::Map(vec![(Value::Text("Hello".into()), Value::Map(vec![]))]);
995        let mut bytes = Vec::new();
996        ciborium::ser::into_writer(&bare, &mut bytes).unwrap();
997        let got: Request = ciborium::de::from_reader(&bytes[..]).unwrap();
998        match got {
999            Request::Hello { client_version } => assert!(client_version.is_empty()),
1000            other => panic!("wrong variant: {other:?}"),
1001        }
1002    }
1003
1004    /// **The measurement the whole negotiation design rests on.**
1005    ///
1006    /// A frame naming a variant the reader has never heard of does
1007    /// NOT desynchronise the stream: the length prefix was honoured,
1008    /// every payload byte was consumed, and the very next frame
1009    /// decodes normally. So a server hanging up on an unknown
1010    /// variant is a *choice*, not a necessity — which is what let
1011    /// `serve_connection_full` be changed to answer instead.
1012    ///
1013    /// The undecodable frame here is a real `Request::Hello`
1014    /// serialised against a reader (`OldRequest`) that predates it —
1015    /// the exact shape of a new client meeting an old daemon.
1016    #[test]
1017    fn an_undecodable_frame_leaves_the_stream_aligned() {
1018        /// A structural stand-in for the `Request` enum as it existed
1019        /// before `Hello` — two variants is enough to prove the
1020        /// point, since serde rejects on the tag before it looks at
1021        /// anything else.
1022        #[derive(Debug, Deserialize)]
1023        #[allow(dead_code)]
1024        enum OldRequest {
1025            ListSessions,
1026            GetConfig,
1027        }
1028
1029        let mut stream = Vec::new();
1030        write_msg(
1031            &mut stream,
1032            &Request::Hello {
1033                client_version: "0.1.9".into(),
1034            },
1035        )
1036        .unwrap();
1037        write_msg(&mut stream, &Request::ListSessions).unwrap();
1038
1039        let mut cur = Cursor::new(stream);
1040        match read_frame::<_, OldRequest>(&mut cur).unwrap() {
1041            Framed::Undecodable { reason, len } => {
1042                assert!(
1043                    reason.contains("unknown variant") && reason.contains("Hello"),
1044                    "expected an unknown-variant reason, got: {reason}"
1045                );
1046                assert!(len > 0);
1047            }
1048            Framed::Msg(m) => panic!("Hello must not decode as OldRequest, got {m:?}"),
1049        }
1050        // The whole point: the reader is still aligned.
1051        match read_frame::<_, OldRequest>(&mut cur).unwrap() {
1052            Framed::Msg(OldRequest::ListSessions) => {}
1053            other => panic!("stream desynchronised after an undecodable frame: {other:?}"),
1054        }
1055    }
1056
1057    /// `read_msg` must stay byte-identical in behaviour to what it
1058    /// was before it was rebuilt on `read_frame` — same error kind,
1059    /// serde's own message. Every existing caller depends on this.
1060    #[test]
1061    fn read_msg_still_collapses_an_undecodable_payload_into_invalid_data() {
1062        #[derive(Debug, Deserialize)]
1063        enum OldRequest {
1064            ListSessions,
1065        }
1066        let mut stream = Vec::new();
1067        write_msg(&mut stream, &Request::GetConfig).unwrap();
1068        let err = read_msg::<_, OldRequest>(&mut Cursor::new(stream)).unwrap_err();
1069        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1070        assert!(err.to_string().contains("unknown variant"));
1071        // Silence the never-constructed warning without weakening
1072        // the enum: it exists purely as a decode target.
1073        let _ = OldRequest::ListSessions;
1074    }
1075
1076    /// An oversized length prefix is NOT an undecodable payload —
1077    /// those bytes were never counted off the stream, so the
1078    /// connection really is lost. Must stay a hard `io::Error`.
1079    #[test]
1080    fn an_oversized_prefix_stays_a_hard_error_not_an_undecodable_frame() {
1081        let len: u32 = 32 * 1024 * 1024;
1082        let mut buf = Vec::new();
1083        buf.extend_from_slice(&len.to_be_bytes());
1084        let err = read_frame::<_, Request>(&mut Cursor::new(buf)).unwrap_err();
1085        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1086        assert!(err.to_string().contains("MAX_FRAME_BYTES"));
1087    }
1088
1089    #[test]
1090    fn truncated_frame_errors_cleanly() {
1091        // 100-byte length-prefix, only 4 bytes of payload — read_msg
1092        // should return io::Error rather than panic.
1093        let mut buf = Vec::new();
1094        let len: u32 = 100;
1095        buf.extend_from_slice(&len.to_be_bytes());
1096        buf.extend_from_slice(&[1, 2, 3, 4]);
1097        let mut cur = Cursor::new(buf);
1098        let err = read_msg::<_, Request>(&mut cur).unwrap_err();
1099        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
1100    }
1101
1102    /// No `Request` variant may carry a `TearPane` — the guard `yurai.rs`
1103    /// says exists.
1104    ///
1105    /// `Shutai` closed the payload-identity door by having no `Deserialize`,
1106    /// so a peer cannot hand the daemon an identity. `Yurai` **must** derive
1107    /// `Deserialize` — it rides outbound inside `TearPane` inside a
1108    /// `Response` that clients decode — which structurally reopens that door
1109    /// one tier lower. What holds it shut is only this: no inbound `Request`
1110    /// carries a `TearPane`, so a peer's bytes have no route into the
1111    /// daemon's pane records.
1112    ///
1113    /// `yurai.rs` names that ceiling exactly — *"adding any request that
1114    /// carries a `TearPane` silently restores the payload path"* — and
1115    /// states it is *"guarded by a source scan, not by the type."* **It was
1116    /// not.** The only source scan in this crate was `shutai.rs`'s; this one
1117    /// was claimed in prose and never written (found 2026-08-01 while
1118    /// checking an adversarial review's hit against the actual tree). A
1119    /// ceiling documented but unguarded is worse than one left undocumented,
1120    /// because a reader budgets trust against the claim.
1121    ///
1122    /// Tier: **CI-caught**, and it cannot be otherwise — "this enum does not
1123    /// mention that type" is not a property Rust can state about itself.
1124    #[test]
1125    fn no_request_variant_carries_a_tearpane() {
1126        let src = include_str!("wire.rs");
1127        let body = src
1128            .split_once("pub enum Request {")
1129            .expect("Request enum must exist")
1130            .1
1131            .split_once("\n}")
1132            .expect("Request enum must terminate")
1133            .0;
1134        assert!(
1135            body.len() > 200,
1136            "scan found only {} bytes of Request — the parser has broken, and \
1137             a broken parser here reports FALSE SAFETY",
1138            body.len()
1139        );
1140        assert!(
1141            !body.contains("TearPane"),
1142            "a Request variant now carries a TearPane, which restores the \
1143             peer-supplied-provenance path Shutai's missing Deserialize \
1144             closed: a client could hand the daemon a pane whose `yurai` it \
1145             chose. Carry a PaneId and let the daemon resolve it."
1146        );
1147    }
1148}