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}