main ece7682

protocol.md RawHistoryFolder

Vev session protocol

This document defines the observable Protobuf session contract and its QUIC, Unix, and SSH stdio carriage rules. Each invariant maps to a current test or benchmark.

Message directions

internal/protocol/messages.go closes both unions. The daemon rejects a wrong-direction frame before any mutation (sessionwire.ErrWrongDirection → protocol.DecodeWrongDirection).

Client → server: Hello, Input, Resize, Detach, Ping, List, Kill, Theme, Ack, ImagePush, ClientNotice, CommandRequest, OutputResetRequest, SelectTab, TerminalFocus, ModifierState, RemotePreviewWatch, RouteAttentionSubscription, SamePeerSwitchRequest, RecentRouteSnapshot, RouteNavigationFailure, SessionCreationFailure, UIFence, NavigationInventoryRequest, NavigationInventoryPublication, NavigationInventoryFailure, PickerClose, PickerSelection.

Server → client: Welcome, ErrorMsg, Output, Detached, Pong, Sessions, CommandResult, AttachTarget, RemotePreview, CommittedRouteIdentity, RouteNavigationAction, RouteCreateSessionAction, RouteNavigationFailure, RoutePosition, RouteRetired, SamePeerSwitchFailure, UIReceipt, UIViewUpdate, NavigationInventoryResponse, NavigationInventoryDemand, NavigationInventorySelection, PickerOffer, PickerSnapshot, PickerClosed, PickerResult, PickerFailure, KillResult.

RouteNavigationFailure is the one payload that appears in both unions. sessionwire conversion switches (decodeProtoClient/decodeProtoServer, encodeProtoClient/encodeProtoServer) are exhaustive over the directional ClientEnvelope/ServerEnvelope oneof unions; unknown or opposite-direction payloads fail with DecodeUnknownType / DecodeMalformed before any mutation.

Handshake timeline

One absolute 15-second budget (protocol.HandshakeTimeout) covers dial through the first committed publication, on both ends. Every connection opens with exactly one preamble envelope (PreambleRequest client → server, PreambleResponse server → client) carrying magic, epoch, version (exact equality mandatory), role, and the immutable per-connection ceilings; the preamble is bounded to 4 KiB before allocation and is never replayed on resume. Hello.MaxOutputInFlight stays a per-attachment claim capped by the negotiated ceiling.

  • Client (internal/usecase/client/handshake.go): newHandshakeContext owns a single timer; boundedDial detaches the dial context so a successful carriage outlives the handshake; Close is the only interruption mechanism for blocked transport operations.
  • Daemon (internal/usecase/daemon/handshake.go, daemon.go:handleConn): newHandshakeContext + watchHandshakeTransport close the exact admitted connection on timeout; failHandshakeAttachment retires only that connection and purges a route-created session only when empty. An accepted connection that implements the optional ports.HandshakeDeadlineProvider seam supplies the absolute deadline it was admitted with, which the daemon adopts verbatim: queue delay is consumed instead of restarting the budget, and an already-elapsed deadline fails promptly. Connections without the seam keep a fresh budget from accept. Completion stays where the daemon commits its initial publication; the sessionwire preamble's own completion is not the handshake deadline.

First frame routing (daemon.go:handleConn): Hello → full attach; List, CommandRequest, NavigationInventoryRequest, and Kill are one-shot control connections answered and closed. RemotePreviewWatch streams RemotePreview frames until the client closes. Anything else, including any malformed first frame, gets a typed compatibility response and no session mutation:

First-frame failure kind Response Needs RequestID
DecodeMessageHello, version mismatch ErrorMsg{ErrVersionMismatch} no
DecodeMessageHello, malformed ErrorMsg{ErrInternal} no
DecodeMessageCommand CommandResult{Outcome, Code, Text} yes, else silent close
DecodeMessageKill ErrorMsg{ErrInternal} no — a malformed kill has no reliable RequestID to correlate a KillResult to
DecodeMessageRemotePreview RemotePreview{Status: Malformed} no
DecodeMessageNavigationInventory NavigationInventoryResponse{Invalid / VersionMismatch} yes, else silent close
other / unknown ErrorMsg{ErrInternal, "expected hello"} no

Live checks after decode: Hello.Version, CommandRequest.Version, NavigationInventoryRequest.Version, and the watch's RemotePreviewRequest.Version (independent catalogue-era schema version 1) must equal the negotiated constant or the request fails with the version-mismatch response above. There is no multi-version negotiation: equality is mandatory.

Hello.color (ColorCapabilities{mode, source}, field 26) is the only carrier of terminal color capability; Theme has no capability field. mode is a closed enum (TRUE_COLOR, ANSI256, ANSI16, MONOCHROME) and a missing message, an UNSPECIFIED mode, or any unknown enum value is a malformed Hello (sessionwire rejects it before any mutation). source UNSPECIFIED means "unknown": the daemon then ignores the claimed mode and re-detects from Hello.Env; DECLARED and FORCED claims are used verbatim. A HEURISTIC truecolor claim upgrades a weaker daemon-side detection; any other HEURISTIC claim falls back to detection. (internal/domain/terminalcap.Resolve; wire tests in sessionwire/proto_convert_test.go:TestProtoHelloColorWire.)

Command requests additionally carry a 10-second result deadline (daemon/command_tracker.go:CommandRequestTimeout), tracked per connection and correlated by RequestID. Every dispatched request produces one closed CommandResult outcome: succeeded, failed, or outcome unknown. Timeout or reply loss after dispatch is outcome unknown and is never replayed.

Broker negotiation

Broker connections are a separate conversation from the session protocol. internal/adapters/brokerwire owns it: client tags 101-114 and server tags 201-211 are disjoint from every session tag, and each direction is its own closed directional oneof union. A broker connection opens with exactly one broker preamble (PreambleRequest client → server, PreambleResponse server → client) using broker roles 3/4. The preamble carries the same magic (wire.PreambleMagic), epoch (wire.ProtocolEpoch), exact protocol.Version equality, and negotiated ceilings as the session handshake (envelope 1 MiB..16 MiB, stream chunk 1..64 KiB), and the serialized preamble is bounded to 4 KiB (wire.PreambleLimit, exposed as brokerwire.CheckPreambleSize) before scan or allocation.

Refusals use the shared PreambleRejectionCode taxonomy. There is no capability negotiation: nonzero capability bits are refused as code 7 (limit refused), and code 6 (out of order) is reserved for the connection dispatcher.

internal/adapters/brokeripc carries this conversation over broker.sock in the per-user runtime directory, with same-user peer-credential admission and bounded per-connection queues. Each logical stream is credit flow controlled in both directions: a sender spends StreamChunkCredit (payload plus 64 bytes) per data frame out of a 4 MiB window (brokerwire.StreamWindowBytes) and waits when it has too little, and the receiver returns the credit of frames its consumer has read in StreamWindowUpdate batches. A slow consumer therefore slows its own stream instead of losing it. Data or cumulative credit beyond the window is a flow-control violation that settles only that stream; a single grant of zero or above the window is a malformed frame and fails the connection. The remote helpers _broker-mux-stdio, _broker-mux-quic-bootstrap, and _broker-mux-quic-proxy bridge a remote daemonmux socket to stdio or to one freshly authenticated QUIC stream.

Every daemonmux logical stream is credit flow controlled in both directions. Each side starts with the same per-stream byte window, derived from the negotiated ceilings (aggregate bytes / max streams, 512 KiB by default). A MuxData frame costs its payload plus 64 bytes of credit; the writer waits when credit runs out, and the receiver returns consumed credit with MuxWindowUpdate once half a window is drained. A slow link or a slow consumer therefore slows its own stream without affecting siblings. Data beyond the granted window, or credit beyond the window, is a peer violation and resets only that stream.

Setup is fully bounded: the dialer's one context/deadline covers the Unix dial, preamble, Register send, and wait for Registered, and the server requires Register within the same accept-time handshake budget that bounded the preamble and admission.

Output, ACK, and flow control

An Output state is (Epoch, Base, New, Echo, ViewRevision, Size, Full, Context?, Data). State-bearing output (New != 0) requires a validated ViewContext (non-zero publication, committed route identity, valid tab and pane IDs); side-effect output (New == 0) carries no context and no base. ValidateOutput enforces the epoch/base/new chain (New == Base+1 unless Full with Base == 0).

  • Ack{Epoch, State} is cumulative per epoch; epoch 0 is invalid.
  • At most MaxOutputWindow (8) states may be in flight per attachment (daemon.go:maxUnackedOutputStates); the daemon negotiates the window down from the transport capabilities (Hello.MaxOutputInFlight claim capped by the transport ceiling; datagram carriage reports 1). A client claims its carriage's preferred window, or MaxOutputWindow when the carriage states none, so output pipelines instead of waiting a round trip per frame.
  • Unacknowledged Output data is also bounded by MaxOutputWindowBytes (2 MiB). State frames are charged to their own state, side effects to the next state, and a cumulative ACK releases what it covers. The next state frame waits when either window is full, except that a frame may always be sent while no state frame is in flight. A frame carrying a large image may exceed the byte window; it then holds the next frame until its ACK.
  • The client coalesces ACKs (client.go:cumulativeAckQueue) and keeps applying + acknowledging daemon frames even while a picker lease owns the terminal (see below) — admission without physical write.
  • Echo is the newest InputSeq the daemon applied at least 50 ms ago (mosh's echo timeout), so the screen it ships with already holds that input's echo. When no frame is due, the daemon sends it in an empty side-effect Output (New == 0). Clients use it to confirm predicted echo. OutputResetRequest restarts the chain; UIFence{ActionID} orders UI operations and UIReceipt reports the processed boundary (Processed requires epoch/state/publication; Unavailable carries none).

Compression (sessionwire Output converters): only full snapshots at or above 1024 bytes are zlib candidates, and only when the compressed form plus header is smaller. Decoded length is validated against MaxOutputDataLen before allocation; decompression rejects length-excess, trailing bytes, and non-Full compressed states.

Byte ownership: Recv returns freshly allocated, right-sized payload copies the caller may retain; Send/SendAsync copies into the adapter's queue. IPC reuses one grow-once read buffer internally.

Picker admission without physical write

While a client picker overlay is open over a live attachment (session picker PickerOffer, or the first move PickerSnapshot), the client still applies and acknowledges every accepted daemon frame but does not write it. Closing the overlay sends OutputResetRequest, and the daemon answers with an authoritative full paint.

The daemon sends a palette-opened picker only after a frame composed after the palette closed has been emitted, so the erased palette is on screen before output suppression starts. An ACK-blocked paint or a transient freeze delays the picker; a detach or transport change drops it. The daemon side drops raw key/mouse input for the attachment while a move picker is open; only typed picker messages act.

Ordering, close, and admission semantics

  • Each connection is one ordered reliable stream. Concurrent Send calls are serialized by a single writer (IPC) or a mutex (SSH stdio); exactly one Recv caller at a time.
  • Close is concurrent-safe, idempotent, and unblocks in-flight Send/Recv with a terminal error (io.EOF on clean close).
  • Bounded admission, three preserved contracts (ports + wire.Transport): synchronous wire attempt (Send), bounded async admission (SendAsync, 8 queued frames / 32 MiB bytes, ErrBackpressure when full), owned bounded synchronous operation. Buffer ownership, cancellation, and error semantics of each return are unchanged. Synchronous sends rendezvous with the writer and carry no async byte-budget charge; async sends are charged on admission and released exactly once on dequeue.

Current state: the one-shot kill control paths have moved to the explicit result protocol. Kill carries a unique nonzero RequestID, and the daemon answers every normally decoded kill request with exactly one correlated KillResult before the control connection closes: KillSucceeded, KillFailed (with a bounded structured Failures list), or a definite KillOutcomeUnknown. The client consumes that result; a close without a result, a mismatched RequestID, or a wrong-typed reply is outcome unknown and is never replayed, so a control failure whose response cannot be delivered is never mistaken for success. CommandRequest follows the same correlated model through CommandResult: succeeded, failed, or outcome unknown. A timeout, close, cancellation, malformed reply, or mismatched RequestID after send is outcome unknown and is never replayed.

The remaining one-shot controls (List, remote preview, inventory, and picker control) do not treat close as success, but they have no closed outcome field. The daemon attempts one response and then closes; a client that receives no valid response reports a receive error. An undeliverable response is logged only where the handler has a send-failure path, so these controls remain weaker than KillResult and CommandResult.

Limits

Item Ceiling Owner
Preamble envelope 4 KiB (wire.PreambleLimit), exact magic/epoch/version/role protocol/wire/preamble.go, sessionwire/proto_handshake.go
Absolute envelope 16 MiB (wire.AbsoluteEnvelopeLimit), enforced before allocation protocol/wire/preamble.go, streamframe
Control (non-Output, non-image) envelope 1 MiB (wire.ControlEnvelopeLimit) protocol/wire/preamble.go
Output.Data MaxOutputDataLen = 16776809 (16 MiB minus worst-case Protobuf overhead) protocol/session.go
Image envelope 16 MiB absolute (image data rides the envelope ceiling) sessionwire image converters
Queued envelopes per connection 8 messages, 32 MiB bytes (streamframe defaults), then backpressure; sync sends uncharged, async charged once adapters/streamframe
Encoding buffer bounded per-connection framing buffers owned by streamframe adapters/streamframe
Output states in flight 8 (MaxOutputWindow) preferred for QUIC/IPC/SSH daemon, negotiated down
Unacknowledged Output bytes 2 MiB (MaxOutputWindowBytes); one frame always allowed when none is in flight daemon
Broker stream window 4 MiB per stream and direction (brokerwire.StreamWindowBytes), returned by StreamWindowUpdate adapters/brokerwire, enforced in adapters/brokeripc
QUIC unauthenticated peers 4 connections, 3 s auth deadline, one bidirectional stream each; extra/unidirectional streams rejected adapters/quic
QUIC bootstrap 32-byte token + 16-byte nonce (base64), ≤4 KiB readiness (schema v1, host-independent port, SHA-256 pin, ≤15 s expiry), one bounded (512 B) auth record, atomic one-time consumption, token erasure on close adapters/quic bootstrap, app/_broker-mux-quic-bootstrap + _broker-mux-quic-proxy
Handshake, dial → first committed publication 15 s, single absolute deadline propagated unchanged, never reset both ends
Command result 10 s per request daemon/command_tracker.go
RemotePreview viewport 256×128 cells, 1 MiB protocol/preview.go
Route snapshot publication 32 entries protocol/routes.go
Route label 256 bytes, UTF-8, no controls protocol/routes.go
SSH shutdown wait 3 s (sshCloseTimeout) adapters/sshstdio

Framing on IPC/SSH stdio/QUIC stream: 4-byte big-endian length, then one complete serialized Protobuf envelope (no type byte). Zero-length envelopes and lengths above the ceiling fail without allocation. The strict scanner rejects short reads, trailing bytes, duplicate variants, and unknown fields before generated unmarshal.

Authentication boundary

No session bytes are accepted before authentication and version acceptance. Local carriage requires the preamble + Hello/Welcome inside the 15-second budget; a failed handshake closes the exact connection. Broker-owned remote carriage uses QUIC (internal/adapters/quic, TLS 1.3, ALPN vev/1, exact SHA-256 pin, one bidirectional stream, no 0-RTT); a host added with vev host add --transport ssh uses SSH stdio instead. For the QUIC route the broker starts one short-lived _broker-mux-quic-bootstrap --production over SSH which spawns a detached _broker-mux-quic-proxy (ephemeral listener + certificate, 32-byte token, ≤4 KiB readiness with host-independent port/pin/expiry), prints the single readiness line, and exits. The broker resolves the SSH target host, composes host:port, pins the certificate, and sends one bounded auth record before any preamble. The token is consumed atomically on first use; concurrent, replayed, or expired auth fails closed without private daemonmux access, and the proxy exits after session termination, expiry, or setup failure with token material erased.

Error taxonomy

  • Envelope level: short payload, trailing bytes, duplicate variants, unknown fields, invalid wire types, oversize — all fail closed before generated unmarshal, no partial values escape.
  • Typed boundary: protocol.DecodeFailure{Category, Kind, Version, RequestID?, Err} with categories Malformed | UnknownType | WrongDirection. The numeric Type field is retained only for log compatibility; direction is structural (closed oneof unions per direction). Payload bytes are never retained.
  • Transport level: io.EOF (close), ErrFrameTooLarge, ErrZeroLengthFrame, ErrBackpressure, process-exit errors for SSH. LinkState reports transport connectivity only, never semantic handshake acceptance.

Benchmarks

Run the protocol, framing, and typed-connection benchmarks from the repository root:

go test ./internal/protocol/wire -run '^$' -bench 'BenchmarkHelloEnvelope|BenchmarkInputEnvelope|BenchmarkImageEnvelope|BenchmarkOutput' -benchmem -count 5
go test ./internal/adapters/ipc -run '^$' -bench 'BenchmarkTransport' -benchmem -count 5
go test ./internal/adapters/sessionwire -run '^$' -bench 'BenchmarkTyped|BenchmarkOutputProductionRoundTrip' -benchmem -count 5

Scanner specifications are memoized by descriptor (specCache), so each message shape pays reflection once per process. Application and QUIC performance budgets and reproducible evidence commands live in performance.md.

Tooling

  • QUIC: github.com/quic-go/quic-go v0.62.0 (MIT). Verified API surface: TLS 1.3 (MinVersion: tls.VersionTLS13), ALPN-gated single bidirectional stream (AcceptStream server / OpenStreamSync client, MaxIncomingStreams: 1, MaxIncomingUniStreams: 0, extra streams rejected), stream cancellation (CancelRead/CancelWrite), stream deadlines (SetDeadline/SetReadDeadline/SetWriteDeadline), keepalive/idle limits (KeepAlivePeriod, MaxIdleTimeout, HandshakeIdleTimeout), listener ownership (Listener.Accept/Close), explicit close mapping (CloseWithError), 0-RTT disabled by using only DialAddr/ListenAddr (never *Early), resumption observable via ConnectionState.Used0RTT, tracing hook for observability. No QUIC types leak past internal/adapters/quic + internal/app composition; no datagrams, no 0-RTT mutations.
  • Protobuf: google.golang.org/protobuf v1.36.12 runtime + protoc-gen-go v1.36.12 pinned as a Go tool (tool google.golang.org/protobuf/cmd/protoc-gen-go in go.mod); generation via github.com/bufbuild/buf/cmd/buf v1.73.0 pinned as a Go tool (tool github.com/bufbuild/buf/cmd/buf). Sources live in internal/protocol/wire/schema/, generated Go lands directly in internal/protocol/wire (never edited); reproducibility command: go tool buf generate (config buf.gen.yaml), verified by Makefile:protocol-check. No gRPC.
  • Supporting dependencies are pinned in go.mod and go.sum; QUIC requires the corresponding x/crypto, x/net, and x/sys modules.
source
1# Vev session protocol
2
3This document defines the observable Protobuf session contract and its QUIC,
4Unix, and SSH stdio carriage rules. Each invariant maps to a current test or
5benchmark.
6
7## Message directions
8
9`internal/protocol/messages.go` closes both unions. The daemon rejects a
10wrong-direction frame before any mutation (`sessionwire.ErrWrongDirection`
11→ `protocol.DecodeWrongDirection`).
12
13Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`,
14`Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`,
15`OutputResetRequest`, `SelectTab`, `TerminalFocus`, `ModifierState`, `RemotePreviewWatch`,
16`RouteAttentionSubscription`, `SamePeerSwitchRequest`,
17`RecentRouteSnapshot`, `RouteNavigationFailure`,
18`SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`,
19`NavigationInventoryPublication`, `NavigationInventoryFailure`,
20`PickerClose`, `PickerSelection`.
21
22Server → client: `Welcome`, `ErrorMsg`, `Output`, `Detached`, `Pong`,
23`Sessions`, `CommandResult`, `AttachTarget`, `RemotePreview`,
24`CommittedRouteIdentity`, `RouteNavigationAction`,
25`RouteCreateSessionAction`, `RouteNavigationFailure`, `RoutePosition`,
26`RouteRetired`, `SamePeerSwitchFailure`, `UIReceipt`, `UIViewUpdate`,
27`NavigationInventoryResponse`, `NavigationInventoryDemand`,
28`NavigationInventorySelection`, `PickerOffer`, `PickerSnapshot`,
29`PickerClosed`, `PickerResult`, `PickerFailure`, `KillResult`.
30
31`RouteNavigationFailure` is the one payload that appears in both unions.
32`sessionwire` conversion switches (`decodeProtoClient`/`decodeProtoServer`,
33`encodeProtoClient`/`encodeProtoServer`) are exhaustive over the
34directional `ClientEnvelope`/`ServerEnvelope` `oneof` unions; unknown
35or opposite-direction payloads fail with `DecodeUnknownType` /
36`DecodeMalformed` before any mutation.
37
38## Handshake timeline
39
40One absolute 15-second budget (`protocol.HandshakeTimeout`) covers dial
41through the first committed publication, on both ends. Every
42connection opens with exactly one preamble envelope
43(`PreambleRequest` client → server, `PreambleResponse` server →
44client) carrying magic, epoch, version (exact equality mandatory),
45role, and the immutable per-connection ceilings; the preamble is
46bounded to 4 KiB before allocation and is never replayed on resume.
47`Hello.MaxOutputInFlight` stays a per-attachment claim capped by the
48negotiated ceiling.
49
50- Client (`internal/usecase/client/handshake.go`): `newHandshakeContext`
51  owns a single timer; `boundedDial` detaches the dial context so a
52  successful carriage outlives the handshake; `Close` is the only
53  interruption mechanism for blocked transport operations.
54- Daemon (`internal/usecase/daemon/handshake.go`, `daemon.go:handleConn`):
55  `newHandshakeContext` + `watchHandshakeTransport` close the exact
56  admitted connection on timeout; `failHandshakeAttachment` retires only
57  that connection and purges a route-created session only when empty. An
58  accepted connection that implements the optional
59  `ports.HandshakeDeadlineProvider` seam supplies the absolute deadline it
60  was admitted with, which the daemon adopts verbatim: queue delay is
61  consumed instead of restarting the budget, and an already-elapsed
62  deadline fails promptly. Connections without the seam keep a fresh
63  budget from accept. Completion stays where the daemon commits its
64  initial publication; the sessionwire preamble's own completion is not
65  the handshake deadline.
66
67First frame routing (`daemon.go:handleConn`): `Hello` → full attach;
68`List`, `CommandRequest`, `NavigationInventoryRequest`, and `Kill` are
69one-shot control connections answered and closed. `RemotePreviewWatch`
70streams `RemotePreview` frames until the client closes. Anything else, including any
71malformed first frame, gets a typed compatibility response and no session
72mutation:
73
74| First-frame failure kind | Response | Needs RequestID |
75|---|---|---|
76| `DecodeMessageHello`, version mismatch | `ErrorMsg{ErrVersionMismatch}` | no |
77| `DecodeMessageHello`, malformed | `ErrorMsg{ErrInternal}` | no |
78| `DecodeMessageCommand` | `CommandResult{Outcome, Code, Text}` | yes, else silent close |
79| `DecodeMessageKill` | `ErrorMsg{ErrInternal}` | no — a malformed kill has no reliable RequestID to correlate a `KillResult` to |
80| `DecodeMessageRemotePreview` | `RemotePreview{Status: Malformed}` | no |
81| `DecodeMessageNavigationInventory` | `NavigationInventoryResponse{Invalid / VersionMismatch}` | yes, else silent close |
82| other / unknown | `ErrorMsg{ErrInternal, "expected hello"}` | no |
83
84Live checks after decode: `Hello.Version`, `CommandRequest.Version`,
85`NavigationInventoryRequest.Version`, and the watch's `RemotePreviewRequest.Version`
86(independent catalogue-era schema version `1`) must equal the negotiated
87constant or the request fails
88with the version-mismatch response above. There is no multi-version
89negotiation: equality is mandatory.
90
91`Hello.color` (`ColorCapabilities{mode, source}`, field 26) is the only carrier
92of terminal color capability; `Theme` has no capability field. `mode` is a
93closed enum (`TRUE_COLOR`, `ANSI256`, `ANSI16`, `MONOCHROME`) and a missing
94message, an `UNSPECIFIED` mode, or any unknown enum value is a malformed Hello
95(`sessionwire` rejects it before any mutation). `source` `UNSPECIFIED` means
96"unknown": the daemon then ignores the claimed mode and re-detects from
97`Hello.Env`; `DECLARED` and `FORCED` claims are used verbatim. A `HEURISTIC` truecolor claim
98upgrades a weaker daemon-side detection; any other `HEURISTIC` claim falls back
99to detection.
100(`internal/domain/terminalcap.Resolve`; wire tests in
101`sessionwire/proto_convert_test.go:TestProtoHelloColorWire`.)
102
103Command requests additionally carry a 10-second result deadline
104(`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
105connection and correlated by `RequestID`. Every dispatched request produces
106one closed `CommandResult` outcome: succeeded, failed, or outcome unknown.
107Timeout or reply loss after dispatch is outcome unknown and is never replayed.
108
109## Broker negotiation
110
111Broker connections are a separate conversation from the session protocol.
112`internal/adapters/brokerwire` owns it: client tags 101-114 and server tags
113201-211 are disjoint from every session tag, and each direction is its own
114closed directional `oneof` union. A broker connection opens with exactly one
115broker preamble (`PreambleRequest` client → server, `PreambleResponse` server
116→ client) using broker roles 3/4. The preamble carries the same magic
117(`wire.PreambleMagic`), epoch (`wire.ProtocolEpoch`), exact `protocol.Version`
118equality, and negotiated ceilings as the session handshake (envelope
1191 MiB..16 MiB, stream chunk 1..64 KiB), and the serialized preamble is
120bounded to 4 KiB (`wire.PreambleLimit`, exposed as
121`brokerwire.CheckPreambleSize`) before scan or allocation.
122
123Refusals use the shared `PreambleRejectionCode` taxonomy. There is no
124capability negotiation: nonzero capability bits are refused as code 7
125(`limit refused`), and code 6 (`out of order`) is reserved for the
126connection dispatcher.
127
128`internal/adapters/brokeripc` carries this conversation over `broker.sock`
129in the per-user runtime directory, with same-user peer-credential admission
130and bounded per-connection queues. Each logical stream is credit flow
131controlled in both directions: a sender spends `StreamChunkCredit` (payload
132plus 64 bytes) per data frame out of a 4 MiB window
133(`brokerwire.StreamWindowBytes`) and waits when it has too little, and the
134receiver returns the credit of frames its consumer has read in
135`StreamWindowUpdate` batches. A slow consumer therefore slows its own stream
136instead of losing it. Data or cumulative credit beyond the window is a
137flow-control violation that settles only that stream; a single grant of zero
138or above the window is a malformed frame and fails the connection. The remote helpers `_broker-mux-stdio`,
139`_broker-mux-quic-bootstrap`, and `_broker-mux-quic-proxy` bridge a remote
140daemonmux socket to stdio or to one freshly authenticated QUIC stream.
141
142Every daemonmux logical stream is credit flow controlled in both directions.
143Each side starts with the same per-stream byte window, derived from the
144negotiated ceilings (aggregate bytes / max streams, 512 KiB by default). A
145`MuxData` frame costs its payload plus 64 bytes of credit; the writer waits
146when credit runs out, and the receiver returns consumed credit with
147`MuxWindowUpdate` once half a window is drained. A slow link or a slow
148consumer therefore slows its own stream without affecting siblings. Data
149beyond the granted window, or credit beyond the window, is a peer violation
150and resets only that stream.
151
152Setup is fully bounded: the dialer's one context/deadline covers the Unix
153dial, preamble, Register send, and wait for Registered, and the server requires Register within
154the same accept-time handshake budget that bounded the preamble and admission.
155
156## Output, ACK, and flow control
157
158An `Output` state is `(Epoch, Base, New, Echo, ViewRevision, Size, Full,
159Context?, Data)`. State-bearing output (`New != 0`) requires a validated
160`ViewContext` (non-zero publication, committed route identity, valid tab
161and pane IDs); side-effect output (`New == 0`) carries no context and no
162base. `ValidateOutput` enforces the epoch/base/new chain
163(`New == Base+1` unless `Full` with `Base == 0`).
164
165- `Ack{Epoch, State}` is cumulative per epoch; epoch `0` is invalid.
166- At most `MaxOutputWindow` (8) states may be in flight per attachment
167  (`daemon.go:maxUnackedOutputStates`); the daemon negotiates the window
168  down from the transport capabilities (`Hello.MaxOutputInFlight` claim
169  capped by the transport ceiling; datagram carriage reports 1). A client
170  claims its carriage's preferred window, or `MaxOutputWindow` when the
171  carriage states none, so output pipelines instead of waiting a round trip
172  per frame.
173- Unacknowledged Output data is also bounded by `MaxOutputWindowBytes`
174  (2 MiB). State frames are charged to their own state, side effects to the
175  next state, and a cumulative ACK releases what it covers. The next state
176  frame waits when either window is full, except that a frame may always be
177  sent while no state frame is in flight. A frame carrying a large image may
178  exceed the byte window; it then holds the next frame until its ACK.
179- The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps
180  applying + acknowledging daemon frames even while a picker lease owns
181  the terminal (see below) — admission without physical write.
182- `Echo` is the newest `InputSeq` the daemon applied at least 50 ms ago
183  (mosh's echo timeout), so the screen it ships with already holds that
184  input's echo. When no frame is due, the daemon sends it in an empty
185  side-effect `Output` (`New == 0`). Clients use it to confirm predicted
186  echo. `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI
187  operations and `UIReceipt` reports the processed boundary
188  (`Processed` requires epoch/state/publication; `Unavailable` carries
189  none).
190
191Compression (`sessionwire` Output converters): only full snapshots at or above
1921024 bytes are zlib candidates, and only when the compressed form plus
193header is smaller. Decoded length is validated against
194`MaxOutputDataLen` before allocation; decompression rejects
195length-excess, trailing bytes, and non-`Full` compressed states.
196
197Byte ownership: `Recv` returns freshly allocated, right-sized payload
198copies the caller may retain; `Send`/`SendAsync` copies into the
199adapter's queue. IPC reuses one grow-once read buffer internally.
200
201## Picker admission without physical write
202
203While a client picker overlay is open over a live attachment (session
204picker `PickerOffer`, or the first move `PickerSnapshot`), the client
205still applies and acknowledges every accepted daemon frame but does not
206write it. Closing the overlay sends `OutputResetRequest`, and the daemon
207answers with an authoritative full paint.
208
209The daemon sends a palette-opened picker only after a frame composed
210after the palette closed has been emitted, so the erased palette is on
211screen before output suppression starts. An ACK-blocked paint or a
212transient freeze delays the picker; a detach or transport change drops it. The daemon side drops raw
213key/mouse input for the attachment while a move picker is open; only
214typed picker messages act.
215
216## Ordering, close, and admission semantics
217
218- Each connection is one ordered reliable stream. Concurrent `Send` calls
219  are serialized by a single writer (IPC) or a mutex (SSH stdio);
220  exactly one `Recv` caller at a time.
221- `Close` is concurrent-safe, idempotent, and unblocks in-flight
222  `Send`/`Recv` with a terminal error (`io.EOF` on clean close).
223- Bounded admission, three preserved contracts (`ports` +
224  `wire.Transport`): synchronous wire attempt (`Send`), bounded async
225  admission (`SendAsync`, 8 queued frames / 32 MiB bytes,
226  `ErrBackpressure` when full), owned bounded synchronous operation.
227  Buffer ownership, cancellation, and error semantics of each
228  return are unchanged. Synchronous sends rendezvous with the writer
229  and carry no async byte-budget charge; async sends are charged on
230  admission and released exactly once on dequeue.
231
232Current state: the one-shot kill control paths have moved to the explicit
233result protocol. `Kill` carries a unique nonzero `RequestID`, and the daemon
234answers every normally decoded kill request with exactly one correlated
235`KillResult` before the control connection closes: `KillSucceeded`,
236`KillFailed` (with a bounded structured `Failures` list), or a definite
237`KillOutcomeUnknown`. The client consumes that result; a close without a
238result, a mismatched `RequestID`, or a wrong-typed reply is `outcome unknown`
239and is never replayed, so a control failure whose response cannot be delivered
240is never mistaken for success. `CommandRequest` follows the same correlated
241model through `CommandResult`: succeeded, failed, or outcome unknown. A timeout,
242close, cancellation, malformed reply, or mismatched `RequestID` after send is
243outcome unknown and is never replayed.
244
245The remaining one-shot controls (`List`, remote preview, inventory, and picker
246control) do not treat close as success, but they have no closed outcome field.
247The daemon attempts one response and then closes; a client that receives no
248valid response reports a receive error. An undeliverable response is logged
249only where the handler has a send-failure path, so these controls remain weaker
250than `KillResult` and `CommandResult`.
251
252## Limits
253
254| Item | Ceiling | Owner |
255|---|---|---|
256| Preamble envelope | 4 KiB (`wire.PreambleLimit`), exact magic/epoch/version/role | `protocol/wire/preamble.go`, `sessionwire/proto_handshake.go` |
257| Absolute envelope | 16 MiB (`wire.AbsoluteEnvelopeLimit`), enforced before allocation | `protocol/wire/preamble.go`, `streamframe` |
258| Control (non-Output, non-image) envelope | 1 MiB (`wire.ControlEnvelopeLimit`) | `protocol/wire/preamble.go` |
259| `Output.Data` | `MaxOutputDataLen` = 16776809 (16 MiB minus worst-case Protobuf overhead) | `protocol/session.go` |
260| Image envelope | 16 MiB absolute (image data rides the envelope ceiling) | `sessionwire` image converters |
261| Queued envelopes per connection | 8 messages, 32 MiB bytes (`streamframe` defaults), then backpressure; sync sends uncharged, async charged once | `adapters/streamframe` |
262| Encoding buffer | bounded per-connection framing buffers owned by `streamframe` | `adapters/streamframe` |
263| Output states in flight | 8 (`MaxOutputWindow`) preferred for QUIC/IPC/SSH | daemon, negotiated down |
264| Unacknowledged Output bytes | 2 MiB (`MaxOutputWindowBytes`); one frame always allowed when none is in flight | daemon |
265| Broker stream window | 4 MiB per stream and direction (`brokerwire.StreamWindowBytes`), returned by `StreamWindowUpdate` | `adapters/brokerwire`, enforced in `adapters/brokeripc` |
266| QUIC unauthenticated peers | 4 connections, 3 s auth deadline, one bidirectional stream each; extra/unidirectional streams rejected | `adapters/quic` |
267| QUIC bootstrap | 32-byte token + 16-byte nonce (base64), ≤4 KiB readiness (schema v1, host-independent port, SHA-256 pin, ≤15 s expiry), one bounded (512 B) auth record, atomic one-time consumption, token erasure on close | `adapters/quic` bootstrap, `app/_broker-mux-quic-bootstrap` + `_broker-mux-quic-proxy` |
268| Handshake, dial → first committed publication | 15 s, single absolute deadline propagated unchanged, never reset | both ends |
269| Command result | 10 s per request | `daemon/command_tracker.go` |
270| `RemotePreview` viewport | 256×128 cells, 1 MiB | `protocol/preview.go` |
271| Route snapshot publication | 32 entries | `protocol/routes.go` |
272| Route label | 256 bytes, UTF-8, no controls | `protocol/routes.go` |
273| SSH shutdown wait | 3 s (`sshCloseTimeout`) | `adapters/sshstdio` |
274
275Framing on IPC/SSH stdio/QUIC stream: 4-byte big-endian length, then one complete
276serialized Protobuf envelope (no type byte). Zero-length envelopes and
277lengths above the ceiling fail without allocation. The strict scanner
278rejects short reads, trailing bytes, duplicate variants, and unknown
279fields before generated unmarshal.
280
281## Authentication boundary
282
283No session bytes are accepted before authentication and version
284acceptance. Local carriage requires the preamble + `Hello`/`Welcome`
285inside the 15-second budget; a failed handshake closes the exact
286connection. Broker-owned remote carriage uses QUIC (`internal/adapters/quic`,
287TLS 1.3, ALPN `vev/1`, exact SHA-256 pin, one bidirectional stream,
288no 0-RTT); a host added with `vev host add --transport ssh` uses SSH stdio instead.
289For the QUIC route the broker starts one short-lived `_broker-mux-quic-bootstrap --production` over SSH
290which spawns a detached `_broker-mux-quic-proxy` (ephemeral listener +
291certificate, 32-byte token, ≤4 KiB readiness with host-independent
292port/pin/expiry), prints the single readiness line, and exits. The
293broker resolves the SSH target host, composes `host:port`, pins the
294certificate, and sends one bounded auth record before any preamble.
295The token is consumed atomically on first use; concurrent, replayed,
296or expired auth fails closed without private daemonmux access, and the proxy exits
297after session termination, expiry, or setup failure with token
298material erased.
299
300## Error taxonomy
301
302- Envelope level: short payload, trailing bytes, duplicate variants,
303  unknown fields, invalid wire types, oversize — all fail closed before
304  generated unmarshal, no partial values escape.
305- Typed boundary: `protocol.DecodeFailure{Category, Kind, Version,
306  RequestID?, Err}` with categories
307  `Malformed | UnknownType | WrongDirection`. The numeric `Type` field is
308  retained only for log compatibility; direction is structural (closed
309  `oneof` unions per direction). Payload bytes are never retained.
310- Transport level: `io.EOF` (close), `ErrFrameTooLarge`,
311  `ErrZeroLengthFrame`, `ErrBackpressure`, process-exit errors for SSH.
312  `LinkState` reports transport connectivity only, never semantic
313  handshake acceptance.
314
315## Benchmarks
316
317Run the protocol, framing, and typed-connection benchmarks from the repository
318root:
319
320```sh
321go test ./internal/protocol/wire -run '^$' -bench 'BenchmarkHelloEnvelope|BenchmarkInputEnvelope|BenchmarkImageEnvelope|BenchmarkOutput' -benchmem -count 5
322go test ./internal/adapters/ipc -run '^$' -bench 'BenchmarkTransport' -benchmem -count 5
323go test ./internal/adapters/sessionwire -run '^$' -bench 'BenchmarkTyped|BenchmarkOutputProductionRoundTrip' -benchmem -count 5
324```
325
326Scanner specifications are memoized by descriptor (`specCache`), so each
327message shape pays reflection once per process. Application and QUIC performance
328budgets and reproducible evidence commands live in [performance.md](performance.md).
329
330## Tooling
331
332- QUIC: `github.com/quic-go/quic-go v0.62.0` (MIT). Verified API surface:
333  TLS 1.3 (`MinVersion: tls.VersionTLS13`), ALPN-gated single bidirectional
334  stream (`AcceptStream` server / `OpenStreamSync` client,
335  `MaxIncomingStreams: 1`, `MaxIncomingUniStreams: 0`, extra streams
336  rejected), stream cancellation (`CancelRead`/`CancelWrite`), stream
337  deadlines (`SetDeadline`/`SetReadDeadline`/`SetWriteDeadline`),
338  keepalive/idle limits (`KeepAlivePeriod`, `MaxIdleTimeout`,
339  `HandshakeIdleTimeout`), listener ownership (`Listener.Accept/Close`),
340  explicit close mapping (`CloseWithError`), 0-RTT disabled by using only
341  `DialAddr`/`ListenAddr` (never `*Early`), resumption observable via
342  `ConnectionState.Used0RTT`, tracing hook for observability.
343  No QUIC types leak past `internal/adapters/quic` + `internal/app`
344  composition; no datagrams, no 0-RTT mutations.
345- Protobuf: `google.golang.org/protobuf v1.36.12` runtime +
346  `protoc-gen-go v1.36.12` pinned as a Go tool
347  (`tool google.golang.org/protobuf/cmd/protoc-gen-go` in `go.mod`);
348  generation via `github.com/bufbuild/buf/cmd/buf v1.73.0` pinned as a Go
349  tool (`tool github.com/bufbuild/buf/cmd/buf`). Sources live in
350  `internal/protocol/wire/schema/`, generated Go lands directly in
351  `internal/protocol/wire` (never edited); reproducibility command:
352  `go tool buf generate` (config `buf.gen.yaml`), verified by
353  `Makefile:protocol-check`. No gRPC.
354- Supporting dependencies are pinned in `go.mod` and `go.sum`; QUIC requires
355  the corresponding `x/crypto`, `x/net`, and `x/sys` modules.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close