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}