a73x

fad55914

docs: the whole tree stops telling stories

a73x   2026-08-23 09:50

Commit message
docs: the whole tree stops telling stories

Eight agents took the remaining 279 flagged doc blocks to zero across
thirty files. Every line of docscheck.budget is now 0, and the ratchet
is exact-match, so no file can grow a heavy comment back without a diff
someone signs.

The rule the sweep applied: a comment states the why a reader standing
at the decl cannot get from the code. A hazard, an invariant, a
deliberate refusal, a constraint that is not visible locally — those
stay. A sentence that restates the code below it is deleted, not
shortened. A claim a test already asserts does not need re-arguing.

Nothing was thrown away. Past compiles, past hangs, timing numbers,
mutation ledgers and "it used to be X and that cost us Y" moved to
decisions.md, grouped by file and naming the decl each fact belonged to.
That file is grepped on demand; the source is re-read every turn.

src/ and tools/ lose 1,904 lines of prose; decisions.md gains 1,158.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

docs/decisions.md
Old New
@@ -5546,3 +5546,1143 @@ and is a rule the tests already assert or a fact about a past run.
5546 - **`relayout` failing keeps the old geometry.** Forgetting a tile can only 5546 - **`relayout` failing keeps the old geometry.** Forgetting a tile can only
5547 give the survivors MORE rows, so `TooSmall` there is unreachable except for 5547 give the survivors MORE rows, so `TooSmall` there is unreachable except for
5548 the empty wall, which is handled before it. 5548 the empty wall, which is handled before it.
5549
5550 ## 2026-08-23 — the tree-wide tier-3 burn-down
5551
5552 Eight agents took the remaining 279 flagged doc blocks to zero across thirty
5553 files, on the doctrine the two headers established earlier today: a comment
5554 states the WHY a reader at the decl cannot get from the code, a justification
5555 is a test, and history stays here.
5556
5557 What follows is what came OUT of those comments — past compiles, past hangs,
5558 timing numbers, mutation ledgers, and rules the tests already assert. It is
5559 grouped by the file it was cut from and names the decl each fact belonged to.
5560 Nothing here was deleted; it moved, because this file is grepped and the
5561 source is re-read every turn.
5562
5563 ### server.zig — facts moved out of doc comments
5564
5565 History, measurements, past bugs and mutation-testing ledgers cut from
5566 `src/server.zig` doc blocks during the tier-3 burn-down. Each bullet names the
5567 decl it was attached to.
5568
5569 #### Transport / QUIC
5570
5571 - **`drainWaitMs`** — the floor and the ceiling each fix a different wedge.
5572 Since commit `207ebbb`, ngtcp2 retransmission happens ONLY in `tick()`: sleep
5573 past an expiry and a lost packet is never resent, the peer has nothing to
5574 acknowledge, the socket never becomes readable, and the connection is wedged
5575 for the whole budget.
5576 - **`boundUdpPort`** — placement rationale: it lives on the daemon rather than
5577 on `quic_server.Listener` beside `pollFd` because the daemon and server.zig's
5578 own quic test helper are the only things outside `quic_server.zig` that have
5579 ever needed to ask the socket for its port, and two callers were not enough to
5580 widen that type's surface.
5581 - **`Sink.close`** — the close-the-connection-not-the-socket rule is pinned by a
5582 test in this file.
5583 - **`Server.quic`** (the ownership union) — replaced a `?*Listener` + `bool`
5584 pair that could type the unrepresentable `(null, owned)` state.
5585 - **`endpointPort`** — the reason a 0 answer goes to stderr and not into the
5586 frame: the asker can do nothing with it but relay, and `muxd endpoint`'s
5587 announce-none already tells the client everything it can act on.
5588 - **`endpointPortFrom`** — key resolution order is `MUX_KEY_FILE` then the
5589 default path. There is deliberately no `--key` half: a daemon being asked
5590 lazily is one that was never handed a flag.
5591 - **`refusalFrame`** — kept parameterless because a builder taking a payload
5592 would need a copy loop no caller would ever exercise.
5593
5594 #### Sessions, sizing, agent forwarding
5595
5596 - **`resolveSession`** — the bug the real threshold prevents. Gating creation on
5597 merely "nonzero" forked a shell whose engine, pty and winsize were all 1×1;
5598 `applySize` then refused to move it, `recordSize` was skipped, the slot stayed
5599 0×0, and `claimGrid` could never claim — so the client that caused the size
5600 could never fix it. A session nobody can use is worse than a refusal nobody
5601 can miss.
5602 - **`makeAgentDir`** — the random half of the directory name also settles the
5603 mundane collision: a SIGKILLed predecessor whose pid we redraw. The 0700 +
5604 exclusive-create posture, and the degrade-to-null posture, are the ones
5605 `shellint.install` wrote down at length.
5606 - **`agentAnswerer`** — a slot that has never attached has no session and no
5607 activity, so it is excluded by the session test before the ranking sees it.
5608 - **`bumpActivity`** — which frames count as `.input` (and why paging scrollback
5609 or asking for stats is not one of them) is argued in the `.input` arm's own
5610 inline comment.
5611
5612 #### Pending side events (clipboard / bell)
5613
5614 - **`pendingSlots`** — the three consumers (replay, expiry, teardown) each used
5615 to hand-write `{ &pending_clipboard, &pending_bell }`, so a third
5616 `SideEvent.Kind` would have compiled clean and been recorded, never replayed,
5617 never expired, and leaked on teardown: the privacy contract would have
5618 silently stopped applying to it.
5619 - **`recordPending`** — a failed dupe drops the event silently, exactly as a
5620 failed encode in `drainSideEvents` does and for the same reason: there is
5621 nowhere to say so, and what is lost is one replay of one event.
5622 - **`rebuildTracker`** — where `rebuild` fails is what matters (delta.zig). It
5623 fails at two POINTS, not in two ways (both are allocation failures): a failure
5624 in the resize block backs out before touching any field, so the drop correctly
5625 retains everything; a failure inside the dump loop happens after `reset_seq`
5626 has already advanced and leaves `rows` at 0, which makes `canServe` false for
5627 every seq in existence — payloads permanently undeliverable and still
5628 resident. The unconditional `defer` is safe because the drop is predicated on
5629 `canServe`, not on which branch got there. Both halves of that predicate are
5630 pinned by direct call in the expiry tests, since neither failure point is
5631 reachable from a test.
5632 - **`replayPending`** — the snapshot branch is guarded twice over, found by
5633 mutation rather than designed: every snapshot path goes through a rebuild, the
5634 rebuild moves `reset_seq` past every recorded seq, and `rebuildTracker` drops
5635 what it has just put out of reach, so a call added AFTER a `snapshotTo` finds
5636 both slots empty. The guards are not redundant (a failed rebuild leaves only
5637 the branch holding the line) but no test can tell that mutation from the real
5638 thing.
5639 - **`replayPending`** — `> have_seq` used to cost an invisible chunk its replay:
5640 `update()` answers `.none` when no cell moved, so a bare BEL or an OSC 52 with
5641 no redraw behind it stayed stamped at the seq the gap began on and the loop
5642 refused it. It no longer does, and not by design — a gap is by definition
5643 unattached, `noteBlind` has no `.none` case, so every pty chunk during one
5644 advances seq. Pinned in delta.zig ("a blind chunk that changed nothing still
5645 advances seq"), because nothing on the server side would notice it going away.
5646 - **`replayPending`** — deferring the replay alongside modes and title is not
5647 even available: defers unwind last-registered-first, so anything registered
5648 there would run BEFORE the sampled-state block at the top.
5649 - **`resyncSnapshot`** — the "still do the bookkeeping, then bail on the
5650 sending" shape used to be shared with `drainSideEvents`, which cited it. The
5651 divergence is deliberate: the pending slots gave that drain something to fold
5652 into session state on the way past, so it now encodes for nobody on purpose.
5653
5654 #### Command state / awaits
5655
5656 - **`sendCmdStateTo`** — closes the same gap `sendPtyModeTo` does, one level up:
5657 `cmd_state` is only pushed on a transition, so a client attaching between two
5658 commands would know nothing until the next one. `muxa status` is the way to
5659 ask about a session that has never spoken marks; it reports the regime rather
5660 than claiming a transition. The ordering is the opposite of `sendPtyModeTo`'s
5661 for the same underlying reason: mode bits describe how to read bytes that have
5662 not arrived yet, rows describe bytes that have.
5663 - **`fallbackState`** — one owner for the overrides because the three fallback
5664 arms in `checkAwaits` differ only in which overrides they take, and
5665 hand-patching the struct at each site made a set of deliberate differences
5666 look like three drifting copies. `phase` and `clear_exit_code` default to
5667 leaving what `cmdState` built: a mechanism overrides only what it can claim to
5668 know. The seq override orders the reply against the grid content the client
5669 has, which is what a fallback answer is about; whether these arms should move
5670 the watermark at all is an open question recorded on `proto.CmdState.seq`.
5671 - **`queueSelectionReply`** — encoding into isolated scratch means a failure has
5672 not touched the client's pending bytes, so it costs this reply only;
5673 `queueFrame` retains its own rule for failures after the complete payload
5674 reaches the real queue. `.unavailable` with no text is the one reply that
5675 cannot fail validation, leaving allocation as its only remaining way to be
5676 lost.
5677
5678 #### Sampling, stats, tuning
5679
5680 - **`sampleTermModes`** — sampled rather than intercepted because a mode has no
5681 history worth keeping: a reattaching client needs the current value.
5682 - **`sampleTermTitle`** — same sampled-state discipline and same early return as
5683 `sampleTermModes`: a title changes when you cd or start an editor, not per
5684 chunk.
5685 - **`accrueSnapshotEquiv`** — pays a full snapshot serialization per update
5686 purely to measure the saving. Put it behind an option if it ever costs
5687 anything.
5688 - **`liveClients`** — slot occupancy was previously unobservable from outside: a
5689 QUIC connection that completes its handshake and never attaches holds a slot
5690 until its idle timeout, and there was no way to see that happening, or to see
5691 it clear, without attaching a debugger.
5692 - **`statsText`** — the old leading `seq=` field was ONE session's tracker,
5693 which had no honest answer once there could be more than one, so it moved off
5694 the main line entirely. The fields that were always parsed by name
5695 (`snapshots=`, `clients=`) keep meaning what they always meant. The bench
5696 measures a live single client whose queue drains every pump, so accruing byte
5697 counters at queue-accept time leaves the ratio it reports unaffected.
5698 - **`shrinkSendBuf`** — the tests depend only on the result being small, never
5699 on its value (Linux doubles the request and clamps up to `SOCK_MIN_SNDBUF`).
5700
5701 #### Test fixtures
5702
5703 - **`awaitFrame`** — `iters` is roughly 6ms of wall clock each (a 5ms pump plus
5704 a 1ms poll), so 200 is about a second and a quarter. Bounded so a regression
5705 fails there instead of hanging the suite. The blocking read is safe only
5706 because nothing in the test path can split a frame across two writes; see
5707 `shrinkSendBuf` for the deliberate work it takes to make the daemon's socket
5708 buffer too small to swallow a frame whole.
5709 - **`expectInitRefused`** — a Server built when it should not have been owns a
5710 live shell on a pty; letting the test discard it leaves that shell holding the
5711 test runner's stdout, so the build never sees EOF and hangs for hours. That is
5712 exactly how the daemon-stealing bug hid in the first place.
5713 - **`bash_rc_with_prompt_member`** — Arch's `/etc/bash.bashrc` appends a
5714 `PROMPT_COMMAND` member under any `xterm*` TERM, and muxd sets exactly that;
5715 the fixture plants the same shape rather than relying on the system file.
5716 - **`zsh_rc_with_precmd_hook`** — zsh hands each precmd hook the original
5717 command's status rather than the previous hook's, which is what makes mux's
5718 reading safe. Measured on this box before it was asserted.
5719 - **`writeGapShell`** — the mutation ledger for the fixture's four gap events:
5720 - `after-osc`, the SECOND clipboard set and the BEL: remove any and a test
5721 goes red.
5722 - the FIRST clipboard set: removing it leaves the suite green. It holds up a
5723 MUTATION — it is the only reason the CLIPBOARD slot is written twice, so
5724 without it `recordPending` keeping the first occupant instead of the last is
5725 indistinguishable from correct. Specifically that set, not the replacement
5726 free next to it: the bell tests write the bell slot repeatedly by
5727 themselves, so with the set deleted AND the free deleted a bell test still
5728 reports the leak. (Both checked by mutation; the first draft of this bullet
5729 named the free and was wrong.)
5730 - `gap-open`: removing it leaves the suite green and uncovers no mutation. It
5731 removes a DEPENDENCY instead — without it these tests pass because the pty
5732 echoes `go`, so they would keep passing until they ran somewhere with ECHO
5733 off and then fail describing the replay rule rather than the terminal
5734 setting. It was load-bearing back when only a changed cell, a moved cursor
5735 or history growth advanced `tracker.seq`.
5736 - the BEL also matters because every comment on the bell replay path is
5737 clipboard-flavoured, which is how a well-meant tightening ("the privacy rule
5738 is about the clipboard") could quietly drop it.
5739 - **`writeDyingGapShell`** — a session dies through `reapSessions`, not through
5740 `Server.deinit`, and those are the two places the teardown half of the pending
5741 contract is honoured. Every gap test above holds its shell open and so
5742 exercises only the deinit site; nothing reached the reap site at all, and a
5743 shell that exits between the copy and the next attach is the ordinary case.
5744 - **`modesWithResync`** — separate from `awaitFrame` because the question is
5745 about a PAIR (which branch ran, and what it said about the modes) and
5746 `awaitFrame` drops everything that is not what it was asked for, including the
5747 frame that names the branch.
5748
5749 ### interact.zig — facts moved out of the source
5750
5751 History, measurements, past bugs and rationale trimmed from `src/interact.zig`
5752 doc blocks. Each bullet names the symbol it belonged to.
5753
5754 #### `appendTermTitle`
5755
5756 - OSC 0 is written rather than OSC 2 so the icon name moves with the title:
5757 OSC 0 is what a session's own applications write, and both forms reach the
5758 engine as one window-title operation. Mirroring what the session wrote is
5759 the point.
5760 - Restoring the user's ORIGINAL title on exit is not this function's job and
5761 is not left undone: the host terminal's own title stack carries it, via the
5762 `22;0t` that leads the alt-screen entry and the `23;0t` that closes
5763 `terminal_teardown`.
5764 - mux cannot read a title back, and the engine cannot help: ghostty's terminal
5765 handler ignores `title_push`/`title_pop` outright, so the SESSION's title
5766 stack does not exist to be mirrored. This is why the host terminal's own
5767 stack is the only mechanism available.
5768 - The control-byte refusal (anything below 0x20, plus DEL 0x7f) exists because
5769 such a byte terminates the OSC early — BEL is the terminator itself, ESC
5770 begins the other one — and everything after it lands on the user's screen as
5771 text they have to clear. Re-checked client-side because the daemon's own
5772 check (`sampleTermTitle`) is on the other side of a wire whose peer need not
5773 be this version of muxd.
5774
5775 #### `writeSideChannel`
5776
5777 - **The bug this gating closed** (the title found it): `is_tty` is `isatty` of
5778 STDIN — it gates raw mode and the alt-screen entry, both about input — while
5779 the side-channel writes go to STDOUT. With stdin redirected and stdout still
5780 a terminal (`echo x | mux`, `mux < /dev/null` typed at a prompt) the claim
5781 never left `.none`, so mux would set the user's title and never pop it, and
5782 turn bracketed paste on and never turn it off. Every side channel had the
5783 same shape; only the title made it a broken promise, because the title is the
5784 one mux justified by saying it could put things back.
5785 - **The accepted cost:** in that mode the session's title, clipboard and bell
5786 go nowhere even though a terminal is attached to stdout and would have shown
5787 them. That matches what mux already does there (no raw mode, no alternate
5788 screen, no hidden cursor); the alternative is a client changing terminal
5789 state it has arranged no way to change back. Gated in one place rather than
5790 at the three call sites so a fourth channel cannot arrive without it.
5791 - Side channels are written OUTSIDE the paint's synchronized-update bracket:
5792 they are messages TO the terminal, not part of the picture, and a sync
5793 bracket around one would hold it until the next frame.
5794 - `append` is a declared function type rather than `anytype` because `anytype`
5795 accepts a builder that fails some way other than allocation, and that error
5796 propagates out of `Core.frame` into the driver's pump, which abandons the
5797 rest of the read (`wallview.pumpTile`'s `break :frames`). A stray clipboard
5798 byte does not get to do that. `Value` is comptime for the same reason: it
5799 names the contract and lets each caller's value coerce to the type its
5800 builder declares.
5801
5802 #### `appendHostEffect`
5803
5804 - The clipboard target and base64 alphabet are re-checked here rather than
5805 trusted from the effect because `ClipboardSet` is a plain struct — Zig cannot
5806 make the validating decoder its only constructor — and one caller already
5807 builds an unvalidated one: `wasm_core.zig` default-initialises its borrowed
5808 clipboard slot to `.{ .target = 0, .base64 = &.{} }`, which `validClipboard`
5809 refuses. A linear scan over at most 64 KiB is free next to the write it
5810 guards; the alternative is `ESC]52;<NUL>;BEL` on a real tty.
5811 - Every builder in this file is built whole before the first byte is appended:
5812 a rejection must not leave half an escape behind for a caller that reuses one
5813 buffer across events.
5814
5815 #### `writeSelectionCopy`
5816
5817 - Routed through `appendHostEffect` rather than a second OSC 52 of its own: the
5818 target check, the alphabet check and the all-or-nothing shape belong to that
5819 function, and a hand-rolled write would be a second place to get them wrong.
5820 - Target `c` is the clipboard proper — what tmux's `set-clipboard external`
5821 sets, and what a paste reads back.
5822 - `owns_terminal` is true unconditionally because the claim answers whether a
5823 SESSION's clipboard event may reach a terminal this tile does not hold; this
5824 text is the user's own drag on the screen in front of them, and at the wall
5825 the tile answering it is a demoted stripe every time. The caller gates on
5826 `is_tty` instead — a piped `mux` claims no mouse modes and can have no drag.
5827
5828 #### `appendMouseModes`
5829
5830 - An application that asked for the mouse gets EXACTLY the modes it asked for
5831 and every mouse byte verbatim (see `MouseFilter`'s call site); otherwise the
5832 client keeps its own capture set and spends the wheel on scrollback.
5833 - A full level-set of all eight modes every time, not a diff, because
5834 `term_modes` is sampled state that repeats on every attach and reconnect and
5835 the terminal on the other end may be one this process never configured (a
5836 reconnect, a `--via` that reconnected under us). Level-setting is idempotent,
5837 so the repeats cost bytes and nothing else.
5838
5839 #### `mouse_teardown`
5840
5841 - Built from the wire table rather than typed out because the set it has to
5842 undo is exactly the set the daemon can ask it to mirror. A forgotten mode is
5843 a terminal left reporting clicks into the user's shell as escape sequences,
5844 long after mux exited.
5845
5846 #### `altScrollSeq` — measurement
5847
5848 - Measured on `less +G`: with DECCKM set (APPLICATION cursor keys, which every
5849 curses program sets), `ESC [ A` scrolled nothing at all — `less` reads
5850 `ESC O A` and ignores the normal spelling as an escape it does not know.
5851
5852 #### `sendAltScroll`
5853
5854 - Sent as input rather than predicted partly because `offerKeystroke` refuses
5855 anything that is not a single byte anyway.
5856 - The batch loop is what bounds the buffer, not the burst; `alt_scroll_batch`
5857 is twenty-one notches' worth, which no hand produces in one read.
5858
5859 #### `watchWinch`
5860
5861 - Public because the driver that owns the terminal is not always a Core: the
5862 wall puts its own terminal into raw mode and has no Core at all, while the
5863 zoomed tile still has to follow the tty. The wall arms the signal, the
5864 promoted pump answers it.
5865
5866 #### `dumpPredictStats`
5867
5868 - Public because a Core is not always the thing that says goodbye: a wall
5869 tile's Core lives on a DETACHED pump thread that process exit kills where it
5870 stands — no return, no deinit — so wallview's `run` prints the line after it
5871 has put the terminal back.
5872
5873 #### `Sink`
5874
5875 - Two function pointers rather than a driver interface because there is exactly
5876 one question — "may I write to `out_fd` now, and will that stay true until I
5877 say I am done" — and the wall already had the answer (`paint_mu` plus its
5878 zoom check) before this Core existed.
5879 - A plain client never asks: its terminal is its own for the whole run, so the
5880 default answers yes and holds nothing.
5881 - It gates paints and not side channels because a mode or a title is a write
5882 whose meaning does not depend on where the cursor is.
5883
5884 #### `initSized`
5885
5886 - The wall measures the terminal at startup and re-reads it only through the
5887 promoted tile that answers the SIGWINCH (`setWallSize`), so a tile clips to
5888 the size its stripe was cut from rather than to whatever a second ioctl says
5889 after the user dragged a corner.
5890
5891 #### `adoptSize`
5892
5893 - A tile's Core is born at the terminal's size and only `winch` changes it;
5894 `winch` reads a PROCESS-wide flag, so only the tile holding the terminal can
5895 answer one. A tile promoted after somebody resized would otherwise clip its
5896 paints to a screen that no longer exists. The resize frame that follows a
5897 promote tells the daemon; `adoptSize` is the local half.
5898
5899 #### `claimTerminal`
5900
5901 - Writes `session_claim` once per PROMOTE; the wall wrote the screen half
5902 (`wall_setup`) for its own lifetime. What a session needs is exactly the
5903 mouse modes: without them a host terminal answers the wheel by synthesising
5904 arrow keys (DEC 1007) that land in the session as input — which is what a
5905 zoomed tile did before the Core existed, and the whole reason a tile's input
5906 is routed through one.
5907 - A claim taken outside the sink can be preempted between reading the zoom
5908 store and writing its modes, land AFTER the release meant to precede it, and
5909 leave a terminal in modes nothing is arranged to undo — because the demote
5910 deliberately writes nothing.
5911 - An attaching client is told the modes moments later (`sendResync` ends with
5912 `term_modes`), but a promote's resize is answered by `resyncSnapshot`, which
5913 carries no modes deliberately. Without the level-set at promote, every wheel
5914 report would be eaten as this client's scrollback instead of reaching the
5915 application it belongs to.
5916 - The Core knows the modes without asking: `semantic` has tracked every mode
5917 sample since the tile was born, because only the WRITE was ever gated on the
5918 claim. Level-setting is idempotent, so a session with nothing to say pays a
5919 few bytes.
5920
5921 #### `dropScrollView`
5922
5923 - A resync repaints live state, so a history page left up would be silently
5924 replaced a moment later — and the scroll banner would sit over stale rows
5925 until the user happened to leave scroll mode.
5926 - The overlay's scroll-mode bit is recoverable rather than terminal: the "any
5927 other key" exit in `forward` cannot clear it (that branch is guarded by
5928 `scroll_rows > 0`, which `dropScrollView` has just made false), but
5929 Shift+PageDown's `scroll_rows == 0` arm clears it unconditionally. Only by a
5930 keystroke the user has no reason to guess, so prediction is silently off
5931 until they hit it.
5932
5933 #### `reconcileOverlay` / `paintOverlay` / `offerKeystroke`
5934
5935 - Kept private together: the wall's pump drives a `Core` rather than
5936 hand-rolling the client's input path, so nothing outside the file reaches the
5937 overlay. The overlay is where "prediction never enters the replica" is kept,
5938 so the fewer doors the better — a driver needing one of these back is a
5939 second implementation announcing itself.
5940
5941 #### `replicaCellChar`
5942
5943 - Reading the replica's own cursor instead of the predicted one hands reconcile
5944 a `prev_ch` belonging to somebody else's cell, which turns "the frame has not
5945 answered yet" into "we were contradicted" and flushes the queue. The
5946 real-Engine test in this file exists to catch exactly that.
5947
5948 #### `grid`
5949
5950 - The wall's DEMOTED tile is the only driver that paints its own view of a
5951 session: a stripe is a crop of the same grid, painted by the wall at the
5952 wall's rows, while the Core paints nothing because it holds no claim. One
5953 replica per tile, one applier for it (`replica.zig`), two ways of looking.
5954
5955 #### `dragReports`
5956
5957 - The button word arrives from the filter verbatim, motion bit and modifiers
5958 included, so it is the low two bits that name the button.
5959 - At the wall a click moves the selection between stripes; a zoomed tile is the
5960 only session on its screen, which is why a click is a no-op here.
5961 - The copy is `forward`'s to send because the transport is.
5962
5963 #### `replyBytes` (test fixture)
5964
5965 - The hand-written layout is id, status, watermark, then text — the shape
5966 `protocol.encodeSelectionReply` produces.
5967
5968 #### `releaseTerminal`
5969
5970 - "Nothing goes on the wire" is the whole of the CLAUDE.md invariant that an
5971 unzoomed tile claims nothing.
5972 - The demoted tile's highlight is dropped because an inversion kept across a
5973 demote would be invisible until the next zoom and then reappear over rows the
5974 user chose in another session's lifetime — and a reply still in flight would
5975 copy text for them. (Unchanged — it was already an inline comment on the
5976 `self.drag.clear()` statement, and stays there.)
5977
5978 ### Moved out of src/muxa.zig and src/main.zig (tier-3 burn-down)
5979
5980 Facts, measurements and rationale removed from doc blocks. They belong in
5981 `docs/decisions.md`, not in the source.
5982
5983 #### src/muxa.zig
5984
5985 ### `Conn.link`
5986 - The verbs above the union are written once and know nothing of the
5987 transport. That is the claim `--quic` makes, and the union is where it is
5988 kept.
5989
5990 ### `Conn.openQuic`
5991 - `connect` only creates state — the first flight has not been answered.
5992 - `client.zig`'s `quicTransport` waits for the handshake for the same reason.
5993
5994 ### `Conn.graceMs`
5995 - The unix arm keeps the flat 2s (`await_grace_ms`). The QUIC arm adds
5996 nothing until four round trips of its own handshake exceed that: never on
5997 a LAN or loopback, most of a second on a 200ms link.
5998 - Four round trips, not two, because the request and the reply are not the
5999 only flights in the trip — the daemon may be settling a command when the
6000 timeout fires.
6001 - The cap exists because `connect_ms` is bounded only by the handshake wait:
6002 a connection that took fifteen seconds to come up would otherwise buy a
6003 minute of grace. Past the cap we are no longer waiting for the daemon's
6004 answer but for a network that has already shown it cannot carry one.
6005
6006 ### `Conn.sendFrame`
6007 - `deadline_ms` is the caller's own bound — the same one it will wait for
6008 the answer under.
6009
6010 ### `Conn.sendFrameQuic`
6011 - QUIC `send` takes what fits and reports how much (a bounded ring; the
6012 caller holds the backlog), so a short take is not a failure and not
6013 ignorable either — the tail is offered again once acks have made room.
6014 - muxa's frames are a handful of bytes against a 256KB ring, so the loop is
6015 expected never to turn twice.
6016
6017 ### `Conn.awaitFrame`
6018 - The returned frame is allocated from the Conn's own allocator, so
6019 `frame.deinit` takes that one. Every caller was already passing it (one
6020 allocator in this process); asking for it made the pairing look like a
6021 choice.
6022
6023 ### `Conn.awaitFrameFd`
6024 - Debt deliberately retained on the socket arm: only the wait is
6025 deadline-bounded, not the read. Once poll says a frame has begun,
6026 `readFrame`'s `readExact` blocks until the whole payload lands, so a peer
6027 that stalls mid-frame outlives the deadline.
6028 - Harmless over a local socket: the daemon writes whole frames at once, and
6029 a stall means a daemon that has stopped running rather than a path that
6030 has stopped delivering. Buying it off would mean a second partial-frame
6031 buffer for a case that cannot happen there.
6032
6033 ### `Conn.awaitFrameQuic`
6034 - Nothing here blocks on the transport: a datagram carries whatever arrived,
6035 whole frames or a third of one, so frames are delimited out of the
6036 client's inbound buffer and a partial tail simply stays there until the
6037 rest lands. A daemon that stops mid-frame costs this loop the deadline it
6038 was given and not a second more.
6039 - A datagram routinely carries several frames and the reply may be the
6040 second, which is why every buffered frame is taken before the next poll.
6041
6042 ### `Conn.reconnect`
6043 - Nothing reads `graceMs` after the reconnect: the await it belongs to
6044 already has its deadline, and the re-issue continues that same deadline.
6045
6046 ### `frameFrom`
6047 - The arithmetic is `proto.delimitFrame`'s — the same walk the daemon does
6048 over the same wire from the other end. What `frameFrom` adds is the copy.
6049
6050 ### `emit`
6051 - A closed pipe (the agent's harness stopped reading) or a full filesystem
6052 is enough to produce exit 0 with nothing on stdout. Swallowing the write
6053 error turned both into a silent success — the one shape the contract says
6054 cannot happen, and the worst to hand an agent: its shell tool checks the
6055 status first, sees success, then has no object to parse.
6056
6057 ### `failAs`
6058 - Produces exactly the `"<verb>: <what>"` the two verbs printed when they
6059 were written out separately.
6060
6061 ### `deadlineFor`
6062 - The alternative reading of `--timeout 0` — a deadline already in the past
6063 — would make it fail instantly instead of waiting forever.
6064
6065 ### `failSessionEnded`
6066 - JSON like every other outcome, but on the failure path.
6067
6068 ### `writeCmdFields`
6069 - The five fields are published in the one order both verbs have always
6070 used. Each verb keeps its own envelope; what they stopped keeping is a
6071 second spelling of the fields inside it.
6072
6073 ### `attachZero`
6074 - `name` is joins-only by construction, not by a separate check: this binary
6075 never spawns a shell by asking about one.
6076
6077 ### `spanFetchDeadline`
6078 - Widening the fetch deadline to the larger of the two bounds handed a
6079 `--timeout 0` run an unbounded fetch — muxa hanging forever on a daemon
6080 that went quiet, long after the answer the agent asked for was in hand.
6081 - Narrowing to the smaller would cut off exactly the transcript
6082 `span_fetch_ms` exists to rescue: the one belonging to a command that
6083 returned in the last millisecond of the window.
6084 - Pinned by the test "the span fetch is bounded even when the run it follows
6085 was not".
6086
6087 ### `awaitReissuing`
6088 - A wait is the only round trip long enough for a network to die under: a
6089 status round trip is over in a millisecond, a `run` on a build is not, and
6090 losing it costs an agent the whole command it was watching. This is the
6091 one place a transport failure is retried rather than reported.
6092 - What makes the retry safe rather than a second command is `since_seq`: the
6093 request is a question about a watermark ("tell me about a return newer
6094 than this"), so re-asking it after a reconnect is the SAME question and
6095 the daemon answers it identically whether or not it saw the first one.
6096 The server's own tests pin that idempotency.
6097 - At-most-once, not at-least-once: what is re-sent is the attach and the
6098 `await_req` and nothing else — never `run`'s input. If the command line
6099 was lost with the connection, the re-issued await finds no return and the
6100 agent is told `timeout`, which is true and checkable, instead of the shell
6101 running `make deploy` a second time because a client decided to be
6102 helpful. A wait may be repeated because asking twice changes nothing; an
6103 input may not, because it changes everything.
6104 - Three things are deliberately not reset: the deadline (a redial that ate
6105 four seconds has spent four seconds of the wait, not bought a fresh one);
6106 `since_seq`; and the attach, which IS re-sent, at 0x0 like every other
6107 attach this binary makes.
6108 - `ConnectionLost` is the only error that reconnects. `SendStalled` does not:
6109 a stall means the peer is still there but has stopped acknowledging a
6110 quarter-megabyte of backlog — it has already spent the flush bound proving
6111 that, and at muxa's frame sizes it is very nearly unreachable.
6112 - A redial that fails does not swallow the reason: it is recorded on the
6113 `Conn` and `ConnectionLost` is re-raised, so the verb reports what went
6114 wrong FIRST (the path tore) and second (the redial), not only the second.
6115
6116 ### `fetchSpan`
6117 - `end_row` is the row the D mark landed on (the prompt redraw), so the span
6118 is [start_row, end_row) and an end at or before the start is no output.
6119 - Rows are absolute screen rows and best-effort by construction (see
6120 `MarkEvent.row`): the alt screen and scrollback pruning can invalidate
6121 them between the reply and the fetch. The exit code is the answer; the
6122 transcript is the bonus.
6123
6124 ### `printAwaitReply`
6125 - One JSON object: what ended the wait, the session's command state when it
6126 ended, and how long we waited.
6127
6128 ### `reportAwait`
6129 - `returned` and `settled` are both answers, including a command that
6130 returned nonzero.
6131
6132 ### `awaitVerb`
6133 - `await` and `run` are one pipeline: attach claiming no grid, read the
6134 watermark, wait for the session to come to rest, report. `run` is that
6135 pipeline with a command line put in — sent between the watermark and the
6136 wait, with the marks span fetched at the end — so the two are written once
6137 rather than twice with the middle diverging.
6138
6139 #### src/main.zig
6140
6141 ### `pickKey`
6142 - An order that quietly inverted would otherwise only show up as a daemon
6143 authenticating with the wrong key.
6144
6145 ### `envKey`
6146 - `Key.load` would blame a confusing `""`.
6147
6148 ### `specForName`
6149 - Exact match on the whole word, first row wins.
6150
6151 ### `specForCmd`
6152 - The first-wins scan is unambiguous only because the same comptime block
6153 refuses a second row for one `Cmd`.
6154
6155 ### `shellIntegrationEnabled`
6156 - Shell integration was an OPT-OUT through the agent surface and the
6157 multi-session daemon, on the reasoning that marks are what make an exit
6158 code knowable. The daily-driver reading is the opposite: the shim costs a
6159 zsh user their `~/.zshenv` and displaces a bash user's DEBUG trap (atuin,
6160 bash-preexec) on every session, while only `muxa` reads what it buys.
6161 - Inverting the sense rather than adding a second spelling means a stale
6162 `MUX_SHELL_INTEGRATION=0` still reads as off.
6163
6164 ### `oneShotQuery`
6165 - `dump` and `stats` are the same round trip and differed only in the verb
6166 they name, the frame they send and the frame they wait for.
6167 - Frames of other types are skipped rather than refused: the reply is the
6168 answer to THIS request, and a daemon is free to have said something else
6169 on the way to it.
6170 - `askEndpointPort` may be asked by a binary from before `endpoint_req`
6171 existed, which is why it waits under a deadline; `oneShotQuery` has no
6172 such case (a daemon that understands the socket understands both verbs).
6173
6174 ### `logHint`
6175 - The clause was written twice, with a comment saying so, before it became
6176 this function. Its two users are `stopCmd` and `reportNoListener`, both
6177 about a daemon not doing what was asked while the reader is elsewhere.
6178 - The finding that must not be replaced by an error trace: "the daemon did
6179 not stop", "the daemon has no listener".
6180
6181 ### `reportNoListener`
6182 - This is the likeliest way the announce goes negative in production, and it
6183 must not be silent.
6184 - The log clause is `logHint`'s, conditions and hedge included — the same
6185 clause `stopCmd` ends with, for the same reasons.
6186
6187 ### `reportKeyRefusal`
6188 - "no usable key" alone would cost a reader on another box the trip to find
6189 out which of the three refusals it was.
6190 - The words are `quic.keyRefusalBody`'s and this half owns only the prefix
6191 and the `; staying on ssh` that says what the refusal cost. Word for word
6192 what `run` prints for the same refusals, path in the same position.
6193 - Every error is handed over, catch-all included: `announceKeyFrom` only
6194 reaches `load_failed` with what the load returned, so an unclassified
6195 error is still a key that would not read — which is what the body's fourth
6196 sentence says. `run`'s arm routes only the three for the opposite reason:
6197 it can afford to let the rest propagate.
6198
6199 ### `parse`
6200 - `parseArgs` takes what `argsAlloc` produces, so the tests must speak the
6201 same type.
6202
6203 ### Moved out of src/protocol.zig and src/engine.zig (tier-3 burn-down)
6204
6205 #### protocol.zig
6206
6207 - **`delimitFrame`** — Kept pure and taking a plain slice rather than a
6208 connection so both ends of the wire delimit with the same arithmetic and the
6209 function can be exercised against a canned buffer.
6210 - **`delimitFrame`** — The type byte is read through a non-exhaustive enum on
6211 purpose: an unknown message type is the peer's business to have sent and the
6212 caller's to ignore, not a reason to refuse the stream.
6213 - **`appendFrame`** — Exists because daemons buffer frames per client and flush
6214 opportunistically instead of blocking on a slow peer. A golden test pins that
6215 its bytes are identical to `writeFrame`'s.
6216 - **`agentDataOversize`** — The channel, not the frame, is the unit of refusal:
6217 dropping the oversize frame instead would leave the agent stream short of
6218 bytes its far end is still waiting on. Both ends hand the payload straight to
6219 a blocking `writeAllFd`, so a frame claiming more than one bite is refused
6220 rather than pumped.
6221 - **`SelectionPoint`** — The protocol preserves both endpoints exactly and
6222 leaves ordering/normalization to the component that owns the terminal grid.
6223 - **`encodeSelectionReply`** — Allocation errors retain normal ArrayList
6224 semantics (only `error.BadPayload` is guaranteed to leave `out` untouched).
6225 - **`encodeAwaitReq`** — History: this decl used to carry a comment admitting
6226 that a caller who set `.name` and called this instead of `encodeAwaitReqNamed`
6227 would find the name silently dropped — a known way to send the wrong bytes,
6228 written down and left live. Dropping `AwaitReq.name`'s default was considered
6229 and does not fix it: it forces every literal to say `.name = ""` and still
6230 lets `.name = "b"` reach the function. The assert replaced the comment because
6231 it fires at the call site, in the build modes the tests and the daemon run
6232 under.
6233 - **`StatusReply`** — The fields chosen are what a driving agent needs before
6234 deciding how to interact: size, cursor, whether a TUI holds the screen, who
6235 echoes keystrokes, and the command state.
6236 - **`PtyModeFlags`** — Read off the session's pty by the daemon and shipped
6237 verbatim: the client is told what the terminal IS, never what to do about it.
6238 The six reserved bits are pinned to zero by a test rather than left to
6239 whatever the encoder happened to have on the stack.
6240 - **`TermModes`** (unflagged, context for the above) — its reserved bits work
6241 the opposite way deliberately: an unknown bit there is ignored, because those
6242 modes are independent host settings rather than one prediction verdict.
6243 - **`TermEvent.Clipboard`** — The payload stays base64 the whole way: ghostty
6244 hands the OSC 52 payload over undecoded, and every transform is a chance to
6245 corrupt bytes neither end ever needs to read. The daemon caps length on the
6246 way in and the client re-validates before it lands in an `ESC]52;…BEL`
6247 written to a real tty; this codec only carries the bytes between them.
6248 - **`encodeBellEvent`** — A fixed-array return would just move the append into
6249 the caller's `switch (ev.kind)` arm, which is why both event encoders share
6250 the append shape.
6251 - **`resolveName`** — Spelled in one function because "empty means default" was
6252 otherwise a convention five daemon call sites happened to remember in the
6253 same way, and five copies is five chances for one to be updated alone.
6254 - **`encodeDebugDumpNamed`** — Added when `muxd dump` and `muxa capture` were
6255 found to hold byte-identical hand-assembled copies of the payload; wire
6256 layout belongs to the wire module. An empty name writes exactly the one-byte
6257 payload from before named sessions.
6258 - **`SnapshotPrefix`** — `epoch` identifies the daemon instance that produced
6259 `seq`; a client echoes it back on reattach so the daemon can tell "you are
6260 current" from "you are current in a session that no longer exists". (The
6261 borrowing rule now lives at `AttachReq`.)
6262
6263 #### engine.zig
6264
6265 - **`MuxHandler.onClipboard`** — Answering the OSC 52 `?` QUERY form would let
6266 any program in any session — including one on a box reached over QUIC,
6267 including one an agent is driving — read whatever the human last copied.
6268 xterm ships the query disabled; Alacritty defaults to OnlyCopy.
6269 - **`Engine.writeSelection`** — A null selection must never fall back to
6270 formatting the whole PageList, which would drag scrollback into the dump.
6271 - **`Engine.snapWide`** — The bug that found this: dragging across 漢字. Each
6272 of `dumpVtRowSpan`'s three pieces resolves its own edge independently, so a
6273 character straddling a cut was emitted by BOTH pieces, the row landed one
6274 column wider than the grid, and every glyph right of the pointer shifted as
6275 the highlight moved. Mechanism in ghostty's formatter: it reaches back a
6276 column when a selection STARTS on a spacer tail, and a selection ENDING on a
6277 wide cell still emits both of that cell's columns.
6278 - **`Engine.cursorKeys`** — `less` and every curses program set DECCKM, which
6279 is why alternate scroll cannot get away with the normal arrow spelling.
6280 - **`Engine.title`** — Nothing carried the title before it was added, which is
6281 why a host terminal's title used to stay wrong under mux. Empty and "never
6282 set" are the same answer; `sampleTermTitle` in server.zig has why mux
6283 declines to forward either.
6284 - **`Engine.reset`** — Discarding queued side_events is data loss, not terminal
6285 state: an undrained OSC 52 copy is thrown away rather than replayed after the
6286 reconstructed state lands. Correct for today's callers (replica.zig,
6287 wasm_core.zig), which reset without ever draining; a session-restart or
6288 resync path that drains must drain first.
6289 - **`Engine.onDeviceAttributes`** — The defaults are VT220 conformance + ANSI
6290 colour (`CSI ? 62;22 c`), the same modest identity xterm ships; nothing there
6291 claims sixel or windowing the replica cannot honor. ghostty-vt's stock
6292 handler answers DA1 only when the embedder supplies one, and the unanswered
6293 query cost a flat second off every nvim start and quit before this existed.
6294
6295 ### QUIC-layer comment burn-down — facts moved out of the source
6296
6297 Extracted from `src/quic_server.zig`, `src/quic_client.zig`, `src/quic.zig`,
6298 `src/proxy.zig`. These belong in `docs/decisions.md`, not beside the decl.
6299
6300 #### `proxy.ignoreSigpipe`
6301
6302 - Defence in depth, not a fix. Zig's `std/start.zig` already installs a noop
6303 SIGPIPE handler, so `proxy.pump`'s write-error returns are reachable without
6304 this call. What the explicit `SIG_IGN` pins is that the reachability belongs
6305 to mux's own code rather than to a std default (`std.options.keep_sigpipe`)
6306 that another module could flip out from under it.
6307 - It is exported so that every process which writes to a pipe it does not own
6308 installs the *identical* ignore rather than its own copy. No protocol
6309 knowledge crosses that boundary, which is the only thing `proxy.zig`'s import
6310 list forbids.
6311 - Consequences of the survives-exec rule, previously enumerated at the decl:
6312 `wallview.zig` installs its ignore only after opening the transport;
6313 `muxd endpoint` calls it after its auto-start; order is irrelevant in the
6314 proxy itself because the proxy spawns nothing.
6315
6316 #### `proxy.shrinkBufs`
6317
6318 - The tests depend only on the buffers being *small*, not on any particular
6319 size — which is why a floor request that Linux doubles and clamps is good
6320 enough.
6321
6322 #### `quic.keyRefusalBody`
6323
6324 - The one-owner refactor: four literal copies of the refusal text lived in four
6325 binaries, held in sync only by prose comments, and their catch-all arms had
6326 already drifted apart before the function was extracted.
6327 - Classification is by error *value*, and identical at every caller, because a
6328 key is rejected for the same reasons whichever binary read it.
6329 - Truncating rather than failing follows `failedMsg`'s policy in `client.zig`,
6330 for its reason: the line is the user's only account of the refusal, so a
6331 clipped one beats none.
6332
6333 #### `quic.keepAliveNs`
6334
6335 - A third of the idle timeout, so two keepalives can go unanswered before the
6336 connection is called dead. (Asserted by the test
6337 "keepAlive: a third of the idle timeout, and never disabled".)
6338 - ngtcp2 reads UINT64_MAX as "disabled" too, so a small `idle_ms` rounding down
6339 to zero would silently restore exactly the behaviour the keepalive exists to
6340 prevent.
6341
6342 #### `quic.WriteAction` / `quic.accountWrite`
6343
6344 - The ordering is why this is a function rather than three copies of two ifs.
6345 ngtcp2 can commit `ndatalen` — advancing the stream offset it will retransmit
6346 from — and *still* return an error afterwards (NOMEM out of rtb_add, say).
6347
6348 #### `quic_server.Listener.send` — the re-entrancy abort
6349
6350 The full reproduction, moved out wholesale:
6351
6352 - QUEUE ONLY is the fix for a real defect, not a stylistic preference.
6353 - ngtcp2 is not re-entrant, and draining inside `send` re-entered it. The chain
6354 was synchronous and entirely ordinary: `read_pkt` -> `recv_stream_data`
6355 callback -> the daemon's frame handling -> a reply queued -> `send` ->
6356 `drain` -> `writev_stream` on the SAME connection while `read_pkt` was still
6357 on the stack below.
6358 - Two failures follow. (1) Monotonic timestamps move backwards within one
6359 `read_pkt`, quietly corrupting loss detection. (2) When a datagram carries a
6360 STREAM frame ahead of an ACK, the nested write mutates the retransmission
6361 buffer that the outer ack walk is about to traverse.
6362 - `ngtcp2_unreachable()` aborts unconditionally even under NDEBUG, so the
6363 symptom is a bare SIGABRT with no panic banner and no defers run. It depends
6364 on traffic shape, which is why it presented as a test failing once in
6365 hundreds.
6366 - Draining now happens only where the stack is ours: after `read_pkt` returns,
6367 in `tick`, and in the daemon's explicit `drainAll`.
6368 - Earlier defect on the same decl: while `send` accepted everything
6369 unconditionally, a peer that stopped reading grew an unbounded buffer inside
6370 the listener, where nothing watches it, instead of tripping the daemon's
6371 `pending_cap` where something does. The short-return contract now matches the
6372 socket sink's, so one rule bounds both kinds of client.
6373
6374 #### `quic_server.Listener.closeConn`
6375
6376 - Not sending CONNECTION_CLOSE is a real, accepted cost: a client learns of a
6377 deliberate close no faster than it learns of a crash, because it waits for
6378 its own idle timer. A graceful close belongs with the client transport, which
6379 is the side that would act on it.
6380 - Not invoking `onClose` is deliberate: a close the owner asked for needs no
6381 callback telling the owner what it just did. Only `kill` — the listener
6382 deciding a connection is finished — calls back.
6383
6384 #### `quic_server.Listener.bind`
6385
6386 - A listener returned from `bind` drops everything that arrives until
6387 `setHandler` is called; nothing polls it before then.
6388
6389 #### `quic_client.Client.sendRecvFailed`
6390
6391 - ECONNREFUSED on a *connected* UDP socket is an ICMP unreachable — a dead
6392 transport, not a blip. `recv` sees it after a failed flight; `send` sees it
6393 when the queued error is delivered on the NEXT syscall, which on a quiet
6394 connection is `drain`'s send. Both must agree, because the error goes to
6395 whichever syscall runs first after it is queued and is *cleared* by it: if
6396 that path does not act, nothing else ever sees it.
6397 - The parameter is the union of the two call sites' error sets rather than
6398 `anyerror`, so a misspelled prong is a compile error instead of an arm that
6399 silently never matches.
6400
6401 #### `quic_client.Client.send`
6402
6403 - The short-return contract is the same one the daemon's socket sink obeys, for
6404 the same reason: the ring is bounded, so somebody has to hold the backlog and
6405 it should be somebody who can see how big it is.
6406
6407 #### `quic_server.zig` / `quic_client.zig` module headers
6408
6409 - The transport thesis, restated in three headers and now pointed at instead:
6410 if an opaque byte pipe suffices to carry the protocol, transport is a swap,
6411 not a redesign. Recorded as an invariant in `CLAUDE.md`.
6412 - `quic_client.zig` imports the key, the wire constants, the `Egress` ring, the
6413 write accounting and the clock from `quic.zig` so there is only one copy of
6414 each to drift.
6415
6416 ### Extracted from the web layer (webhub, wasm_core, replica, webhub_main)
6417
6418 Facts moved out of source comments during the tier-3 burn-down. Each bullet
6419 names the symbol it came from.
6420
6421 #### webhub.zig
6422
6423 - **`originAllowed`** — the Origin check is a spec requirement, not a
6424 nicety: exactly our own two spellings (`http://127.0.0.1:PORT`,
6425 `http://localhost:PORT`) pass, and a request with no Origin header is
6426 refused.
6427 - **`Hub.checkoutTarget`** — a browser can always dial a tile another
6428 device just removed, so `UnknownId` is a normal outcome, not a bug. It
6429 stays distinct from `OutOfMemory` so a memory failure is never folded
6430 into "no such tile" (404 vs 500).
6431 - **`Hub.checkoutTarget`** — fd tracking is deliberately one fd per tile,
6432 best-effort. Two browsers on one wall run two pumps per tile; FIRST
6433 registration wins, the second pump serves its browser untracked and ends
6434 on its own WS read. A full fd list per tile is deferred until
6435 two-browser removal latency is shown to matter.
6436 - **`drainBrowser`** — both loops share the drain because the five
6437 decisions are the same five in the same order (fill, incomplete,
6438 too_big, pong, ready); the only difference is whether a data frame has a
6439 transport to go to. The cost of draining unconditionally is one
6440 `headFrame` call over an empty buffer per idle pass.
6441 - **`pumpTile`** — a slow browser stalls only its own tile. The daemon side
6442 is protected by its own 8 MiB pending cap; the hub's upstream reads just
6443 stall. Blocking per-tile reads (fatal to a multiplexing hub) are correct
6444 here only because the Transport is private to the thread.
6445 - **`pumpTile`** — the reconnection sequence: on transport death narrate
6446 `reconnecting`, re-dial on the CLI's own backoff schedule
6447 (`client.nextBackoffMs`, no retry cap, deliberately), then narrate `up`.
6448 The browser's replica quotes have_seq/have_epoch in a fresh attach and
6449 the snapshot-vs-delta resolution does the rest.
6450 - **`pumpTile`** — the dead-leg bound is not a bound: an ssh-fallback tile
6451 whose peer goes quiet tears on the transport's own terms, not on the
6452 nominal 90 seconds (3 × 30 s ping interval).
6453 - **`dialLoop`** — the backoff doubles as the WS liveness window; it caps
6454 at 2 s against a 30 s ping interval, so the liveness tick is never more
6455 than one backoff late.
6456 - **`appendJsonString`** — deliberately NOT `muxa`'s `jsonEscape`, though
6457 the two look alike. This one sends every control byte to `\u00XX` (one
6458 rule, no table to get wrong); `muxa` spells the short forms `\n`, `\r`,
6459 `\t`. Both are valid JSON and parse identically, but the bytes differ
6460 and each is pinned by its own test, so sharing one implementation would
6461 rewrite one side's output for no gain.
6462 - **`Hub.json`** — the `/tiles` response shape: one object per tile in
6463 index order, `{"label":…,"session":…}`.
6464
6465 #### wasm_core.zig
6466
6467 - **`panic`** — the intended later use of this hook is to surface panic
6468 text to JS through a host-imported log before the trap.
6469 - **`std_options`** — the no-op `logFn` is load-bearing because ghostty-vt
6470 logs warnings on some unsupported sequences; the failure was found as
6471 obstacle 4 of the browser-client feasibility spike.
6472 - **`mux_viewport_ptr`** — the flat viewport ABI (the per-cell layout is
6473 now stated beside the packing code in `paintRow`): `[0]` codepoint
6474 (0 = empty), `[1]` fg `kind << 24 | value` (kind 0 none, 1 palette,
6475 2 rgb; value = palette index or 0xRRGGBB), `[2]` bg same packing,
6476 `[3]` flags.
6477 - **`mux_paste_begin`** — history: the browser shell used to wrap EACH
6478 chunk of a large paste, which put a paste-END in the middle of the
6479 pasted text. vim left paste mode 32 KiB in and re-indented the rest.
6480 The fix made the code match `keymap.pasteInto`'s existing contract,
6481 "the wrap and nothing else" around the WHOLE paste.
6482 - **`mux_text_encode`** — an IME's `compositionend` hands over finished
6483 text, and finished text is TYPING. Bracketing it would tell the
6484 application a human did not write it: vim would skip paste mode's
6485 indentation and a shell's bracketed-paste guard would refuse to run it.
6486
6487 #### replica.zig
6488
6489 - **`Replica.scrollStart`** — the returned row saturates at the oldest
6490 retained row; the view begins `rows_up` above the live viewport top,
6491 which is row index `history_rows`.
6492
6493 #### webhub_main.zig
6494
6495 - **`addSpelling`** — the usage message names the tile because with
6496 several targets on the command line, a bare `usage` would not say which
6497 spelling was rejected.
6498
6499 ### Render path — facts moved out of source comments
6500
6501 Extracted while burning down tier-3 flags in `src/predict.zig`, `src/paint.zig`,
6502 `src/select.zig`, `src/delta.zig`, `src/keymap.zig`. Each bullet names the symbol
6503 the fact belonged to.
6504
6505 #### predict.zig (module header)
6506
6507 - **Past bug — "not yet" read as "wrong".** An earlier version of `reconcile`
6508 treated an unchanged predicted cell as a contradiction. Consequence: a typing
6509 burst refuted itself once per round trip. Typing `hello`, `h` confirms while
6510 `e,l,l,o` are judged by a frame that was built before they were typed; all four
6511 read as contradictions and the queue flushes. Prediction that erases itself
6512 every RTT is the opposite of the feature. (The surviving comment states the
6513 evidence rule; the worked example is the history.)
6514 - **Comparability claim.** The overlay's separation from the replica is what
6515 keeps `muxd dump` and the client grid comparable byte for byte at every moment.
6516 - **Consequence of the TERMIOS tiers (`.adaptive` vs `.always`).** The mode bits
6517 move once or twice per command at a normal prompt as readline hands the
6518 terminal back and forth to run each command. Since every move re-earns display
6519 from scratch, the first couple of keystrokes after each prompt are invisible
6520 predictions. This is a deliberate conservative trade; per-context confidence
6521 memory is the banked polish that would remove it.
6522 - **Memory shape.** The queue is read back by index rather than handed out as a
6523 slice; a slice would go stale on the next append — the shape decisions.md's
6524 egress records call the UAF-that-never-crashes. (Kept in source in one line;
6525 the cross-reference to the egress records is the part dropped.)
6526 - **Bounded pending** (`expire_after_frames` / `expire_after_ms`) was stated in
6527 the header as well as at both constants' own decls. The header copy is gone;
6528 the decls carry the phantom-glyph rationale in full.
6529
6530 #### predict.zig (decls)
6531
6532 - `noteSeq` — `reconcile` calls it itself; the client calls it directly only on
6533 paths that apply a frame without judging anything.
6534 - `recordSuppressed` — the counter counts DECISIONS, not which side of the
6535 interface made them. Before this entry point existed the counter silently
6536 disagreed with the behaviour: nothing was predicted and nothing said so. The
6537 forcing case is a plain-ASCII paste, whose printable lead byte would otherwise
6538 have been predicted as the paste's first character.
6539 - `markPainted` — `displayed` is defined as "predictions that ever reached the
6540 screen".
6541 - `flush` — counting a snapshot/resize/scroll/reconnect as a contradiction would
6542 fire the demotion machinery on a window resize, costing the next
6543 `promote_after` keystrokes their visibility.
6544
6545 #### paint.zig
6546
6547 - `Highlight` — `cols` is handed DOWN rather than remembered by the caller: the
6548 width the highlight is clamped to must be the width this paint is about to
6549 use. Enforced by an assert in `Engine.dumpVtRowSpan`, so the comment was
6550 re-arguing something the code already checks.
6551 - `renderStripe` — the bottom-crop first version of the stripe window showed 14
6552 empty rows of every fresh session. That measurement is why the window is
6553 cursor-anchored.
6554 - `renderStripe` — full-width rows only because an interior column would need
6555 VT-safe truncation this module does not do. (Kept as the "so the wall stacks
6556 stripes" clause; the truncation detail is here.)
6557 - `renderStripe` doc block had drifted onto `stripeWinStart` (no blank line
6558 between the block and an inserted decl), so the stripe prose documented the
6559 wrong function. Moved back onto `renderStripe`; the per-statement parts
6560 (cursor anchoring, no `\x1b[2J`, the unpaired sync close) are now inline
6561 beside the statements they explain.
6562 - `stripeWinStart` — a second copy of this arithmetic that drifted would land
6563 clicks on a different line than the one the user pointed at, and nothing on
6564 screen would say so.
6565
6566 #### select.zig
6567
6568 - `Range.span` — an out-of-range column is what makes the daemon answer
6569 `.invalid` at the other end. (Retained in source.)
6570 - `Drag.motion` — a wall is panes; a selection dragged out and back is a
6571 selection of one cell, not a click.
6572 - `Drag.clear` — the callers, and why each invalidates coordinates: a relayout
6573 or a forget re-cuts the stripes under the anchor; a zoom transition hands the
6574 screen to somebody else; a resync renames the absolute row space outright. A
6575 highlight kept across one of those is a highlight over rows nobody selected.
6576 (The 46-byte budget could not hold this list.)
6577 - `Drag.on` — a caller dropping a tile's coordinates has to drop a drag anchored
6578 on that tile too, even one that has not moved yet.
6579 - module header — a column layout, added later, would change the driver's
6580 hit-test and nothing in `select.zig`.
6581
6582 #### delta.zig
6583
6584 - `noteBlind` — **measurement:** rendering every row in order to hash it is
6585 ~60% of the daemon's cycles on a full-width repaint. With no client, none of
6586 those bytes has anywhere to go. This number is the whole justification for the
6587 no-render path.
6588 - `noteBlind` — the reason the signature takes no allocator is defensive: a
6589 signature that can allocate is a signature that can render, and the next
6590 reader will put the loop back.
6591 - `noteBlind` — `seq` is the session's watermark, stamped into
6592 `last_return.seq`; two commands returning during one blind stretch sharing a
6593 seq means an await cannot tell which of them it was told about.
6594 - `noteBlind` — geometry and screen changes still have to become a
6595 discontinuity: `noteBlind` cannot describe them, and `resyncSnapshot`'s
6596 rebuild is owed to a detached session too. (The three `return .discontinuity`
6597 guards at the top of the body are the code that does this.)
6598 - `canServe` — the tracker does not know which daemon instance it belongs to,
6599 which is why the epoch check is the caller's. A tracker never built, or whose
6600 rebuild failed partway, can describe nothing.
6601
6602 #### keymap.zig
6603
6604 - `pasteInto` — filtering a pasted ESC was explicitly deferred out of v1; the
6605 brackets wrap the paste verbatim.
6606
6607 ### Fragments moved out of the small modules
6608
6609 History, incidents and measurements lifted from doc blocks during the tier-3
6610 burn-down. Each bullet names the symbol it belonged to.
6611
6612 #### `sockpath.defaultSockPath` — why there is no `/tmp` fallback
6613
6614 A guessed `/tmp/muxd-<uid>.sock` used to stand in when `$XDG_RUNTIME_DIR` was
6615 unset. It broke the property the default exists for — two binaries started with
6616 no `--sock` landing on the SAME daemon. A tmux server started before logind
6617 exported the variable hands every pane an environment without it, so panes went
6618 to `/tmp` while the daemon a pane had started owned the runtime directory: one
6619 uid, one box, two daemons, and `muxd stop` reporting nothing there. A default
6620 that cannot make two binaries agree is not a default, so the guess was removed
6621 and the caller now names the path.
6622
6623 #### `sockpath.PathId` — the guard that was unconditionally false
6624
6625 `PathId` records the PATH's dev+ino, not the listening descriptor's. A bound
6626 unix socket's descriptor lives in sockfs (dev 10 on this box) while the path
6627 resolves to an ordinary filesystem inode (dev 38), so comparing the two could
6628 never be equal. The teardown guard read as careful and was always false, which
6629 meant the daemon never unlinked its socket on a clean exit at all — masked ever
6630 since by the stale-socket recovery in `claim` cleaning up on the next start.
6631
6632 #### `mux_main.wallEdit` — why validation is a separate pass
6633
6634 Per-spelling saves made the "validated before anything is written" promise a
6635 half-truth: a bad line was caught before anything moved, but an IO error on the
6636 third of four left the first two applied and the rest not — exactly the partial
6637 state the validation pass exists to prevent. The edit is now built in memory and
6638 saved in one atomic rename.
6639
6640 #### `wall.loadLines` — the incident that created it
6641
6642 When EVERY path went through `wall.load`, one hand-edited line made the wall
6643 file unrepairable by the tool that owns it, including the command whose entire
6644 job is removing a line. `loadLines` was added so removal can delete the broken
6645 line and write every line it did not touch back byte for byte.
6646
6647 #### `shellint.install` — the two collisions the random suffix closes
6648
6649 - Symlink aim: `parent_dir` is the socket's directory, a shared `/tmp` when
6650 `$XDG_RUNTIME_DIR` is unset, and a pid is guessable. Another user could
6651 pre-create the exact `mux-shellint-<pid>` name as a symlink to a directory of
6652 ours, and the shim files the session shell then SOURCES would land through it.
6653 - Mundane: a predecessor SIGKILLed before teardown left `mux-shellint-<its pid>`
6654 behind, and a later daemon drawing that pid used to lose its marks to the
6655 leftover.
6656
6657 #### `shellint.zsh_zshrc` — the known cost of the ZDOTDIR shim
6658
6659 (Left in source; recorded here because it is a roadmap item, not a rule.)
6660 Pointing ZDOTDIR at the shim silently costs the user their `~/.zshenv`: zsh looks
6661 for `.zshenv` under `$ZDOTDIR` and the shim directory has none, so a config kept
6662 there stops being read for the session. A `.zshenv` shim that restores ZDOTDIR
6663 the way ghostty's does is the fix.
6664
6665 #### `client_core.validTarget` — the case that cannot reach it
6666
6667 Multi-character OSC 52 targets cannot reach `validTarget` at all: ghostty rejects
6668 the whole OSC when `data[1] != ';'`, so `ESC]52;pc;...` yields no event to
6669 forward. There is nothing to widen the list for. (Single stray bytes DO reach it
6670 — ghostty sets `kind = data[0]` with no validation of its own, in
6671 `osc/parsers/clipboard_operation.zig`, so `ESC]52;X;...` arrives as target 0x58.)
6672
6673 #### `mux_main.insideThisSession` — why there is no escape chord
6674
6675 An inner client repaints its own grid (paint -> delta -> repaint) and takes the
6676 alt screen and every keystroke with it. Once Ctrl-\ became a prefix, the OUTER
6677 keyboard could no longer steer it back out, so there is no chord to offer as an
6678 alternative to refusing the attach.
6679
6680 #### `spawn.ensureForAttach` — how it differs from `muxd start`
6681
6682 `muxd start` deliberately does not go through `ensureForAttach`: it forwards the
6683 user's own flags rather than a fixed `run --sock <path>`, it truncates the log
6684 rather than appending, and it REPORTS already-running where `ensureForAttach`
6685 stays silent. Someone who typed `muxd start` asked about a daemon and is owed a
6686 verdict on one; someone who typed `mux` asked for a session and is about to get
6687 it.
6688
docscheck.budget
Old New
@@ -1,34 +1,34 @@
1 client_core_wasm_check.zig 0 1 client_core_wasm_check.zig 0
2 client_core.zig 756 2 client_core.zig 0
3 client.zig 0 3 client.zig 0
4 cmd.zig 117 4 cmd.zig 0
5 delta.zig 1659 5 delta.zig 0
6 docscheck.zig 209 6 docscheck.zig 0
7 engine.zig 6185 7 engine.zig 0
8 handoff.zig 700 8 handoff.zig 0
9 interact.zig 21074 9 interact.zig 0
10 keymap.zig 302 10 keymap.zig 0
11 main.zig 4422 11 main.zig 0
12 muxa.zig 13402 12 muxa.zig 0
13 mux_main.zig 3597 13 mux_main.zig 0
14 paint.zig 3631 14 paint.zig 0
15 predict.zig 5894 15 predict.zig 0
16 protocol.zig 7237 16 protocol.zig 0
17 proxy.zig 1300 17 proxy.zig 0
18 pty.zig 497 18 pty.zig 0
19 quic_client.zig 2917 19 quic_client.zig 0
20 quic_server.zig 5847 20 quic_server.zig 0
21 quic.zig 1901 21 quic.zig 0
22 replica.zig 620 22 replica.zig 0
23 select.zig 3029 23 select.zig 0
24 server.zig 35479 24 server.zig 0
25 shellint.zig 2945 25 shellint.zig 0
26 sockpath.zig 1119 26 sockpath.zig 0
27 spawn.zig 2471 27 spawn.zig 0
28 testtmp.zig 0 28 testtmp.zig 0
29 wallview.zig 0 29 wallview.zig 0
30 wall.zig 3731 30 wall.zig 0
31 wasm_core.zig 2841 31 wasm_core.zig 0
32 webhub_main.zig 496 32 webhub_main.zig 0
33 webhub.zig 5751 33 webhub.zig 0
34 xdg.zig 2247 34 xdg.zig 0
src/client_core.zig
Old New
@@ -1,9 +1,7 @@
1 const std = @import("std"); 1 const std = @import("std");
2 const proto = @import("protocol"); 2 const proto = @import("protocol");
3 3
4 /// A clipboard event whose bytes borrow the frame payload. The slice is only 4 /// The bytes BORROW the frame payload; copy before reusing it.
5 /// valid until that payload is reused or discarded; callers that need to keep
6 /// it must copy it before advancing their receive buffer.
7 pub const ClipboardSet = struct { 5 pub const ClipboardSet = struct {
8 target: u8, 6 target: u8,
9 base64: []const u8, 7 base64: []const u8,
@@ -98,17 +96,8 @@ pub fn validClipboard(target: u8, base64: []const u8) bool {
98 return true; 96 return true;
99 } 97 }
100 98
101 /// Pc as xterm's ctlseqs defines it, and this list DOES narrow: ghostty 99 /// ghostty sets `kind = data[0]` unvalidated, so any byte a program in
102 /// sets `kind = data[0]` with no validation of its own 100 /// the session writes arrives here: this list is the only guard.
103 /// (osc/parsers/clipboard_operation.zig), so any byte a program inside the
104 /// session writes arrives here — `ESC]52;X;…` lands as target 0x58.
105 /// This list is the only thing standing between that byte and a consumer,
106 /// so it is NOT dead code.
107 ///
108 /// Multi-character targets are a separate matter and cannot reach here at
109 /// all: ghostty rejects the whole OSC when `data[1] != ';'`, so
110 /// `ESC]52;pc;…` yields no event to forward. Nothing to widen this
111 /// list for.
112 fn validTarget(target: u8) bool { 101 fn validTarget(target: u8) bool {
113 return target == 'c' or target == 'p' or target == 'q' or target == 's' or 102 return target == 'c' or target == 'p' or target == 'q' or target == 's' or
114 (target >= '0' and target <= '7'); 103 (target >= '0' and target <= '7');
src/cmd.zig
Old New
@@ -66,8 +66,7 @@ pub const Tracker = struct {
66 } 66 }
67 } 67 }
68 68
69 /// True while marks say a command is open — the window in which the 69 /// The window in which the pgid fallback must NOT race the marks.
70 /// pgid fallback must NOT race the marks to a verdict.
71 pub fn marksOpen(self: *const Tracker) bool { 70 pub fn marksOpen(self: *const Tracker) bool {
72 return self.marks_seen and self.phase == .running; 71 return self.marks_seen and self.phase == .running;
73 } 72 }
src/delta.zig
Old New
@@ -153,30 +153,14 @@ pub const DeltaTracker = struct {
153 return .advanced; 153 return .advanced;
154 } 154 }
155 155
156 /// Output arrived with nobody attached. Records that the grid moved 156 /// Records that the grid moved WITHOUT rendering a row: hashing by
157 /// WITHOUT rendering a single row, which is the whole point: rendering 157 /// rendering dominates the daemon on a full-width repaint, and with
158 /// every row in order to hash it is ~60% of the daemon's cycles on a 158 /// nobody attached those bytes go nowhere. Takes no allocator — a
159 /// full-width repaint, and with no client not one of those bytes has 159 /// signature that can allocate is one that can render.
160 /// anywhere to go.
161 /// 160 ///
162 /// Takes no allocator, and must not grow one. A signature that can 161 /// Two commands returning in one blind stretch must not share a `seq`;
163 /// allocate is a signature that can render, and the next reader will 162 /// a reattach must get the rows that moved while nobody looked;
164 /// put the loop back. 163 /// stale hashes can match a row the client never held.
165 ///
166 /// What it still has to get right:
167 /// - `seq` advances. It is the session's watermark, stamped into
168 /// `last_return.seq`, and two commands returning during one blind
169 /// stretch must not share a seq or an await cannot tell which of
170 /// them it has just been told about.
171 /// - every row_seq is stamped, so a reattach holding an older seq is
172 /// answered with a delta carrying the whole grid rather than one
173 /// that silently omits the rows that moved while nobody looked.
174 /// - the hashes are marked stale, because they describe the grid from
175 /// before the stretch and can match a row the client never held.
176 ///
177 /// Geometry and screen changes still have to become a discontinuity:
178 /// this cannot describe them, and resyncSnapshot's rebuild is owed to a
179 /// detached session too.
180 pub fn noteBlind(self: *DeltaTracker, eng: *Engine) Update { 164 pub fn noteBlind(self: *DeltaTracker, eng: *Engine) Update {
181 if (self.rows == 0) return .discontinuity; 165 if (self.rows == 0) return .discontinuity;
182 if (self.rows != eng.term.rows or self.cols != eng.term.cols) return .discontinuity; 166 if (self.rows != eng.term.rows or self.cols != eng.term.cols) return .discontinuity;
@@ -190,15 +174,9 @@ pub const DeltaTracker = struct {
190 return .advanced; 174 return .advanced;
191 } 175 }
192 176
193 /// Can a client holding `have_seq` be sent a delta rather than a full 177 /// Only the tracker's half: whether `have_seq` is in the span it can
194 /// snapshot? Only the tracker's half of that question: whether the seq 178 /// still describe. The epoch is the caller's; 0 means the client holds
195 /// falls inside the span this tracker can still describe. Whether the 179 /// nothing.
196 /// seq is even ours to interpret — the epoch — is the caller's, since
197 /// the tracker does not know which daemon instance it belongs to.
198 ///
199 /// 0 is never serviceable: it is what a client says when it holds
200 /// nothing. Neither is any seq against a tracker that has never been
201 /// built, or whose rebuild failed partway: it can describe nothing.
202 pub fn canServe(self: *const DeltaTracker, have_seq: u64) bool { 180 pub fn canServe(self: *const DeltaTracker, have_seq: u64) bool {
203 return have_seq != 0 and 181 return have_seq != 0 and
204 have_seq >= self.reset_seq and have_seq <= self.seq and 182 have_seq >= self.reset_seq and have_seq <= self.seq and
src/engine.zig
Old New
@@ -74,13 +74,7 @@ pub const MuxHandler = struct {
74 }) catch {}; 74 }) catch {};
75 } 75 }
76 76
77 /// OSC 52. SET only: a `?` payload is the QUERY form, which asks the 77 /// OSC 52. SET only: the `?` QUERY form is a deliberate refusal.
78 /// terminal to write the clipboard back on the pty's INPUT stream.
79 /// Answering it would let any program in any session — including one
80 /// on a box reached over QUIC, including one an agent is driving —
81 /// read whatever the human last copied. xterm ships it disabled and
82 /// Alacritty defaults to OnlyCopy for this reason. Do not "complete"
83 /// this by adding a reply arm.
84 fn onClipboard( 78 fn onClipboard(
85 self: *MuxHandler, 79 self: *MuxHandler,
86 value: StreamAction.Value(.clipboard_contents), 80 value: StreamAction.Value(.clipboard_contents),
@@ -319,11 +313,8 @@ pub const Engine = struct {
319 return vt.Selection.init(tl, br, false); 313 return vt.Selection.init(tl, br, false);
320 } 314 }
321 315
322 /// The one styled-dump path: `sel` rendered as content-only bytes (no 316 /// No palette/mode side effects, so a host terminal keeps its theme.
323 /// palette/mode side effects), so the result is safe to paint onto a 317 /// Null writes nothing.
324 /// host terminal without clobbering its theme. A null selection writes
325 /// nothing — never a fallback to formatting the whole PageList, which
326 /// would drag scrollback into the dump.
327 fn writeSelection( 318 fn writeSelection(
328 self: *Engine, 319 self: *Engine,
329 w: *std.Io.Writer, 320 w: *std.Io.Writer,
@@ -354,42 +345,28 @@ pub const Engine = struct {
354 return try aw.toOwnedSlice(); 345 return try aw.toOwnedSlice();
355 } 346 }
356 347
357 /// Visible screen (viewport only) with SGR/style sequences preserved — 348 /// Viewport only, SGR preserved, no palette/mode side effects. History
358 /// content only, no palette/mode side effects, so it is safe to paint 349 /// stays daemon-side, fetched by `dumpScrollback`.
359 /// onto a host terminal (the client renderer) without clobbering its
360 /// theme. History is never included; it stays daemon-side and is
361 /// fetched on demand instead (`dumpScrollback`).
362 pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 { 350 pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 {
363 return self.formatSelection(alloc, "", self.viewportSelection()); 351 return self.formatSelection(alloc, "", self.viewportSelection());
364 } 352 }
365 353
366 /// One viewport row (0-based), styled, self-contained: starts with an 354 /// One viewport row (0-based), self-contained: leading SGR reset, no
367 /// SGR reset, contains only that row's content, no trailing newline. 355 /// trailing newline. Delta payloads are built from these.
368 /// Delta payloads are built from these. `y` must be < term.rows
369 /// (asserted in debug builds).
370 pub fn dumpVtRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 { 356 pub fn dumpVtRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 {
371 std.debug.assert(y < self.term.rows); 357 std.debug.assert(y < self.term.rows);
372 return self.formatSelection(alloc, "\x1b[0m", self.viewportRows(y, y)); 358 return self.formatSelection(alloc, "\x1b[0m", self.viewportRows(y, y));
373 } 359 }
374 /// Widen a column span to whole characters. 360 /// Widen a column span to whole characters.
375 /// 361 ///
376 /// A wide cell is two columns — the character in the first, a spacer 362 /// A wide cell is two columns and a drag stops where the hand stopped,
377 /// tail in the second — and a drag stops wherever the hand stopped, so 363 /// so a span may cut one in half. ghostty resolves the partial cell by
378 /// a span may cut one in half. ghostty resolves a partial wide cell by 364 /// emitting the WHOLE character — wrong for `dumpVtRowSpan`, whose three
379 /// emitting the WHOLE character: ghostty's formatter reaches back a 365 /// pieces each resolve their own edge, so a straddling character is
380 /// column when a selection STARTS on a spacer tail, and a selection 366 /// emitted TWICE and the row lands a column wider than the grid.
381 /// ENDING on a wide cell still emits both of that cell's columns.
382 /// 367 ///
383 /// Harmless for a selection dumped on its own, wrong here, because 368 /// Outwards, not inwards: half a character under the pointer means the
384 /// `dumpVtRowSpan` cuts the row into three pieces and each resolves its 369 /// character is under the pointer.
385 /// own edge independently — so a character straddling a cut is emitted
386 /// by BOTH pieces and the row lands one column wider than the grid.
387 /// Every glyph to the right of the pointer then shifts as the highlight
388 /// moves, which is how this was found: by dragging across 漢字.
389 ///
390 /// Snapping outwards rather than inwards because a highlight is a thing
391 /// a hand drew: half a character under the pointer means the character
392 /// is under the pointer.
393 fn snapWide(self: *Engine, y: u16, from: u16, to: u16) struct { from: u16, to: u16 } { 370 fn snapWide(self: *Engine, y: u16, from: u16, to: u16) struct { from: u16, to: u16 } {
394 const screen = self.term.screens.active; 371 const screen = self.term.screens.active;
395 const last: u16 = @intCast(self.term.cols - 1); 372 const last: u16 = @intCast(self.term.cols - 1);
@@ -410,25 +387,20 @@ pub const Engine = struct {
410 387
411 /// `dumpVtRow`, with grid columns [from, to] painted inverted. 388 /// `dumpVtRow`, with grid columns [from, to] painted inverted.
412 /// 389 ///
413 /// This exists because there is no VT sequence that inverts part of a 390 /// No VT sequence inverts part of a row already on screen: the painters
414 /// row already on screen: the painters emit whole rows of raw SGR 391 /// emit whole rows of raw SGR bytes, so a highlighted row is re-emitted
415 /// bytes, so a highlighted row has to be re-emitted rather than 392 /// rather than decorated. Same contract as `dumpVtRow` otherwise.
416 /// decorated. Same contract as `dumpVtRow` otherwise — self-contained,
417 /// one row, no terminator, painted after the caller's own CUP.
418 /// 393 ///
419 /// Three pieces, joined by CHA (`CSI n G`) rather than by counting 394 /// Three pieces joined by CHA (`CSI n G`) rather than by counting
420 /// characters. The formatter trims trailing whitespace by default, so 395 /// characters: the formatter trims trailing whitespace, so the head's
421 /// the head's byte length says nothing about where it left the cursor; 396 /// byte length says nothing about where it left the cursor. The span is
422 /// addressing the span by column is the only spelling that survives a 397 /// emitted PLAIN, which neutralizes the row's own SGR inside it — a cell
423 /// row ending in blanks. The span itself is emitted PLAIN, which is 398 /// that kept its colour reads as a hole in the highlight, and one already
424 /// what neutralizes the row's own SGR inside it: a cell that kept its 399 /// reverse-video would vanish into it.
425 /// colour under the inversion reads as a hole in the highlight, and a
426 /// cell that was already reverse-video would vanish into it.
427 /// 400 ///
428 /// The inversion closes with a full reset rather than `\x1b[27m`. Both 401 /// The inversion closes with a full reset rather than `\x1b[27m`: the
429 /// leave the terminal clean HERE — the span emits no other SGR — but 402 /// tail's formatter assumes it starts from default, and a reset is what
430 /// the tail's formatter assumes it starts from default, and a reset is 403 /// makes that true rather than what happens to be true.
431 /// what makes that true rather than what happens to be true.
432 /// 404 ///
433 /// Wide cells, graphemes and styling are ghostty's `Selection` and 405 /// Wide cells, graphemes and styling are ghostty's `Selection` and
434 /// formatter doing the work; nothing here walks a cell. 406 /// formatter doing the work; nothing here walks a cell.
@@ -510,33 +482,21 @@ pub const Engine = struct {
510 return self.term.screens.active_key != .primary; 482 return self.term.screens.active_key != .primary;
511 } 483 }
512 484
513 /// Whether the session's application has asked for bracketed paste 485 /// Sampled DEC 2004: only the host terminal can bracket a paste, so the
514 /// (DEC 2004). Sampled state: the client mirrors it onto the host 486 /// client mirrors it.
515 /// terminal, which is the only thing that can actually bracket a paste.
516 pub fn bracketedPaste(self: *const Engine) bool { 487 pub fn bracketedPaste(self: *const Engine) bool {
517 return self.term.modes.get(.bracketed_paste); 488 return self.term.modes.get(.bracketed_paste);
518 } 489 }
519 490
520 /// Whether the session's application has put the cursor keys in 491 /// DECCKM changes what an arrow key IS on the wire: `ESC O A`, not
521 /// APPLICATION mode (DECCKM). It changes what an arrow key IS on the 492 /// `ESC [ A`.
522 /// wire — `ESC O A` rather than `ESC [ A` — so anything synthesising
523 /// one has to ask. `less` and every curses program set it, which is why
524 /// alternate scroll cannot get away with the normal spelling.
525 pub fn cursorKeys(self: *const Engine) bool { 493 pub fn cursorKeys(self: *const Engine) bool {
526 return self.term.modes.get(.cursor_keys); 494 return self.term.modes.get(.cursor_keys);
527 } 495 }
528 496
529 /// Which mouse tracking modes and report formats the session's 497 /// Tracking modes AND report formats: a report in a spelling the
530 /// application has asked for. Sampled state like `bracketedPaste`, and 498 /// application did not ask for arrives as garbage in its input. Field
531 /// mirrored for the same reason: only the host terminal has a mouse, so 499 /// names, not `protocol`'s — layer 0 does not know the wire.
532 /// a client must ask it for exactly the modes the application wants.
533 ///
534 /// Not a bare "does it want the mouse": the format modes decide how an
535 /// event is spelled, and a report in a spelling the application did not
536 /// ask for arrives as garbage in its input.
537 ///
538 /// Field names, not `protocol`'s: this module is layer 0 and does not
539 /// know the wire. `server.zig` maps one to the other.
540 pub const MouseModes = struct { 500 pub const MouseModes = struct {
541 x10: bool = false, 501 x10: bool = false,
542 normal: bool = false, 502 normal: bool = false,
@@ -562,15 +522,7 @@ pub const Engine = struct {
562 }; 522 };
563 } 523 }
564 524
565 /// The window title the session set (OSC 0/2), or empty if it never 525 /// OSC 0/2, sampled. Empty and "never set" are the same answer here.
566 /// did. Sampled state, like `bracketedPaste`: the client mirrors it onto
567 /// the host terminal, which is the only thing with a title bar. Nothing
568 /// carried it before, which is why your terminal's title used to stay
569 /// wrong under mux.
570 ///
571 /// Empty and "never set" are the same answer here, and callers treat
572 /// them the same: see `sampleTermTitle` in server.zig for why mux
573 /// declines to forward either.
574 pub fn title(self: *const Engine) []const u8 { 526 pub fn title(self: *const Engine) []const u8 {
575 return self.term.getTitle() orelse ""; 527 return self.term.getTitle() orelse "";
576 } 528 }
@@ -605,10 +557,8 @@ pub const Engine = struct {
605 return self.formatSelection(alloc, "\x1b[0m", sel); 557 return self.formatSelection(alloc, "\x1b[0m", sel);
606 } 558 }
607 559
608 /// Extract an inclusive selection from the active screen's retained 560 /// Screen-space rows, zero being the oldest retained. Formatting writes
609 /// screen-space rows (row zero is the oldest retained history row). 561 /// into a fixed-size allocation: hostile coordinates cannot blow it up.
610 /// Formatting writes directly into a fixed-size allocation, so attacker-
611 /// supplied coordinates can never cause an unbounded intermediate result.
612 pub fn extractSelection( 562 pub fn extractSelection(
613 self: *Engine, 563 self: *Engine,
614 alloc: std.mem.Allocator, 564 alloc: std.mem.Allocator,
@@ -691,16 +641,7 @@ pub const Engine = struct {
691 return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) }; 641 return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) };
692 } 642 }
693 643
694 /// Full reset (RIS): grid, modes, cursor, styles. Used by `replica.zig` 644 /// Full reset (RIS). Also DISCARDS queued side_events: drain them first.
695 /// before applying each snapshot.
696 ///
697 /// Also discards any queued side_events — data loss, not terminal
698 /// state: an undrained OSC 52 copy is thrown away, not replayed after
699 /// the reconstructed state lands. Correct for today's callers
700 /// (replica.zig, wasm_core.zig), which reset without ever draining.
701 /// A caller that drains — a session-restart or resync path — must
702 /// drain before calling reset(), or ship queued events itself first;
703 /// this function will not warn it away.
704 pub fn reset(self: *Engine) void { 645 pub fn reset(self: *Engine) void {
705 self.term.fullReset(); 646 self.term.fullReset();
706 self.clearSideEvents(); 647 self.clearSideEvents();
@@ -731,14 +672,8 @@ pub const Engine = struct {
731 break :ret @typeInfo(@typeInfo(F).pointer.child).@"fn".return_type.?; 672 break :ret @typeInfo(@typeInfo(F).pointer.child).@"fn".return_type.?;
732 }; 673 };
733 674
734 /// Library defaults: VT220 conformance + ANSI color (`CSI ? 62;22 c`), 675 /// Wired even though it returns the default: an unanswered DA1 blocks
735 /// the same modest identity xterm ships. Nothing here claims sixel or 676 /// TUIs.
736 /// windowing the replica cannot honor.
737 ///
738 /// It has to be wired even though it returns the default: ghostty-vt's
739 /// stock handler answers only when the embedder supplies one, and an
740 /// unanswered DA1 is the barrier TUIs block on — a flat second off
741 /// every nvim start and quit before this existed.
742 fn onDeviceAttributes(_: *vt.TerminalStream.Handler) DeviceAttributes { 677 fn onDeviceAttributes(_: *vt.TerminalStream.Handler) DeviceAttributes {
743 return .{}; 678 return .{};
744 } 679 }
src/handoff.zig
Old New
@@ -47,8 +47,7 @@ pub const deadline_ms: u32 = 2000;
47 /// spellings does not compile. 47 /// spellings does not compile.
48 pub const key_len = 32; 48 pub const key_len = 32;
49 49
50 /// Where a `muxd endpoint` announce says its listener lives, and the key 50 /// What a `muxd endpoint` announce carries.
51 /// that authenticates to it.
52 pub const Endpoint = struct { 51 pub const Endpoint = struct {
53 port: u16, 52 port: u16,
54 key: [key_len]u8, 53 key: [key_len]u8,
@@ -159,9 +158,8 @@ pub fn parseAnnounce(line: []const u8) ParseError!?Endpoint {
159 return ep; 158 return ep;
160 } 159 }
161 160
162 /// An ssh destination reduced to something dialable: everything after the 161 /// Splits where ssh splits: ssh takes everything before the LAST `@` as
163 /// LAST `@`. ssh takes everything before the last `@` as the user name, so 162 /// the user name.
164 /// this splits where ssh splits. A bare host passes through untouched.
165 pub fn dialHost(host: []const u8) []const u8 { 163 pub fn dialHost(host: []const u8) []const u8 {
166 const at = std.mem.lastIndexOfScalar(u8, host, '@') orelse return host; 164 const at = std.mem.lastIndexOfScalar(u8, host, '@') orelse return host;
167 return host[at + 1 ..]; 165 return host[at + 1 ..];
@@ -224,14 +222,10 @@ pub fn readLine(fd: std.posix.fd_t, buf: []u8) ![]const u8 {
224 } 222 }
225 } 223 }
226 224
227 /// The whole pipe-side read: one announce line off `fd`, parsed. Null is 225 /// Null is `endpoint none`: no coordinates, session stays on ssh.
228 /// `endpoint none` — coordinates were not produced and the session stays
229 /// on ssh.
230 /// 226 ///
231 /// This is the cold path's entire interaction with the announce, in one 227 /// One call, so no caller has to know the buffer size or the missing
232 /// call, so no caller has to remember the buffer size or that the line 228 /// newline.
233 /// arrives without its newline. `readLine` stays public because the
234 /// byte-at-a-time property is worth testing on its own.
235 pub fn readAnnounce(fd: std.posix.fd_t) !?Endpoint { 229 pub fn readAnnounce(fd: std.posix.fd_t) !?Endpoint {
236 var buf: [announce_max_len]u8 = undefined; 230 var buf: [announce_max_len]u8 = undefined;
237 return parseAnnounce(try readLine(fd, &buf)); 231 return parseAnnounce(try readLine(fd, &buf));
src/interact.zig
Old New
@@ -1,36 +1,23 @@
1 //! The session-interaction core: everything that happens between a user at 1 //! The session-interaction core: what happens between a user at a terminal
2 //! a terminal and one attached session, with the dialling left out. 2 //! and one attached session, with the dialling left out — the `Ctrl-\`
3 //! chord layer, the mouse/wheel splitter and alternate scroll, speculative
4 //! echo, the side channels a session drives on the host terminal (title,
5 //! clipboard, bell, modes), and the terminal ownership those depend on.
3 //! 6 //!
4 //! What lives here is the machinery a session needs once a transport is 7 //! What does NOT live here is how a transport is BUILT or what a chord
5 //! already open — the `Ctrl-\` chord layer, the mouse/wheel splitter and 8 //! MEANS. Targets, dialling, reconnect and the handoff are client.zig's;
6 //! alternate scroll, speculative echo (offer, reconcile, paint), the side 9 //! `PrefixFilter` answers which action was typed and each driver decides
7 //! channels a session drives on the host terminal (title, clipboard, bell, 10 //! what that action does to its own world.
8 //! modes), and the terminal ownership those depend on (raw mode, the
9 //! alternate screen, the teardown that puts both back).
10 //! 11 //!
11 //! What deliberately does NOT live here is how a transport is BUILT or what 12 //! One driver: a wall tile's pump (`wallview.pumpTile`); every tile holds
12 //! a chord MEANS. Targets, dialling, reconnect backoff and the handoff are 13 //! its own transport and replica, and nothing here dials.
13 //! client.zig's; `PrefixFilter` answers "which action was typed" and every
14 //! driver decides what that action does to its own world: in a zoomed
15 //! tile `.detach` detaches and ends the run, `.wall` unzooms
16 //! (`wallview.zoomChord`).
17 //! 14 //!
18 //! There is one driver: a wall tile's pump (`wallview.pumpTile`). Since the 15 //! The transport is `anytype` throughout because `Transport` sits ABOVE
19 //! convergence, `mux [TARGET]` is a wall of one tile entered zoomed, so a 16 //! this module: the only thing this module can say about it is the
20 //! plain client and a tile are the same code path. Every tile holds its own 17 //! surface it uses, `writeFrame(proto.MsgType, []const u8) !void`.
21 //! transport and its own replica; nothing here is a singleton and nothing
22 //! here dials.
23 //! 18 //!
24 //! The transport is taken as `anytype` throughout rather than by name. That 19 //! Keeper of two CLAUDE.md invariants: prediction never enters the
25 //! is a layering fact, not a generality wish: `Transport` is built out of 20 //! replica, and `replica.zig` is the one applier.
26 //! QUIC and the ssh handoff and therefore sits ABOVE this module, so the
27 //! only thing this module can say about it is the surface it uses —
28 //! `writeFrame(proto.MsgType, []const u8) !void`, and nothing else.
29 //!
30 //! Two invariants this module is the keeper of. The prediction overlay is a
31 //! display decision: it never writes to the replica, so the replica keeps
32 //! meaning exactly "what the daemon said". And `replica.zig` is the one
33 //! applier: everything here either reads a replica or paints on top of one.
34 21
35 const std = @import("std"); 22 const std = @import("std");
36 const Engine = @import("engine").Engine; 23 const Engine = @import("engine").Engine;
@@ -396,11 +383,8 @@ fn onWinch(_: c_int) callconv(.c) void {
396 383
397 /// Arm SIGWINCH, so `Core.winch` has something to answer. 384 /// Arm SIGWINCH, so `Core.winch` has something to answer.
398 /// 385 ///
399 /// Public because the driver that owns the terminal is not always a Core. 386 /// The wall has no Core, so it arms the signal and the promoted pump
400 /// The wall puts its own terminal into raw mode (it has no Core of its own 387 /// answers it — one handler per process, which is what the flag is.
401 /// at all) and the tile that is zoomed still has to follow the tty, so the
402 /// wall arms the signal and the promoted pump answers it — one handler for
403 /// the process, which is what the flag is.
404 pub fn watchWinch() void { 388 pub fn watchWinch() void {
405 var sa: std.posix.Sigaction = .{ 389 var sa: std.posix.Sigaction = .{
406 .handler = .{ .handler = onWinch }, 390 .handler = .{ .handler = onWinch },
@@ -633,11 +617,8 @@ pub const wall_teardown = terminal_teardown;
633 /// put its own back afterwards (`wallview`'s `setZoom`). 617 /// put its own back afterwards (`wallview`'s `setZoom`).
634 pub const wall_mouse_claim = client_mouse_setup; 618 pub const wall_mouse_claim = client_mouse_setup;
635 619
636 /// Every mouse mode this client can ever have turned on, off. Built from 620 /// Built from the wire table, so a mode added there cannot be missed here
637 /// the wire table rather than typed out, because the set it has to undo is 621 /// and leave the user's shell reporting clicks after mux exits.
638 /// exactly the set the daemon can ask it to mirror — a mode added there and
639 /// forgotten here is a terminal left reporting clicks into the user's shell
640 /// as escape sequences, long after mux exited.
641 const mouse_teardown = blk: { 622 const mouse_teardown = blk: {
642 var s: []const u8 = ""; 623 var s: []const u8 = "";
643 for (proto.mouse_modes) |m| s = s ++ std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}); 624 for (proto.mouse_modes) |m| s = s ++ std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec});
@@ -661,17 +642,8 @@ fn appendTermState(
661 } 642 }
662 } 643 }
663 644
664 /// Who the wheel belongs to, written as the modes this terminal is asked 645 /// Who owns the wheel. A level-set, not a diff: the terminal may
665 /// for. An application that asked for the mouse gets EXACTLY the modes it 646 /// be one mux never set up.
666 /// asked for and every mouse byte verbatim (see `MouseFilter`'s call site);
667 /// otherwise the client keeps its own capture set and spends the wheel on
668 /// scrollback.
669 ///
670 /// A full level-set of all eight modes every time, not a diff: `term_modes`
671 /// is sampled state that repeats on every attach and reconnect, and the
672 /// terminal on the other end may be one this process never configured (a
673 /// reconnect, a `--via` that reconnected under us). Level-setting is
674 /// idempotent, so the repeats cost bytes and nothing else.
675 fn appendMouseModes( 647 fn appendMouseModes(
676 out: *std.ArrayList(u8), 648 out: *std.ArrayList(u8),
677 alloc: std.mem.Allocator, 649 alloc: std.mem.Allocator,
@@ -690,20 +662,8 @@ fn appendMouseModes(
690 } 662 }
691 } 663 }
692 664
693 /// Render one host effect onto the bytes destined for the terminal. 665 /// Re-validated, not trusted: `wasm_core.zig` default-initialises
694 /// 666 /// an invalid `ClipboardSet`.
695 /// The target and alphabet are re-checked here rather than trusted from the
696 /// effect. `ClipboardSet` is a plain struct, so Zig cannot make the
697 /// validating decoder its only constructor — and one caller already builds
698 /// an unvalidated one: `wasm_core.zig` default-initialises its borrowed
699 /// clipboard slot to `.{ .target = 0, .base64 = &.{} }`, which
700 /// `validClipboard` refuses. A linear scan over at most 64 KiB is free next
701 /// to the write it guards, and the alternative is `ESC]52;<NUL>;BEL` on a
702 /// real tty.
703 ///
704 /// Built whole before the first byte is appended, like every other builder
705 /// here: a rejection must not leave half an escape behind for a caller that
706 /// reuses one buffer across events.
707 fn appendHostEffect( 667 fn appendHostEffect(
708 out: *std.ArrayList(u8), 668 out: *std.ArrayList(u8),
709 alloc: std.mem.Allocator, 669 alloc: std.mem.Allocator,
@@ -722,20 +682,8 @@ fn appendHostEffect(
722 } 682 }
723 } 683 }
724 684
725 /// Put a mux selection's text on the HOST terminal's clipboard. 685 /// `owns_terminal` is true by design: the claim gates a session's
726 /// 686 /// event, not this drag.
727 /// Through `appendHostEffect` and not a second OSC 52 of its own: the
728 /// target check, the alphabet check and the all-or-nothing shape are that
729 /// function's, and a hand-rolled write here would be a second place for
730 /// them to be got wrong. `c` is the clipboard proper — what tmux's
731 /// `set-clipboard external` sets, and what a paste reads back.
732 ///
733 /// `owns_terminal` is TRUE unconditionally, and that is not the claim gate
734 /// being skipped. The claim answers whether a SESSION's clipboard event may
735 /// reach a terminal this tile does not hold; this text is the user's own
736 /// drag on the screen in front of them, and at the wall the tile answering
737 /// it is a demoted stripe every time. The caller gates on `is_tty` instead
738 /// — a piped `mux` claims no mouse modes and can have no drag to copy.
739 pub fn writeSelectionCopy( 687 pub fn writeSelectionCopy(
740 alloc: std.mem.Allocator, 688 alloc: std.mem.Allocator,
741 out_fd: std.posix.fd_t, 689 out_fd: std.posix.fd_t,
@@ -750,33 +698,8 @@ pub fn writeSelectionCopy(
750 } }, appendHostEffect); 698 } }, appendHostEffect);
751 } 699 }
752 700
753 /// Render a term_title frame as the OSC 0 write it implies. 701 /// An empty title CLEARS the host terminal's; the peer need not
754 /// 702 /// be this version of muxd.
755 /// Refuses any byte below 0x20 or the DEL at 0x7f. Such a byte terminates
756 /// the OSC early — BEL is the terminator itself, ESC begins the other one —
757 /// and everything after it lands on the user's screen as text they then
758 /// have to clear. Same reasoning as the base64 alphabet check on the
759 /// clipboard path, and the same all-or-nothing shape: nothing is appended
760 /// until every check has passed.
761 ///
762 /// Refuses an empty title too, which is not a parse question but the client
763 /// half of the daemon's policy (`sampleTermTitle`): `ESC]0;BEL` CLEARS the
764 /// host terminal's title, and mux will not do that to a title it never set.
765 /// Checked here as well as there because the peer is not necessarily this
766 /// version of muxd.
767 ///
768 /// OSC 0 rather than OSC 2, so the icon name moves with the title: that is
769 /// what the session's own applications write (both forms reach the engine
770 /// as one window-title operation), and mirroring it is the point.
771 ///
772 /// Restoring the user's original title on exit is NOT this function's job
773 /// and is not left undone: the terminal's own title stack carries it, via
774 /// the `22;0t` that leads the alt-screen entry and the `23;0t` that closes
775 /// `terminal_teardown`. See that constant for the observation that settled
776 /// it. Nothing here needs to remember the old title, which is just as well
777 /// — mux cannot read one back, and the engine cannot help either: ghostty's
778 /// terminal handler ignores title_push/title_pop outright, so the SESSION's
779 /// title stack does not exist to be mirrored.
780 fn appendTermTitle( 703 fn appendTermTitle(
781 out: *std.ArrayList(u8), 704 out: *std.ArrayList(u8),
782 alloc: std.mem.Allocator, 705 alloc: std.mem.Allocator,
@@ -789,51 +712,11 @@ fn appendTermTitle(
789 try out.append(alloc, 0x07); 712 try out.append(alloc, 0x07);
790 } 713 }
791 714
792 /// Write one side channel's rendering of a frame to the host terminal. 715 /// `owns_terminal` gates every channel: the claim arms the teardown, so
793 /// 716 /// a mode set under `.none` is one nothing undoes.
794 /// Outside the paint's synchronized-update bracket: these are messages TO
795 /// the terminal, not part of the picture, and a sync bracket around one
796 /// would hold it until the next frame. Nothing is written when the builder
797 /// produced nothing — every builder here is all-or-nothing, so an empty
798 /// buffer is a refusal, and half an escape sequence on a real tty paints
799 /// garbage the user has to clear.
800 ///
801 /// `owns_terminal` is the caller's `Core.claim`, and it gates every channel
802 /// rather than any one of them. mux writes a side channel only once it has
803 /// taken the terminal over, because taking it over is also what arms the
804 /// teardown that puts it back: the title pop, and the `?2004l` for a
805 /// session that asked for bracketed paste and died without unasking.
806 /// 717 ///
807 /// The hole this closes was the title's to find. `is_tty` is `isatty` of 718 /// `append` is declared, not `anytype`: allocation is a builder's only
808 /// STDIN — it gates raw mode and the alt-screen entry, both of which are 719 /// failure, so empty means refusal.
809 /// about input — while these writes go to STDOUT. With stdin redirected
810 /// and stdout still a terminal (`echo x | mux`, `mux < /dev/null` typed at
811 /// a prompt) `claim` never leaves `.none`, so mux would set the user's
812 /// title and never pop it, and turn bracketed paste on and never turn it
813 /// off. Every other side channel had the same shape; only the title made
814 /// it a broken promise, because the title is the one mux justified by
815 /// saying it could put things back.
816 ///
817 /// The cost, accepted deliberately: in that mode the session's title,
818 /// clipboard and bell go nowhere, even though a terminal is attached to
819 /// stdout and would have shown them. That matches what mux already does
820 /// there — no raw mode, no alternate screen, no hidden cursor — and the
821 /// alternative is a client that changes terminal state it has arranged no
822 /// way to change back. Gating here rather than at the three call sites so
823 /// a fourth channel cannot arrive without it.
824 ///
825 /// `append` is a DECLARED function type rather than `anytype`, because the
826 /// declaration is the specification of a side-channel builder: an output
827 /// buffer, an allocator, one value of the type it renders — and allocation
828 /// as the only way it may fail. Everything else it refuses, it refuses by
829 /// writing nothing, which is what makes "empty buffer means refusal" above
830 /// a rule rather than a hope. `anytype` accepts a builder that fails some
831 /// other way, and that error propagates out of `Core.frame` into the
832 /// driver's pump, which abandons the rest of the read
833 /// (`wallview.pumpTile`'s `break :frames`): not something a stray clipboard
834 /// byte gets to do. `Value` is
835 /// comptime for the same reason — it names the contract, and it lets each
836 /// caller's value coerce to the type its builder actually declares.
837 fn writeSideChannel( 720 fn writeSideChannel(
838 alloc: std.mem.Allocator, 721 alloc: std.mem.Allocator,
839 stdout_fd: std.posix.fd_t, 722 stdout_fd: std.posix.fd_t,
@@ -858,11 +741,9 @@ fn writeSideChannel(
858 /// What the replica shows at one cell — the `prev_ch` a prediction is 741 /// What the replica shows at one cell — the `prev_ch` a prediction is
859 /// judged against later. 742 /// judged against later.
860 /// 743 ///
861 /// Read at the PREDICTED cursor, not the replica's own: mid-burst those are 744 /// Read at the PREDICTED cursor, not the replica's own: mid-burst those
862 /// different cells, and reading the wrong one hands reconcile a `prev_ch` 745 /// are different cells, and the wrong one turns "not answered yet" into
863 /// that belongs to somebody else's cell, which turns "the frame has not 746 /// "contradicted" and flushes the queue.
864 /// answered yet" into "we were contradicted" and flushes the queue. That is
865 /// the failure the real-Engine test below exists to catch.
866 fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.CursorPos) u8 { 747 fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.CursorPos) u8 {
867 const plain = replica.dumpPlain(alloc) catch return ' '; 748 const plain = replica.dumpPlain(alloc) catch return ' ';
868 defer alloc.free(plain); 749 defer alloc.free(plain);
@@ -870,17 +751,8 @@ fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.Curso
870 return grid.cellChar(at.y, at.x) orelse ' '; 751 return grid.cellChar(at.y, at.x) orelse ' ';
871 } 752 }
872 753
873 /// Judge the overlay against the replica as it now stands. Call after the 754 /// Judge the overlay against the replica as it stands — after the
874 /// replica has taken the frame, never before: the whole question is what 755 /// replica has taken the frame, never before.
875 /// the authoritative state says now.
876 ///
877 /// Private, with `paintOverlay` and `offerKeystroke`: the wall's pump
878 /// drives a `Core` rather than hand-rolling the client's input path, so
879 /// nothing outside this file reaches the overlay.
880 /// The overlay is the one place the "never enters the replica"
881 /// rule is kept, so the fewer doors into it the better — if a driver ever
882 /// needs one of these back, that is a second implementation announcing
883 /// itself.
884 fn reconcileOverlay( 756 fn reconcileOverlay(
885 alloc: std.mem.Allocator, 757 alloc: std.mem.Allocator,
886 overlay: *predict.Overlay, 758 overlay: *predict.Overlay,
@@ -897,14 +769,8 @@ fn reconcileOverlay(
897 ); 769 );
898 } 770 }
899 771
900 /// Paint every pending prediction on top of whatever is on screen, and 772 /// Idempotent, and called after every authoritative paint: a delta
901 /// leave the cursor where the typist believes it is. 773 /// repaints whole rows and would wipe an outstanding prediction.
902 ///
903 /// Idempotent and called after every authoritative paint as well as on each
904 /// keystroke, because a delta repaints whole rows: the row content the
905 /// daemon sent would otherwise wipe an underlined glyph whose prediction is
906 /// still outstanding, and the burst would flicker away one frame after it
907 /// was drawn.
908 fn paintOverlay( 774 fn paintOverlay(
909 alloc: std.mem.Allocator, 775 alloc: std.mem.Allocator,
910 overlay: *predict.Overlay, 776 overlay: *predict.Overlay,
@@ -948,9 +814,8 @@ fn paintOverlay(
948 proto.writeAllFd(out_fd, paint.items) catch {}; 814 proto.writeAllFd(out_fd, paint.items) catch {};
949 } 815 }
950 816
951 /// Offer one chunk of typed bytes to the overlay. The chunk goes to the 817 /// The chunk goes to the daemon unchanged whatever happens here:
952 /// daemon unchanged whatever happens here — prediction never alters what 818 /// prediction alters what the screen shows, never what the shell reads.
953 /// the shell receives, only what the screen shows before it answers.
954 fn offerKeystroke( 819 fn offerKeystroke(
955 alloc: std.mem.Allocator, 820 alloc: std.mem.Allocator,
956 overlay: *predict.Overlay, 821 overlay: *predict.Overlay,
@@ -1011,12 +876,10 @@ pub const PredictCounters = predict.Counters;
1011 876
1012 /// The `MUX_PREDICT_STATS` line, on the way out. 877 /// The `MUX_PREDICT_STATS` line, on the way out.
1013 /// 878 ///
1014 /// Public because a Core is not always the thing that says goodbye. A wall 879 /// A wall tile's Core lives on a detached pump the process exit kills
1015 /// tile's Core lives on a DETACHED pump thread that the process exit kills 880 /// where it stands, so the driver that owns the exit prints this from
1016 /// where it stands — no return, no deinit — so the driver that does own the 881 /// counters the pump published — on the normal screen, not an alternate
1017 /// exit (wallview's `run`, after it has put the terminal back) prints it 882 /// one about to be discarded.
1018 /// from counters the pump published. That is also the right ORDER: on the
1019 /// normal screen, not onto an alternate one about to be discarded.
1020 pub fn dumpPredictStats(c: predict.Counters) void { 883 pub fn dumpPredictStats(c: predict.Counters) void {
1021 const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return; 884 const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return;
1022 if (!std.mem.eql(u8, want, "1")) return; 885 if (!std.mem.eql(u8, want, "1")) return;
@@ -1026,16 +889,13 @@ pub fn dumpPredictStats(c: predict.Counters) void {
1026 } 889 }
1027 890
1028 /// Turn wheel notches into the arrow keys an alt-screen application reads, 891 /// Turn wheel notches into the arrow keys an alt-screen application reads,
1029 /// `wheel_rows` of them per notch so the wheel moves the same distance 892 /// `wheel_rows` per notch so the wheel moves the same distance either way.
1030 /// whichever screen is up.
1031 /// 893 ///
1032 /// Sent as input rather than predicted: `offerKeystroke` refuses anything 894 /// Sent as input rather than predicted: a guess painted at the cursor of
1033 /// that is not a single byte anyway, and a guess painted at the cursor of a 895 /// a full-screen application is about a layout the client cannot see.
1034 /// full-screen application is a guess about a layout the client cannot see.
1035 /// 896 ///
1036 /// Batched, because a spin arrives as one burst and one frame per arrow 897 /// Batched: a spin arrives as one burst and one frame per arrow would
1037 /// would put a hundred frames on the wire for one flick of a finger. The 898 /// put a hundred frames on the wire.
1038 /// loop is what bounds the buffer rather than the burst.
1039 fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void { 899 fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void {
1040 const seq = altScrollSeq(wheel, app_cursor); 900 const seq = altScrollSeq(wheel, app_cursor);
1041 var buf: [alt_scroll_batch * 3]u8 = undefined; 901 var buf: [alt_scroll_batch * 3]u8 = undefined;
@@ -1053,15 +913,9 @@ fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void {
1053 /// to pace anything. 913 /// to pace anything.
1054 const alt_scroll_batch: u32 = 64; 914 const alt_scroll_batch: u32 = 64;
1055 915
1056 /// The arrow key one notch means, in the spelling this session reads.
1057 ///
1058 /// DECCKM decides what an arrow key IS, and getting it wrong is silent: 916 /// DECCKM decides what an arrow key IS, and getting it wrong is silent:
1059 /// `less` puts the cursor keys in APPLICATION mode and reads `ESC O A`, so 917 /// `less` reads `ESC O A` and ignores `ESC [ A`. Three bytes either
1060 /// `ESC [ A` arrives as an escape it ignores and the page does not move. 918 /// way, as `sendAltScroll` assumes.
1061 /// Measured on `less +G` — the normal spelling scrolled nothing at all, and
1062 /// every curses program sets the same mode.
1063 ///
1064 /// Three bytes either way, which `sendAltScroll`'s buffer relies on.
1065 fn altScrollSeq(wheel: i32, app_cursor: bool) *const [3]u8 { 919 fn altScrollSeq(wheel: i32, app_cursor: bool) *const [3]u8 {
1066 if (app_cursor) return if (wheel > 0) "\x1bOA" else "\x1bOB"; 920 if (app_cursor) return if (wheel > 0) "\x1bOA" else "\x1bOB";
1067 return if (wheel > 0) "\x1b[A" else "\x1b[B"; 921 return if (wheel > 0) "\x1b[A" else "\x1b[B";
@@ -1153,25 +1007,12 @@ pub const Routed = enum {
1153 not_mine, 1007 not_mine,
1154 }; 1008 };
1155 1009
1156 /// Where a Core's paints are allowed to land, and who says so. 1010 /// Where a Core's paints are allowed to land.
1157 ///
1158 /// A plain client never asks: its terminal is its own for the whole run, so
1159 /// the default answers yes and holds nothing. A wall tile's terminal is
1160 /// shared with N stripes on N threads and, while the tile is DEMOTED, is
1161 /// not the tile's at all — the wall paints its stripe instead, cropped from
1162 /// this same replica. So every paint asks first, and the answer is held
1163 /// until the paint is finished: a zoom that moved between the decision and
1164 /// the bytes would put one session's rows on another session's screen.
1165 /// 1011 ///
1166 /// Two function pointers rather than a driver interface because there is 1012 /// A wall tile's terminal is shared with N stripes on N threads, so every
1167 /// exactly one question — "may I write to `out_fd` now, and will that stay 1013 /// paint asks first and the answer is held until the paint is finished: a
1168 /// true until I say I am done" — and the wall already had the answer 1014 /// zoom that moved mid-paint would put one session's rows on another's
1169 /// (`paint_mu` plus its zoom check) before this Core existed. 1015 /// screen. Side channels are gated on `Core.claim` instead.
1170 ///
1171 /// It gates PAINTS, not side channels. A mode or a title is a write whose
1172 /// meaning does not depend on where the cursor is, and it is already gated
1173 /// on the terminal claim (`Core.claim`), which a demoted tile does not
1174 /// hold.
1175 pub const Sink = struct { 1016 pub const Sink = struct {
1176 ctx: ?*anyopaque = null, 1017 ctx: ?*anyopaque = null,
1177 /// True when the Core may paint, with whatever lock makes that true 1018 /// True when the Core may paint, with whatever lock makes that true
@@ -1338,23 +1179,17 @@ pub const Core = struct {
1338 /// whatever a previous read left mid-report (see MouseFilter.feed). 1179 /// whatever a previous read left mid-report (see MouseFilter.feed).
1339 mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined, 1180 mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined,
1340 1181
1341 /// Born at a size the driver already measured, because the Engine has 1182 /// Born at a size the driver measured: the Engine has to be born at
1342 /// to be born at the size the first paint will be clipped to. 1183 /// the size the first paint clips to.
1343 ///
1344 /// Measured by the DRIVER and not here, because the driver's layout was
1345 /// cut from ONE reading of the terminal — the wall measures at startup
1346 /// and re-reads the terminal only through the promoted tile that
1347 /// answers the SIGWINCH (`setWallSize`) — so that its tiles clip to the
1348 /// size its stripes were cut from, rather than to whatever a second
1349 /// ioctl says after the user dragged a corner.
1350 /// Two answers to "how big is this terminal" inside one screen is a
1351 /// promoted tile painting at rows the wall does not believe in.
1352 pub fn initSized( 1184 pub fn initSized(
1353 alloc: std.mem.Allocator, 1185 alloc: std.mem.Allocator,
1354 in_fd: std.posix.fd_t, 1186 in_fd: std.posix.fd_t,
1355 out_fd: std.posix.fd_t, 1187 out_fd: std.posix.fd_t,
1356 size: proto.Size, 1188 size: proto.Size,
1357 ) !Core { 1189 ) !Core {
1190 // The driver's layout was cut from ONE reading of the terminal, so
1191 // a second ioctl here would clip a promoted tile to rows the wall
1192 // does not believe in.
1358 const eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); 1193 const eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows });
1359 return .{ 1194 return .{
1360 .alloc = alloc, 1195 .alloc = alloc,
@@ -1385,58 +1220,24 @@ pub const Core = struct {
1385 self.rep.eng.deinit(); 1220 self.rep.eng.deinit();
1386 } 1221 }
1387 1222
1388 /// Adopt a size somebody else measured. 1223 /// Only the promoted tile sees a SIGWINCH; it adopts the wall's
1389 /// 1224 /// size.
1390 /// The wall's, at a promote. A tile's Core is born at the terminal's
1391 /// size and only `winch` ever changes it — and `winch` reads a
1392 /// PROCESS-wide flag, so only the tile holding the terminal can answer
1393 /// one. A tile promoted after somebody resized would otherwise clip its
1394 /// paints to a screen that no longer exists. The resize frame that
1395 /// follows a promote is what tells the daemon; this is the local half.
1396 pub fn adoptSize(self: *Core, size: proto.Size) void { 1225 pub fn adoptSize(self: *Core, size: proto.Size) void {
1397 self.size = size; 1226 self.size = size;
1398 } 1227 }
1399 1228
1400 /// Take the terminal for this session ALONE, on a screen somebody else 1229 /// Take the terminal for this session ALONE — the wall's promote.
1401 /// already owns — the wall's promote.
1402 /// 1230 ///
1403 /// This writes `session_claim` once per PROMOTE; the wall wrote the 1231 /// Taken under the SINK for ORDER, not painting: the wall writes the
1404 /// screen half (`wall_setup`) for its own lifetime. What a session 1232 /// previous holder's release under the same lock, before the store that
1405 /// needs is exactly the mouse modes: without them a host terminal 1233 /// makes the handover visible (wallview's `setZoom`). A claim taken
1406 /// answers the wheel by synthesising arrow keys (DEC 1007) that land in 1234 /// outside it can land AFTER that release, leaving modes nothing
1407 /// the session as input, which is what a zoomed tile did before this 1235 /// undoes. `false` is a promote the zoom moved out from under.
1408 /// existed — the whole point of routing a tile's input through a Core.
1409 /// 1236 ///
1410 /// It sets `claim`, and `claim` is what arms the undo. Every mode a 1237 /// The session's own modes go on top because a promote's resize is
1411 /// promoted session then asks this terminal for goes out under that 1238 /// answered by `resyncSnapshot`, which carries no modes: otherwise a
1412 /// flag (`writeSideChannel`), so the set and its undo are one flag 1239 /// tile whose application asked for the mouse would hold a terminal
1413 /// rather than an ordering — see `Claim`, whose pair turns over many 1240 /// that never heard about it.
1414 /// times per wall instead of once per process.
1415 ///
1416 /// Taken under the SINK, and that is about order, not about painting.
1417 /// A driver that shares its terminal writes the previous holder's
1418 /// release itself, under the same lock, immediately before the store
1419 /// that makes the handover visible (wallview's `setZoom`). A claim
1420 /// taken outside that lock can be preempted between reading the store
1421 /// and writing its modes, land AFTER the release meant to precede it,
1422 /// and leave a terminal in modes nothing is arranged to undo — because
1423 /// the demote deliberately writes nothing. So the sink's answer is both
1424 /// "may I write" and "is this handover still mine", and `false` here is
1425 /// a promote the zoom moved out from under: it claims nothing, and the
1426 /// next pass demotes a Core that never held anything.
1427 /// The session's OWN modes go on top, and that half is what a promote
1428 /// needs and an attach does not. An attaching client is told the modes
1429 /// moments later — `sendResync` ends with `term_modes` — but a
1430 /// promote's resize is answered by `resyncSnapshot`, which carries no
1431 /// modes, deliberately. So a tile whose application asked for the mouse
1432 /// an hour ago would hold a terminal that never heard about it, and
1433 /// every wheel report would be eaten as this client's scrollback
1434 /// instead of reaching the application it belongs to.
1435 ///
1436 /// The Core knows without asking: `semantic` has tracked every mode
1437 /// sample since the tile was born, because only the WRITE was ever
1438 /// gated on the claim. Level-setting is idempotent, so a session with
1439 /// nothing to say pays a few bytes.
1440 pub fn claimTerminal(self: *Core) bool { 1241 pub fn claimTerminal(self: *Core) bool {
1441 if (!self.is_tty or self.claim != .none) return false; 1242 if (!self.is_tty or self.claim != .none) return false;
1442 if (!self.beginPaint()) return false; 1243 if (!self.beginPaint()) return false;
@@ -1474,19 +1275,17 @@ pub const Core = struct {
1474 1275
1475 /// Give the terminal back: the demote, and every other way out. 1276 /// Give the terminal back: the demote, and every other way out.
1476 /// 1277 ///
1477 /// Nothing goes on the WIRE — a demote is client-local, which is the 1278 /// Nothing goes on the WIRE — a demote is client-local — but plenty
1478 /// whole of "an unzoomed tile claims nothing" — but plenty comes off 1279 /// comes off the terminal, because the session that was promoted set
1479 /// the terminal, because the session that was promoted set modes on it. 1280 /// modes on it. A wall left still reporting clicks into the user's
1480 /// A wall left still reporting clicks into the user's shell is the 1281 /// shell is the failure this pairs against.
1481 /// failure this pairs against.
1482 /// 1282 ///
1483 /// The scroll view goes with it whichever way the undo went. The stripe 1283 /// The scroll view goes with it whichever way the undo went: the
1484 /// that resumes paints live state from the same replica, so a Core 1284 /// stripe that resumes paints live state from the same replica, so a
1485 /// still suppressing paints for a history page would come back to a 1285 /// Core still suppressing paints would come back to a zoom showing
1486 /// zoom showing nothing. 1286 /// nothing.
1487 /// 1287 ///
1488 /// Idempotent, and it says which teardown by what was claimed — the 1288 /// Idempotent, and it says which teardown by what was claimed.
1489 /// same flag, read the other way.
1490 pub fn releaseTerminal(self: *Core, undo: Undo) void { 1289 pub fn releaseTerminal(self: *Core, undo: Undo) void {
1491 const held = self.claim; 1290 const held = self.claim;
1492 self.claim = .none; 1291 self.claim = .none;
@@ -1504,30 +1303,19 @@ pub const Core = struct {
1504 } 1303 }
1505 } 1304 }
1506 1305
1507 /// The replica's engine, for a driver that paints its OWN view of this 1306 /// For the wall's demoted stripe, which crops this grid.
1508 /// session instead of the Core's.
1509 ///
1510 /// The wall's DEMOTED tile is the only such driver, and the access is
1511 /// deliberately this narrow: a stripe is a crop of the same grid,
1512 /// painted by the wall at the wall's rows, while the Core paints
1513 /// nothing at all because it holds no terminal claim. One replica per
1514 /// tile, one applier for it (replica.zig), two ways of looking at it.
1515 pub fn grid(self: *Core) *Engine { 1307 pub fn grid(self: *Core) *Engine {
1516 return self.rep.eng; 1308 return self.rep.eng;
1517 } 1309 }
1518 1310
1519 /// The selection as this frame's paint must see it.
1520 ///
1521 /// Returned BY VALUE and kept on the caller's stack for the length of 1311 /// Returned BY VALUE and kept on the caller's stack for the length of
1522 /// the paint: `sink()` hands the painter a pointer to it, and the 1312 /// the paint: `sink()` hands the painter a pointer to it.
1523 /// history count it carries is read from the replica the paint is
1524 /// about to walk.
1525 fn highlight(self: *Core) Highlight { 1313 fn highlight(self: *Core) Highlight {
1526 return .{ .drag = &self.drag, .tile = zoomed_tile, .history_rows = self.rep.history_rows }; 1314 return .{ .drag = &self.drag, .tile = zoomed_tile, .history_rows = self.rep.history_rows };
1527 } 1315 }
1528 1316
1529 /// May this Core paint now, and hold that answer until `endPaint`? 1317 /// May this Core paint now, and hold that until `endPaint`? Every
1530 /// Every write of GRID bytes goes through this pair; see `Sink`. 1318 /// GRID write goes through the pair.
1531 fn beginPaint(self: *Core) bool { 1319 fn beginPaint(self: *Core) bool {
1532 const b = self.sink.begin orelse return true; 1320 const b = self.sink.begin orelse return true;
1533 return b(self.sink.ctx); 1321 return b(self.sink.ctx);
@@ -1537,14 +1325,12 @@ pub const Core = struct {
1537 if (self.sink.end) |e| e(self.sink.ctx); 1325 if (self.sink.end) |e| e(self.sink.ctx);
1538 } 1326 }
1539 1327
1540 /// The whole screen from the replica. What a driver calls when the 1328 /// The whole screen from the replica — the wall's promote, where the
1541 /// SCREEN went stale with the session saying nothing — the wall's 1329 /// replica has been hot the whole time the tile was a stripe, so the
1542 /// promote, where the replica has been hot the entire time the tile was 1330 /// zoom is a local repaint at zero round trips.
1543 /// a stripe and the zoom is therefore a local repaint at zero round
1544 /// trips.
1545 /// 1331 ///
1546 /// The overlay goes back on top, because the rows just drawn have 1332 /// The overlay goes back on top: the rows just drawn have overwritten
1547 /// overwritten predictions that are still outstanding. 1333 /// predictions still outstanding.
1548 pub fn repaint(self: *Core) !void { 1334 pub fn repaint(self: *Core) !void {
1549 if (!self.beginPaint()) return; 1335 if (!self.beginPaint()) return;
1550 defer self.endPaint(); 1336 defer self.endPaint();
@@ -1617,8 +1403,7 @@ pub const Core = struct {
1617 } 1403 }
1618 1404
1619 /// A one-line marker in the corner, painted over by the next full 1405 /// A one-line marker in the corner, painted over by the next full
1620 /// repaint — the right lifetime for something the user needs to read. 1406 /// repaint. ASCII only: `bannerText` places the label by byte length.
1621 /// ASCII only: `bannerText` places the label by byte length.
1622 pub fn banner(self: *Core, text: []const u8) void { 1407 pub fn banner(self: *Core, text: []const u8) void {
1623 if (!self.is_tty or !self.beginPaint()) return; 1408 if (!self.is_tty or !self.beginPaint()) return;
1624 defer self.endPaint(); 1409 defer self.endPaint();
@@ -1645,23 +1430,21 @@ pub const Core = struct {
1645 return .ok; 1430 return .ok;
1646 } 1431 }
1647 1432
1648 /// The idle path, and the only thing that can retire a prediction the 1433 /// The only thing that can retire a prediction the application
1649 /// application answered by going quiet: no frame is coming, so 1434 /// answered by going quiet: no frame is coming, so reconcile never
1650 /// reconcile will never run again and the glyph would otherwise stay on 1435 /// runs again.
1651 /// screen for the rest of the session.
1652 pub fn idle(self: *Core) !void { 1436 pub fn idle(self: *Core) !void {
1653 if (self.overlay.expire(std.time.milliTimestamp()) == .contradicted and self.scroll_rows == 0) { 1437 if (self.overlay.expire(std.time.milliTimestamp()) == .contradicted and self.scroll_rows == 0) {
1654 try self.paintFull(); 1438 try self.paintFull();
1655 } 1439 }
1656 } 1440 }
1657 1441
1658 /// The replica has taken a snapshot: tell the overlay and rebuild the 1442 /// The replica has taken a snapshot: tell the overlay, rebuild under
1659 /// screen under it. 1443 /// it.
1660 /// 1444 ///
1661 /// A snapshot answers a resize, ends a reconnect, and rebuilds the 1445 /// A snapshot answers a resize and ends a reconnect. Neither says a
1662 /// screen under anything outstanding. None of that says a prediction 1446 /// prediction was wrong — it says we can no longer find out, so the
1663 /// was wrong — it says we can no longer find out, so the queue goes and 1447 /// queue goes and the counters do not move.
1664 /// the counters do not move.
1665 fn snapshotTaken(self: *Core) !void { 1448 fn snapshotTaken(self: *Core) !void {
1666 self.overlay.setGrid(self.rep.grid.cols, self.rep.grid.rows); 1449 self.overlay.setGrid(self.rep.grid.cols, self.rep.grid.rows);
1667 self.overlay.setResizePending(false); 1450 self.overlay.setResizePending(false);
@@ -2075,23 +1858,15 @@ pub const Core = struct {
2075 return .ok; 1858 return .ok;
2076 } 1859 }
2077 1860
2078 /// Feed this read's mouse reports to the drag.
2079 ///
2080 /// Left button only. Middle is the terminal's own paste and right its 1861 /// Left button only. Middle is the terminal's own paste and right its
2081 /// menu, and stealing either would be a surprise with no answer. The 1862 /// menu.
2082 /// button word arrives from the filter verbatim, motion bit and
2083 /// modifiers included, so it is the low two bits that name the button.
2084 /// 1863 ///
2085 /// A plain click is a defined no-op here, which is the difference 1864 /// A plain click is a defined no-op: a zoomed tile is the only session
2086 /// between this driver and the wall's: at the wall a click moves the 1865 /// on its screen, so there is nothing for a click to select.
2087 /// selection between stripes, and a zoomed tile is the only session on
2088 /// its screen, so there is nothing for a click to select.
2089 /// 1866 ///
2090 /// What comes back is the selection a release FINISHED, for the caller 1867 /// What comes back is the selection a release FINISHED. One read can
2091 /// to ask the daemon about — the copy is `forward`'s to send because 1868 /// hold more than one release; the last is the answer, which is what
2092 /// the transport is. One read can hold more than one release; the last 1869 /// `client_core.beginSelection`'s latest-wins would make of two.
2093 /// is the answer, which is also what `client_core.beginSelection`'s
2094 /// latest-wins would make of two requests in a row.
2095 fn dragReports(self: *Core, events: []const MouseFilter.Event) ?select.Range { 1870 fn dragReports(self: *Core, events: []const MouseFilter.Event) ?select.Range {
2096 var done: ?select.Range = null; 1871 var done: ?select.Range = null;
2097 for (events) |ev| { 1872 for (events) |ev| {
@@ -2195,26 +1970,13 @@ pub const Core = struct {
2195 }; 1970 };
2196 } 1971 }
2197 1972
2198 /// Go back to the live view without painting it — what a driver does 1973 /// Go back to the live view without painting it — a resync's own
2199 /// BEFORE a reconnect, where the resync's own repaint is what will 1974 /// repaint is what arrives.
2200 /// arrive.
2201 ///
2202 /// A resync repaints live state, so a history page would be silently
2203 /// replaced a moment later — and the banner would sit over stale rows
2204 /// until the user happened to leave scroll mode.
2205 ///
2206 /// The overlay has to be told, and only the driver can tell it:
2207 /// `flush()` drops predictions but deliberately leaves the mode bit
2208 /// alone, so a reconnect taken while scrolled would leave the overlay
2209 /// suppressing with no page to suppress for. The "any other key" exit
2210 /// in `forward` cannot rescue it — that branch is guarded by
2211 /// `scroll_rows > 0`, which the line below has just made false.
2212 /// Shift+PageDown still can (its `scroll_rows == 0` arm clears the mode
2213 /// unconditionally), so this is recoverable rather than terminal — but
2214 /// only by a keystroke the user has no reason to guess, so prediction
2215 /// is silently off until they do.
2216 pub fn dropScrollView(self: *Core) void { 1975 pub fn dropScrollView(self: *Core) void {
2217 self.scroll_rows = 0; 1976 self.scroll_rows = 0;
1977 // `flush()` leaves the mode bit alone, so nothing else clears it:
1978 // an overlay left suppressing has no page to suppress for, and
1979 // only Shift+PageDown would ever turn it back on.
2218 self.overlay.setScrollMode(false); 1980 self.overlay.setScrollMode(false);
2219 } 1981 }
2220 1982
@@ -3208,9 +2970,9 @@ test "interact: a borrowed terminal's claim is a session's, and every teardown u
3208 } 2970 }
3209 } 2971 }
3210 2972
3211 /// Everything waiting on a NON-BLOCKING pipe, as one slice. One escape 2973 /// Everything on a NON-BLOCKING pipe, as one slice: these are emitted
3212 /// sequence per `write` is how these are emitted, so a reader that took the 2974 /// one escape sequence per `write`, so a reader that took the first
3213 /// first write for the whole answer would pin half a pair. 2975 /// for the whole answer would pin half a pair.
3214 fn drainPipe(fd: std.posix.fd_t, buf: []u8) []const u8 { 2976 fn drainPipe(fd: std.posix.fd_t, buf: []u8) []const u8 {
3215 var n: usize = 0; 2977 var n: usize = 0;
3216 while (std.posix.read(fd, buf[n..])) |got| { 2978 while (std.posix.read(fd, buf[n..])) |got| {
@@ -3281,8 +3043,8 @@ test "interact: a promote takes the mouse, a demote gives it back, a demote twic
3281 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); 3043 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
3282 } 3044 }
3283 3045
3284 /// A transport that swallows everything. `forward` writes the keystrokes 3046 /// Swallows everything: `forward` writes the keystrokes it did not
3285 /// it did not consume, and none of these tests are about those bytes. 3047 /// consume, and no test here is about them.
3286 const NullTransport = struct { 3048 const NullTransport = struct {
3287 fn writeFrame(_: *NullTransport, _: proto.MsgType, _: []const u8) !void {} 3049 fn writeFrame(_: *NullTransport, _: proto.MsgType, _: []const u8) !void {}
3288 }; 3050 };
@@ -3752,10 +3514,8 @@ test "interact: a release at a zoomed tile asks the daemon for what it highlight
3752 try std.testing.expectEqual(@as(usize, 1), tr.n); 3514 try std.testing.expectEqual(@as(usize, 1), tr.n);
3753 } 3515 }
3754 3516
3755 /// One `selection_reply` payload, laid out by hand: id, status, watermark, 3517 /// Laid out by hand, not encoded: a layout change must fail a test,
3756 /// then the text (`protocol.encodeSelectionReply`). Written out rather than 3518 /// not agree with itself.
3757 /// encoded so a change to that layout shows up here as a failing test and
3758 /// not as a test that quietly agrees with itself.
3759 fn replyBytes( 3519 fn replyBytes(
3760 buf: []u8, 3520 buf: []u8,
3761 id: u32, 3521 id: u32,
src/keymap.zig
Old New
@@ -186,8 +186,7 @@ fn tildeKey(mods: Mods, n: u8, buf: []u8) []const u8 {
186 return std.fmt.bufPrint(buf, "\x1b[{d};{d}~", .{ n, mods.param() }) catch unreachable; 186 return std.fmt.bufPrint(buf, "\x1b[{d};{d}~", .{ n, mods.param() }) catch unreachable;
187 } 187 }
188 188
189 /// SS3 form for F1-F4: ESC O <final> unmodified, CSI 1 ; <mods> <final> 189 /// SS3 has nowhere to put a parameter, so a modified F1-F4 goes as CSI.
190 /// modified (SS3 has nowhere to put a parameter).
191 fn ss3Key(mods: Mods, final: u8, buf: []u8) []const u8 { 190 fn ss3Key(mods: Mods, final: u8, buf: []u8) []const u8 {
192 return introKey(mods, 'O', final, buf); 191 return introKey(mods, 'O', final, buf);
193 } 192 }
@@ -195,9 +194,8 @@ fn ss3Key(mods: Mods, final: u8, buf: []u8) []const u8 {
195 pub const paste_begin = "\x1b[200~"; 194 pub const paste_begin = "\x1b[200~";
196 pub const paste_end = "\x1b[201~"; 195 pub const paste_end = "\x1b[201~";
197 196
198 /// Bracketed paste: the wrap and nothing else. The bytes between the 197 /// Bytes between the brackets go verbatim; filtering a pasted ESC is
199 /// brackets are the paste verbatim — filtering (say, of a pasted ESC) is 198 /// policy.
200 /// a policy question this module does not answer in v1.
201 pub fn pasteInto( 199 pub fn pasteInto(
202 list: *std.ArrayList(u8), 200 list: *std.ArrayList(u8),
203 alloc: std.mem.Allocator, 201 alloc: std.mem.Allocator,
src/main.zig
Old New
@@ -28,17 +28,15 @@ const usage =
28 \\ 28 \\
29 ; 29 ;
30 30
31 /// `--key` beats `MUX_KEY_FILE` beats the default path: the more specific 31 /// `--key` beats `MUX_KEY_FILE` beats the default path — more specific
32 /// the statement of intent, the higher it sits. Pure and separate from 32 /// intent sits higher. Split out so the order is testable without a
33 /// `run` so the ORDER is testable on its own — `run` needs a daemon, and an 33 /// daemon.
34 /// order that quietly inverted would otherwise only show up as a daemon
35 /// authenticating with the wrong key.
36 fn pickKey(flag: ?[]const u8, env: ?[]const u8, default_if_present: ?[]const u8) ?[]const u8 { 34 fn pickKey(flag: ?[]const u8, env: ?[]const u8, default_if_present: ?[]const u8) ?[]const u8 {
37 return flag orelse env orelse default_if_present; 35 return flag orelse env orelse default_if_present;
38 } 36 }
39 37
40 /// MUX_KEY_FILE, with "set but empty" read as unset — an empty path could 38 /// MUX_KEY_FILE, with "set but empty" read as unset — an empty path
41 /// only ever be a mistake, and Key.load would blame a confusing "". 39 /// could only be a mistake, and Key.load would blame "".
42 fn envKey() ?[]const u8 { 40 fn envKey() ?[]const u8 {
43 const v = std.posix.getenv("MUX_KEY_FILE") orelse return null; 41 const v = std.posix.getenv("MUX_KEY_FILE") orelse return null;
44 return if (v.len == 0) null else v; 42 return if (v.len == 0) null else v;
@@ -108,9 +106,8 @@ comptime {
108 } 106 }
109 } 107 }
110 108
111 /// Exact match on the whole word, first row wins. No prefix or abbreviation 109 /// No prefix matching: `ru` is a typo, and guessing which verb it
112 /// matching: `ru` is a typo, and guessing which verb it meant is how a typo 110 /// meant is how a typo becomes a daemon.
113 /// becomes a daemon.
114 fn specForName(name: []const u8) ?Spec { 111 fn specForName(name: []const u8) ?Spec {
115 for (specs) |s| { 112 for (specs) |s| {
116 if (std.mem.eql(u8, name, s.name)) return s; 113 if (std.mem.eql(u8, name, s.name)) return s;
@@ -118,9 +115,8 @@ fn specForName(name: []const u8) ?Spec {
118 return null; 115 return null;
119 } 116 }
120 117
121 /// Unreachable is honest here only because of the comptime block above, 118 /// Unreachable is honest because the comptime check fails the build
122 /// which fails the build for a Cmd with no row — and the first-wins scan is 119 /// for a Cmd with no row.
123 /// unambiguous only because the same block refuses a second one.
124 fn specForCmd(cmd: Cmd) Spec { 120 fn specForCmd(cmd: Cmd) Spec {
125 for (specs) |s| { 121 for (specs) |s| {
126 if (s.cmd == cmd) return s; 122 if (s.cmd == cmd) return s;
@@ -380,18 +376,8 @@ pub fn main() !u8 {
380 } 376 }
381 } 377 }
382 378
383 /// Whether the session shell gets the OSC 133 injection, decided from 379 /// Opt-IN, `=1` alone: the shim costs a zsh user `~/.zshenv` and a
384 /// `MUX_SHELL_INTEGRATION` alone. Pure so the policy can be asserted without 380 /// bash user's DEBUG trap.
385 /// a daemon to set an environment for.
386 ///
387 /// Opt-IN: `=1` and nothing else. It was an opt-out through the agent
388 /// surface and the multi-session daemon, on the reasoning that marks are
389 /// what make an exit code
390 /// knowable; the daily-driver reading is the opposite one, because the shim
391 /// costs a zsh user their `~/.zshenv` and displaces a bash user's DEBUG trap
392 /// (atuin, bash-preexec) on every session, while only `muxa` reads what it
393 /// buys. Inverting rather than adding a second spelling means a stale `=0`
394 /// still reads as off.
395 fn shellIntegrationEnabled(env: ?[]const u8) bool { 381 fn shellIntegrationEnabled(env: ?[]const u8) bool {
396 return std.mem.eql(u8, env orelse "", "1"); 382 return std.mem.eql(u8, env orelse "", "1");
397 } 383 }
@@ -534,20 +520,9 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
534 return try srv.run(); 520 return try srv.run();
535 } 521 }
536 522
537 /// Ask once, print the first reply of the type asked for, exit. `dump` and 523 /// `askEndpointPort` is not folded in here: it waits under a deadline
538 /// `stats` are this same round-trip and differed only in the verb they 524 /// for a daemon too old for `endpoint_req`. This one blocks, so a
539 /// name, the frame they send and the frame they wait for. 525 /// wedge shows.
540 ///
541 /// Frames of other types are skipped rather than refused: the reply is the
542 /// answer to THIS request, and a daemon is free to have said something
543 /// else on the way to it.
544 ///
545 /// `askEndpointPort` below is deliberately not folded in here. It looks
546 /// like the same shape and is not: it waits under a deadline, because the
547 /// daemon it asks may be a binary from before `endpoint_req` existed and
548 /// answer nothing at all. This one has no such case — a daemon that
549 /// understands the socket understands both verbs — so it blocks on the
550 /// read and lets a wedged daemon be seen as a wedged daemon.
551 fn oneShotQuery( 526 fn oneShotQuery(
552 alloc: std.mem.Allocator, 527 alloc: std.mem.Allocator,
553 sock_path: []const u8, 528 sock_path: []const u8,
@@ -647,18 +622,13 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
647 const log_hint_len = std.fs.max_path_bytes + 64; 622 const log_hint_len = std.fs.max_path_bytes + 64;
648 623
649 /// The "where the rest of the story is" clause, or "" when there is no 624 /// The "where the rest of the story is" clause, or "" when there is no
650 /// path to name. 625 /// path to name. `stopCmd` and `reportNoListener` are read by someone
651 /// 626 /// who is not at that box.
652 /// Two reports end with it — this file's `stopCmd` and `reportNoListener`
653 /// — and both are about a daemon that is not doing what was asked while
654 /// the person reading the line is somewhere else. It was written twice,
655 /// with a comment saying so; this is that comment's other half.
656 /// 627 ///
657 /// The clause appears only when the path resolves: an absent HOME (a 628 /// Only when the path resolves: an absent HOME (a container, a systemd
658 /// container, a systemd unit) must not replace the finding that matters — 629 /// unit) must not replace the finding that matters with an error trace.
659 /// the daemon did not stop, the daemon has no listener — with an error 630 /// And the hedge stays in the words: a foreground `muxd run` logs to
660 /// trace. And the hedge stays in the words: a foreground `muxd run` logs 631 /// its own stderr, so naming the xdg path unconditionally would guess.
661 /// to its own stderr, so naming the xdg path unconditionally would guess.
662 fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 { 632 fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 {
663 const log = xdg.logPath(alloc) catch return ""; 633 const log = xdg.logPath(alloc) catch return "";
664 defer alloc.free(log); 634 defer alloc.free(log);
@@ -765,16 +735,10 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
765 return proxy.run(sock_path); 735 return proxy.run(sock_path);
766 } 736 }
767 737
768 /// The daemon is up and answering but has no QUIC listener to offer: no key 738 /// The daemon is up and answering but has no QUIC listener: no key it
769 /// it could load, a bind that failed, or a binary too old to know the verb. 739 /// could load, a bind that failed, or a binary too old to know the
770 /// 740 /// verb. The reason went to the daemon's log, on a box the reader is
771 /// This is the likeliest way the announce goes negative in production, and 741 /// not sitting at, so the line says where the rest is.
772 /// it must not be silent. The daemon wrote the actual reason to ITS log —
773 /// on a box the person reading this line is not sitting at — so the line
774 /// says what happened and where the rest of the story is.
775 ///
776 /// The log clause is `logHint`'s, conditions and hedge included — it is
777 /// the same clause `stopCmd` ends with, for the same reasons.
778 fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void { 742 fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void {
779 var hint: [log_hint_len]u8 = undefined; 743 var hint: [log_hint_len]u8 = undefined;
780 std.debug.print( 744 std.debug.print(
@@ -862,21 +826,9 @@ fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResult {
862 .{ .load_failed = .{ .path = path, .err = load_err } }; 826 .{ .load_failed = .{ .path = path, .err = load_err } };
863 } 827 }
864 828
865 /// One line, naming the reason and not just the verdict. It rides ssh's 829 /// One line naming the reason: it rides ssh's stderr to someone who is
866 /// stderr to someone who is not on that box: "no usable key" alone would 830 /// not on that box. The words are `quic.keyRefusalBody`'s, so a refusal
867 /// cost them the trip to go and find out which of the three it was. 831 /// reads the same however the daemon was asked.
868 ///
869 /// Word for word what `run` prints for the same refusals, path in the same
870 /// position — a key is rejected for the same reasons however the daemon
871 /// was asked. That is now a fact rather than a convention: the words are
872 /// `quic.keyRefusalBody`'s, and this half owns only the prefix and the
873 /// `; staying on ssh` that says what the refusal cost.
874 ///
875 /// Every error is handed over, catch-all included: `announceKeyFrom` only
876 /// reaches `load_failed` with what the load returned, so an unclassified
877 /// error here is still a key that would not read — which is what the
878 /// body's fourth sentence says. `run`'s arm above routes only the three
879 /// for the opposite reason: it can afford to let the rest propagate.
880 fn reportKeyRefusal(path: []const u8, err: anyerror) void { 832 fn reportKeyRefusal(path: []const u8, err: anyerror) void {
881 var buf: [quic.key_refusal_len]u8 = undefined; 833 var buf: [quic.key_refusal_len]u8 = undefined;
882 std.debug.print( 834 std.debug.print(
@@ -993,8 +945,8 @@ fn keygen(alloc: std.mem.Allocator) !u8 {
993 // mux_main.zig five invisible tests). 945 // mux_main.zig five invisible tests).
994 // --------------------------------------------------------------------------- 946 // ---------------------------------------------------------------------------
995 947
996 /// parseArgs takes what argsAlloc produces, so the tests must speak the same 948 /// The tests must speak argsAlloc's type: a slice of
997 /// type: a slice of sentinel-terminated strings. 949 /// sentinel-terminated strings.
998 fn parse(comptime argv: []const [:0]const u8) ParseResult { 950 fn parse(comptime argv: []const [:0]const u8) ParseResult {
999 return parseArgs(argv); 951 return parseArgs(argv);
1000 } 952 }
src/mux_main.zig
Old New
@@ -111,9 +111,8 @@ const agent_probe_ms = 500;
111 /// forwarded out and the real agent's reply comes back. 111 /// forwarded out and the real agent's reply comes back.
112 /// 112 ///
113 /// Fails open on silence, closed on a hangup. A refusal is immediate, so 113 /// Fails open on silence, closed on a hangup. A refusal is immediate, so
114 /// taking too long is not the discriminator; a hardware token or a 114 /// slowness is not the discriminator: a hardware token or a cold-started
115 /// cold-started gpg-agent may be slow and is still an agent, and refusing 115 /// gpg-agent is slow and is still an agent.
116 /// one would break a setup that works.
117 fn agentReachable(path: []const u8) bool { 116 fn agentReachable(path: []const u8) bool {
118 const fd = client.connectAgent(path) orelse return false; 117 const fd = client.connectAgent(path) orelse return false;
119 defer std.posix.close(fd); 118 defer std.posix.close(fd);
@@ -138,31 +137,13 @@ fn agentReachable(path: []const u8) bool {
138 /// it stays testable — and `main` has to use exactly the same name. 137 /// it stays testable — and `main` has to use exactly the same name.
139 pub const key_env = "MUX_KEY_FILE"; 138 pub const key_env = "MUX_KEY_FILE";
140 139
141 /// Told to the user, and the reason for it, when the attach it asked for 140 /// Built from `proto.session_env` so the message and the planter cannot
142 /// would be the session it is standing in. The variable it names to unset is 141 /// disagree about the spelling.
143 /// the one the daemon plants — `proto.session_env`, so the message and the
144 /// planter cannot disagree about the spelling.
145 const self_attach_refusal = 142 const self_attach_refusal =
146 "mux: this shell is inside that session (unset " ++ proto.session_env ++ " to override)\n"; 143 "mux: this shell is inside that session (unset " ++ proto.session_env ++ " to override)\n";
147 144
148 /// Would attaching to `sock`+`session` land on the session this process is 145 /// An inner client repaints its own grid forever, and no chord steers
149 /// already running inside? An inner client repaints its own grid — paint → 146 /// back out. Refused before it starts.
150 /// delta → repaint — and takes the alt screen and every keystroke with it,
151 /// and since Ctrl-\ became a prefix the OUTER keyboard cannot steer it back
152 /// out. So the loop has to be refused before it starts; there is no escape
153 /// chord to offer instead.
154 ///
155 /// Only the true self-pair: attaching from inside session 0 to session 1 of
156 /// the same daemon is useful and does not feed back, so both halves must
157 /// match. A unix socket path only — a host or quic:// target is a different
158 /// daemon whatever its sessions are called.
159 ///
160 /// String equality on the socket path, deliberately: the daemon planted the
161 /// canonical path it bound, so a symlinked or relatively-spelled `--sock`
162 /// argument for the same socket evades this. Accepted — the check is a
163 /// guard against the mistake people actually make (typing `mux` in a mux
164 /// shell), not a security boundary, and stat-ing a path here would cost a
165 /// syscall on every attach to catch a spelling nobody types.
166 fn insideThisSession( 147 fn insideThisSession(
167 env_sock: ?[]const u8, 148 env_sock: ?[]const u8,
168 env_session: ?[]const u8, 149 env_session: ?[]const u8,
@@ -171,11 +152,18 @@ fn insideThisSession(
171 ) bool { 152 ) bool {
172 const es = env_sock orelse return false; 153 const es = env_sock orelse return false;
173 const en = env_session orelse return false; 154 const en = env_session orelse return false;
155 // A unix socket path only: a host or quic:// target is a different
156 // daemon whatever its sessions are called.
174 const target = sock orelse return false; 157 const target = sock orelse return false;
175 // Emptied counts as unset: `MUX_SESSION=` is how a shell overrides an 158 // Emptied counts as unset: `MUX_SESSION=` is how a shell overrides an
176 // exported variable it cannot unset, and the refusal names unsetting as 159 // exported variable it cannot unset, and the refusal names unsetting as
177 // the way out — both spellings of that have to work. 160 // the way out — both spellings of that have to work.
178 if (es.len == 0 or en.len == 0) return false; 161 if (es.len == 0 or en.len == 0) return false;
162 // Both halves, so session 0 attaching to session 1 of the same daemon
163 // keeps working. String equality on the path: a symlinked or relatively
164 // spelled `--sock` for the same socket evades this, accepted, because
165 // this guards the mistake people make (typing `mux` in a mux shell) and
166 // is not a security boundary.
179 return std.mem.eql(u8, es, target) and 167 return std.mem.eql(u8, es, target) and
180 std.mem.eql(u8, en, proto.resolveName(session)); 168 std.mem.eql(u8, en, proto.resolveName(session));
181 } 169 }
@@ -581,29 +569,7 @@ fn spellingReason(err: anyerror) []const u8 {
581 }; 569 };
582 } 570 }
583 571
584 /// `mux wall add SPELLING...` / `mux wall rm SPELLING...`: the scripting 572 /// `mux wall add|rm SPELLING...`: file operations only, neither verb dials.
585 /// face of the two things `x` and an attach do to the wall file.
586 ///
587 /// FILE OPERATIONS ONLY — neither verb dials, resolves a key or spawns
588 /// ssh. `add` is how a tile reaches the wall without attaching to it, and
589 /// `rm` is `x` for a script; "remove is detach" holds for both, since
590 /// neither says anything to a daemon at all.
591 ///
592 /// Every spelling is validated BEFORE any of them is written, and the file
593 /// is written ONCE: the whole edit is built in memory and saved in a single
594 /// atomic rename. Per-spelling saves made the promise a half-truth — a bad
595 /// line was caught before anything moved, but an IO error on the third of
596 /// four left the first two applied and the rest not, which is exactly the
597 /// partial state the validation pass exists to prevent.
598 ///
599 /// `rm` reads the file leniently (`wall.loadLines`), so a hand-edited line
600 /// the grammar cannot parse can still be removed and the others survive it
601 /// verbatim. `add` reads strictly (`wall.load`): growing a wall whose
602 /// existing content is not understood would re-save garbage as if it had
603 /// been read.
604 /// Everything allocates from the arena `wallMain` already holds: one edit,
605 /// one process, freed by its deinit. There is no second allocator here
606 /// because there is nothing long-lived to own.
607 fn wallEdit( 573 fn wallEdit(
608 arena: std.mem.Allocator, 574 arena: std.mem.Allocator,
609 verb: []const u8, 575 verb: []const u8,
@@ -633,6 +599,9 @@ fn wallEdit(
633 return 2; 599 return 2;
634 } 600 }
635 601
602 // Every spelling is validated BEFORE any of them is written, and the
603 // file is written ONCE below: an IO error on the third of four must not
604 // leave the first two applied and the rest not.
636 if (adding) for (spellings.items) |s| { 605 if (adding) for (spellings.items) |s| {
637 // The grammar's own refusals, plus the one refusal that belongs to 606 // The grammar's own refusals, plus the one refusal that belongs to
638 // the transport rather than the grammar: a sun_path that cannot be 607 // the transport rather than the grammar: a sun_path that cannot be
@@ -652,6 +621,8 @@ fn wallEdit(
652 var rc: u8 = 0; 621 var rc: u8 = 0;
653 622
654 if (adding) { 623 if (adding) {
624 // Strict: growing a wall whose existing content is not understood
625 // would re-save garbage as if it had been read.
655 var w = wall.load(arena, path) catch |err| { 626 var w = wall.load(arena, path) catch |err| {
656 std.debug.print("mux: wall add: {s}: {s}\n", .{ path, @errorName(err) }); 627 std.debug.print("mux: wall add: {s}: {s}\n", .{ path, @errorName(err) });
657 return 1; 628 return 1;
@@ -672,6 +643,8 @@ fn wallEdit(
672 return 0; 643 return 0;
673 } 644 }
674 645
646 // Lenient, so a hand-edited line the grammar cannot parse can still be
647 // removed and the others survive it verbatim.
675 var lines = wall.loadLines(arena, path) catch |err| { 648 var lines = wall.loadLines(arena, path) catch |err| {
676 std.debug.print("mux: wall rm: {s}: {s}\n", .{ path, @errorName(err) }); 649 std.debug.print("mux: wall rm: {s}: {s}\n", .{ path, @errorName(err) });
677 return 1; 650 return 1;
@@ -700,8 +673,7 @@ fn wallEdit(
700 return rc; 673 return rc;
701 } 674 }
702 675
703 /// Test helper: parseArgs takes what argsAlloc produces, so the tests have to 676 /// parseArgs takes what argsAlloc produces; the tests must match the type.
704 /// speak the same type — a slice of sentinel-terminated strings.
705 fn parse(comptime argv: []const [:0]const u8) ParseResult { 677 fn parse(comptime argv: []const [:0]const u8) ParseResult {
706 return parseArgs(argv, null); 678 return parseArgs(argv, null);
707 } 679 }
src/muxa.zig
Old New
@@ -302,9 +302,7 @@ const Quic = struct {
302 }; 302 };
303 303
304 const Conn = struct { 304 const Conn = struct {
305 /// Which transport carries the frames. The verbs above this line are 305 /// Verbs are transport-blind; `--quic` chooses here.
306 /// written once and know nothing about the difference — that is the
307 /// claim `--quic` makes, and this union is where it is kept.
308 link: union(enum) { 306 link: union(enum) {
309 fd: std.posix.fd_t, 307 fd: std.posix.fd_t,
310 quic: Quic, 308 quic: Quic,
@@ -338,12 +336,8 @@ const Conn = struct {
338 return .{ .link = .{ .fd = s.handle }, .alloc = alloc }; 336 return .{ .link = .{ .fd = s.handle }, .alloc = alloc };
339 } 337 }
340 338
341 /// Dial a daemon's QUIC listener and wait out the handshake before 339 /// A `send` before the stream exists takes zero bytes, so the frame
342 /// returning. The wait is not optional and not the caller's: `connect` 340 /// would silently never leave.
343 /// only creates state — the first flight has not been answered — and a
344 /// `send` on a connection with no stream yet accepts zero bytes and
345 /// says so by returning 0, which would surface as a frame that
346 /// silently never left. Same reason client.zig's quicTransport waits.
347 fn openQuic( 341 fn openQuic(
348 alloc: std.mem.Allocator, 342 alloc: std.mem.Allocator,
349 addr: std.net.Address, 343 addr: std.net.Address,
@@ -374,23 +368,8 @@ const Conn = struct {
374 } 368 }
375 } 369 }
376 370
377 /// How much longer than the daemon this client waits for an await, 371 /// QUIC widens the grace: the daemon's window opens a flight after
378 /// which over a network is a function of how far away the daemon is. 372 /// ours. The cap bounds a slow handshake.
379 ///
380 /// The unix arm keeps the flat 2s (see await_grace_ms). The QUIC arm
381 /// adds nothing until four round trips of its own handshake exceed
382 /// that, which on a LAN or loopback is never and on a 200ms link is
383 /// most of a second: the daemon's timeout window opens when it READS
384 /// the request, a whole flight after this process started counting,
385 /// and closes a flight before the reply lands. Four, not two, because
386 /// the request and the reply are not the only flights in the trip —
387 /// the daemon may be settling a command when the timeout fires.
388 ///
389 /// Capped, because `connect_ms` is bounded only by the handshake wait:
390 /// a connection that took fifteen seconds to come up would otherwise
391 /// buy a minute of grace, and past this cap we are no longer waiting
392 /// for the daemon's answer but for a network that has already shown it
393 /// cannot carry one.
394 fn graceMs(self: *const Conn) i64 { 373 fn graceMs(self: *const Conn) i64 {
395 return switch (self.link) { 374 return switch (self.link) {
396 .fd => await_grace_ms, 375 .fd => await_grace_ms,
@@ -398,11 +377,10 @@ const Conn = struct {
398 }; 377 };
399 } 378 }
400 379
401 /// `deadline_ms` is the caller's own bound, the same one it will wait 380 /// The socket arm ignores `deadline_ms` — a local write either takes
402 /// for the answer under. The socket arm ignores it — a local write 381 /// the bytes or fails. The QUIC arm gives it precedence over
403 /// either takes the bytes or fails — and the QUIC arm gives it 382 /// `send_flush_ms`, so a verb asked for a 100ms answer cannot spend
404 /// precedence over `send_flush_ms`, so a verb asked for a 100ms answer 383 /// five seconds sending.
405 /// cannot spend five seconds getting its question out.
406 fn sendFrame(self: *Conn, t: proto.MsgType, payload: []const u8, deadline_ms: i64) !void { 384 fn sendFrame(self: *Conn, t: proto.MsgType, payload: []const u8, deadline_ms: i64) !void {
407 switch (self.link) { 385 switch (self.link) {
408 .fd => |fd| try proto.writeFrame(fd, t, payload), 386 .fd => |fd| try proto.writeFrame(fd, t, payload),
@@ -410,20 +388,8 @@ const Conn = struct {
410 } 388 }
411 } 389 }
412 390
413 /// The frame's wire bytes into the egress ring, all of them. 391 /// A half-written frame reads as a corrupt stream, so the short take
414 /// 392 /// is re-offered.
415 /// `send` takes what fits and reports how much (a bounded ring: the
416 /// caller holds the backlog), so a short take is not a failure and not
417 /// ignorable either — the tail is offered again once acks have made
418 /// room. muxa's frames are a handful of bytes against a 256KB ring, so
419 /// this loop is expected never to turn twice; it is here because the
420 /// alternative to looping is a frame that leaves half-written, which
421 /// the peer reads as a corrupt stream rather than as an error.
422 ///
423 /// Bounded by whichever comes first, the caller's deadline or
424 /// `send_flush_ms`: the caller's, so a send cannot overshoot the answer
425 /// it is part of, and the flush cap so that an UNBOUNDED caller
426 /// (`--timeout 0`) still cannot wait here forever.
427 fn sendFrameQuic( 393 fn sendFrameQuic(
428 self: *Conn, 394 self: *Conn,
429 t: proto.MsgType, 395 t: proto.MsgType,
@@ -435,6 +401,8 @@ const Conn = struct {
435 try proto.appendFrame(&buf, self.alloc, t, payload); 401 try proto.appendFrame(&buf, self.alloc, t, payload);
436 402
437 const q = &self.link.quic; 403 const q = &self.link.quic;
404 // The flush cap is what bounds an UNBOUNDED caller (`--timeout 0`);
405 // the caller's own deadline bounds every other one.
438 const deadline = @min(deadline_ms, std.time.milliTimestamp() + send_flush_ms); 406 const deadline = @min(deadline_ms, std.time.milliTimestamp() + send_flush_ms);
439 var off: usize = 0; 407 var off: usize = 0;
440 while (off < buf.items.len) { 408 while (off < buf.items.len) {
@@ -452,19 +420,10 @@ const Conn = struct {
452 } 420 }
453 } 421 }
454 422
455 /// Read frames until one of type `want` arrives (snapshots, deltas and 423 /// Snapshots and deltas stream past an attached client, so unwanted
456 /// pushes stream past an attached client; skip what we did not ask 424 /// frames are skipped. `exit_status` ends the wait instead: the reply
457 /// for). Bounded by `deadline_ms` wall time via poll. 425 /// is never coming, and the session ending is an answer, not a
458 /// 426 /// transport failure.
459 /// `exit_status` is the one skipped frame that ends the wait instead:
460 /// the reply we are waiting for is never coming, and the reason is an
461 /// answer — the session ran its last command — not a transport
462 /// failure. Callers get error.SessionExited plus `session_exit`.
463 ///
464 /// The returned frame is allocated from this Conn's own allocator, so
465 /// `frame.deinit` takes that one. Every caller was already passing it —
466 /// there is one allocator in this process — and asking for it made the
467 /// pairing look like a choice.
468 fn awaitFrame(self: *Conn, want: proto.MsgType, deadline_ms: i64) !proto.Frame { 427 fn awaitFrame(self: *Conn, want: proto.MsgType, deadline_ms: i64) !proto.Frame {
469 return switch (self.link) { 428 return switch (self.link) {
470 .fd => self.awaitFrameFd(self.alloc, want, deadline_ms), 429 .fd => self.awaitFrameFd(self.alloc, want, deadline_ms),
@@ -472,16 +431,8 @@ const Conn = struct {
472 }; 431 };
473 } 432 }
474 433
475 /// Debt, deliberately retained on THIS arm: only the wait is 434 /// Deadline-bounded wait, unbounded read: harmless where a stall
476 /// deadline-bounded, not the read. Once poll says a frame has begun, 435 /// means a dead daemon.
477 /// readFrame's readExact blocks until the whole payload lands, so a
478 /// peer that stalls mid-frame outlives the deadline. That is harmless
479 /// over a local socket, where the daemon writes whole frames at once
480 /// and a stall means a daemon that has stopped running rather than a
481 /// path that has stopped delivering — and buying it off would mean a
482 /// second partial-frame buffer for a case that cannot happen here.
483 /// The QUIC arm below, where a network IS under the transport, does
484 /// not have the luxury and does not take it.
485 fn awaitFrameFd( 436 fn awaitFrameFd(
486 self: *Conn, 437 self: *Conn,
487 alloc: std.mem.Allocator, 438 alloc: std.mem.Allocator,
@@ -509,18 +460,8 @@ const Conn = struct {
509 } 460 }
510 } 461 }
511 462
512 /// The same wait with a network under it, and the difference is that 463 /// Drain the buffer before checking `dead`, or bytes that arrived
513 /// NOTHING here blocks on the transport: a datagram carries whatever 464 /// first are lost.
514 /// arrived, whole frames or a third of one, so the frames are
515 /// delimited out of the client's inbound buffer and a partial tail
516 /// simply stays there until the rest lands. A daemon that stops
517 /// mid-frame costs this loop the deadline it was given and not a
518 /// second more.
519 ///
520 /// Every buffered frame is taken before the next poll — a datagram
521 /// routinely carries several, and the reply may be the second — and
522 /// `dead` is checked only once the buffer is empty, so bytes that
523 /// arrived before the connection died are still delivered.
524 fn awaitFrameQuic( 465 fn awaitFrameQuic(
525 self: *Conn, 466 self: *Conn,
526 alloc: std.mem.Allocator, 467 alloc: std.mem.Allocator,
@@ -557,18 +498,10 @@ const Conn = struct {
557 } 498 }
558 } 499 }
559 500
560 /// Redial the same coordinates and hand the connection over. The old 501 /// `connect_ms` deliberately keeps the FIRST handshake's measurement:
561 /// client is torn down only once the new one is up, so a redial that 502 /// what a reader wants is the distance to the daemon, not the cost of
562 /// fails leaves this Conn holding a live (if dead-ended) client rather 503 /// a redial made while the path was still coming back. Nothing reads
563 /// than a freed one — `close` runs either way. 504 /// it after this point anyway.
564 ///
565 /// `connect_ms` deliberately keeps the FIRST handshake's measurement
566 /// rather than taking this one's. Nothing reads `graceMs` after this
567 /// point — the await it belongs to already has its deadline, and the
568 /// re-issue continues that same deadline — so refreshing it would be
569 /// bookkeeping with no reader, and the reader it might one day have
570 /// wants the distance to the daemon, not the cost of a redial made
571 /// while the path was still coming back.
572 fn reconnect(self: *Conn, deadline_ms: i64) !void { 505 fn reconnect(self: *Conn, deadline_ms: i64) !void {
573 const q = &self.link.quic; 506 const q = &self.link.quic;
574 const cl = try quic_client.Client.connect(self.alloc, q.addr, q.key, q.idle_ms); 507 const cl = try quic_client.Client.connect(self.alloc, q.addr, q.key, q.idle_ms);
@@ -580,12 +513,7 @@ const Conn = struct {
580 } 513 }
581 }; 514 };
582 515
583 /// One OWNED frame delimited out of `buf`, and how many bytes of it that 516 /// Copies: the caller consumes the bytes it points at.
584 /// took. The arithmetic is `proto.delimitFrame`'s — the same walk the
585 /// daemon does over the same wire from the other end — and what this adds
586 /// is the copy: the caller consumes the bytes out of the client's inbound
587 /// buffer immediately, so a payload still pointing into it would be a
588 /// slice into memory about to be shifted.
589 fn frameFrom( 517 fn frameFrom(
590 alloc: std.mem.Allocator, 518 alloc: std.mem.Allocator,
591 buf: []const u8, 519 buf: []const u8,
@@ -856,16 +784,8 @@ test "awaitFrame ends a wait on exit_status, keeping the code" {
856 /// error whose 2 promises stdout was left empty on purpose. 784 /// error whose 2 promises stdout was left empty on purpose.
857 const write_failed_code: u8 = 4; 785 const write_failed_code: u8 = 4;
858 786
859 /// Print the invocation's one JSON object and hand back the exit code that 787 /// A write that fails must not exit 0: an agent checks the status,
860 /// goes with it — `ok` if the object landed, `write_failed_code` if it did 788 /// then has no object.
861 /// not.
862 ///
863 /// The failure this exists for is exit 0 with nothing on stdout, which is
864 /// the single shape the contract says cannot happen and the worst one to
865 /// hand an agent: its shell tool checks the status first, sees success, and
866 /// then has no object to parse. A closed pipe (the agent's harness stopped
867 /// reading) or a full filesystem is enough to produce it, and swallowing
868 /// the write error turned both into a silent success.
869 fn emit(json: []const u8, ok: u8) u8 { 789 fn emit(json: []const u8, ok: u8) u8 {
870 return emitTo(std.posix.STDOUT_FILENO, json, ok); 790 return emitTo(std.posix.STDOUT_FILENO, json, ok);
871 } 791 }
@@ -931,11 +851,9 @@ fn fail(msg: []const u8, detail: []const u8) u8 {
931 return emit(fbs.getWritten(), 1); 851 return emit(fbs.getWritten(), 1);
932 } 852 }
933 853
934 /// `fail` for a message whose verb prefix is only known at runtime, which 854 /// `fail` for a verb prefix known only at runtime — every failure in
935 /// is every failure in the shared await/run pipeline. Produces exactly the 855 /// the shared await/run pipeline. A message too long to prefix falls
936 /// `"<verb>: <what>"` the two verbs printed when they were written out 856 /// back to the unprefixed one rather than losing the failure.
937 /// separately; a message too long to prefix falls back to the unprefixed
938 /// one rather than losing the failure.
939 fn failAs(who: []const u8, msg: []const u8, detail: []const u8) u8 { 857 fn failAs(who: []const u8, msg: []const u8, detail: []const u8) u8 {
940 var buf: [256]u8 = undefined; 858 var buf: [256]u8 = undefined;
941 const joined = std.fmt.bufPrint(&buf, "{s}: {s}", .{ who, msg }) catch msg; 859 const joined = std.fmt.bufPrint(&buf, "{s}: {s}", .{ who, msg }) catch msg;
@@ -950,19 +868,16 @@ fn writeError(writer: anytype, msg: []const u8, detail: []const u8) !void {
950 try writer.writeAll("}\n"); 868 try writer.writeAll("}\n");
951 } 869 }
952 870
953 /// The wall-clock instant a round trip gives up at. `--timeout 0` means 871 /// `--timeout 0` means no bound here as everywhere else (AwaitReq): a
954 /// "no bound at all" everywhere else in this protocol (AwaitReq spells it 872 /// past deadline would fail instantly instead of waiting forever.
955 /// out), so it means that here too — the alternative reading, a deadline
956 /// already in the past, would make `--timeout 0` fail instantly instead of
957 /// waiting forever, which is the opposite of what it asks for.
958 fn deadlineFor(timeout_ms: u32) i64 { 873 fn deadlineFor(timeout_ms: u32) i64 {
959 if (timeout_ms == 0) return std.math.maxInt(i64); 874 if (timeout_ms == 0) return std.math.maxInt(i64);
960 return std.time.milliTimestamp() + timeout_ms; 875 return std.time.milliTimestamp() + timeout_ms;
961 } 876 }
962 877
963 /// The session ended under us: JSON, like every other outcome, but on the 878 /// The session ended under us: JSON, but on the failure path — the
964 /// failure path — the verb that asked (status, capture, send) has no answer 879 /// verb that asked (status, capture, send) has no answer to give.
965 /// to give. `run` and `await` do have one and print it themselves. 880 /// `run` and `await` do have one and print it themselves.
966 fn failSessionEnded(code: ?u8) u8 { 881 fn failSessionEnded(code: ?u8) u8 {
967 var buf: [192]u8 = undefined; 882 var buf: [192]u8 = undefined;
968 var fbs = std.io.fixedBufferStream(&buf); 883 var fbs = std.io.fixedBufferStream(&buf);
@@ -1159,9 +1074,8 @@ fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, session: []const u8, deadli
1159 return emit(out.items, 0); 1074 return emit(out.items, 0);
1160 } 1075 }
1161 1076
1162 /// A command that has not returned — or one whose mechanism cannot know a 1077 /// A command that has not returned, or whose mechanism cannot know a
1163 /// code — has no exit code, and JSON null is the honest spelling: 0 would 1078 /// code, has no exit code: 0 would read as "succeeded".
1164 /// read as "succeeded".
1165 fn writeExitCode(writer: anytype, code: ?u8) !void { 1079 fn writeExitCode(writer: anytype, code: ?u8) !void {
1166 if (code) |c| { 1080 if (code) |c| {
1167 try writer.print("{d}", .{c}); 1081 try writer.print("{d}", .{c});
@@ -1170,13 +1084,12 @@ fn writeExitCode(writer: anytype, code: ?u8) !void {
1170 } 1084 }
1171 } 1085 }
1172 1086
1173 /// The five CmdState fields `status` and `await`/`run` both publish, in the 1087 /// The five CmdState fields `status` and `await`/`run` both publish.
1174 /// one order both have always used. Written as a bare fragment — no braces, 1088 /// Written as a bare fragment — no braces, no leading or trailing
1175 /// no leading or trailing comma — because the two verbs nest it 1089 /// comma — because the two verbs nest it differently: `status` puts
1176 /// differently: `status` puts it inside a `"cmd"` object and follows it 1090 /// it inside a `"cmd"` object and follows it with the seq, while
1177 /// with the seq, while `await` inlines it at the top level and follows it 1091 /// `await` inlines it at the top level and follows it with the
1178 /// with the duration. Each verb keeps its own envelope; what they stopped 1092 /// duration.
1179 /// keeping is a second spelling of the fields inside it.
1180 fn writeCmdFields(writer: anytype, st: proto.CmdState) !void { 1093 fn writeCmdFields(writer: anytype, st: proto.CmdState) !void {
1181 try writer.writeAll("\"phase\":"); 1094 try writer.writeAll("\"phase\":");
1182 try jsonEscape(writer, @tagName(st.phase)); 1095 try jsonEscape(writer, @tagName(st.phase));
@@ -1247,14 +1160,10 @@ fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, session: []const
1247 return emit(out.items, 0); 1160 return emit(out.items, 0);
1248 } 1161 }
1249 1162
1250 /// Join the session claiming NO grid. applySize refuses anything under 2, 1163 /// Join claiming NO grid: applySize refuses under 2, so the 0x0 slot
1251 /// so the slot stays 0x0 and makes no claim in claimGrid: the human's 1164 /// makes no claim and no human's terminal is resized. `name` is
1252 /// terminal must never be resized because an agent connected. 1165 /// joins-only: a 0x0 attach cannot create, resolveSession demands a
1253 /// 1166 /// real size.
1254 /// `name` is joins-only by construction, not by a separate check here: a
1255 /// 0x0 attach can never create (resolveSession demands a real size), so
1256 /// naming a session that does not exist is simply refused — this binary
1257 /// never spawns a shell by asking about one.
1258 fn attachZero(conn: *Conn, name: []const u8, deadline: i64) !void { 1167 fn attachZero(conn: *Conn, name: []const u8, deadline: i64) !void {
1259 var buf: [proto.attach_max_len]u8 = undefined; 1168 var buf: [proto.attach_max_len]u8 = undefined;
1260 try conn.sendFrame(.attach, proto.encodeAttachNamed(&buf, 0, 0, 0, 0, name), deadline); 1169 try conn.sendFrame(.attach, proto.encodeAttachNamed(&buf, 0, 0, 0, 0, name), deadline);
@@ -1329,14 +1238,7 @@ const send_flush_ms = 5_000;
1329 /// answers promptly or is not coming. 1238 /// answers promptly or is not coming.
1330 const span_fetch_ms = 2_000; 1239 const span_fetch_ms = 2_000;
1331 1240
1332 /// The instant the span fetch gives up. Takes NO run deadline, and the 1241 /// No run deadline: `--timeout 0` would make the fetch unbounded.
1333 /// missing parameter is the point: the run's bound is not an input to this
1334 /// one in either direction. Widening to the larger of the two handed a
1335 /// `--timeout 0` run an unbounded fetch — muxa hanging forever on a daemon
1336 /// that went quiet, long after the answer the agent actually asked for was
1337 /// in hand — and narrowing to the smaller would cut off exactly the
1338 /// transcript span_fetch_ms exists to rescue, the one belonging to a
1339 /// command that returned in the last millisecond of the window.
1340 fn spanFetchDeadline() i64 { 1242 fn spanFetchDeadline() i64 {
1341 return deadlineFor(span_fetch_ms); 1243 return deadlineFor(span_fetch_ms);
1342 } 1244 }
@@ -1372,55 +1274,8 @@ fn doAwait(
1372 return try proto.decodeAwaitReply(frame.payload); 1274 return try proto.decodeAwaitReply(frame.payload);
1373 } 1275 }
1374 1276
1375 /// The await, plus the ONE reconnect this client is willing to spend on it. 1277 /// At-most-once: the re-issue re-sends the attach and the request,
1376 /// 1278 /// never `run`'s input.
1377 /// A wait is the only round trip long enough for a network to die under —
1378 /// a status round trip is over in a millisecond, a `run` on a build is not
1379 /// — and losing it costs an agent the whole command it was watching, so
1380 /// this is the one place a transport failure is retried rather than
1381 /// reported. What makes the retry safe rather than a second command is
1382 /// `since_seq`: the request is a question about a watermark ("tell me
1383 /// about a return newer than this"), so re-asking it after a reconnect is
1384 /// the SAME question and the daemon answers it identically whether or not
1385 /// it saw the first one. The server's own tests pin that idempotency.
1386 ///
1387 /// Three things are deliberately not reset:
1388 ///
1389 /// * the deadline, which is the caller's whole bound and continues
1390 /// across the reconnect — a redial that ate four seconds has spent
1391 /// four seconds of the wait, not bought a fresh one;
1392 /// * `since_seq`, for the reason above — re-reading the watermark from
1393 /// the new connection would move it past a return that had happened
1394 /// while we were disconnected, and the await would then sit waiting
1395 /// for one that already went by;
1396 /// * the attach, which IS re-sent, at 0x0 like every other attach this
1397 /// binary makes: the daemon dropped our old client slot with the
1398 /// connection and would have no session to answer about otherwise.
1399 ///
1400 /// Once, and once per process rather than per await: a loop here would be
1401 /// a client that hides a daemon that is gone, and the agent driving it
1402 /// asked a question that deserves an answer within the deadline it named.
1403 ///
1404 /// What is re-sent is the attach and the await_req, and NOTHING else —
1405 /// specifically never `run`'s input. That is what keeps this at-most-once
1406 /// rather than at-least-once: if the command line was lost with the
1407 /// connection, the re-issued await finds no return, and the agent is told
1408 /// `timeout` — which is true and checkable — instead of the shell running
1409 /// `make deploy` a second time because a client decided to be helpful.
1410 /// A wait may be repeated because asking twice changes nothing; an input
1411 /// may not, because it changes everything.
1412 ///
1413 /// `ConnectionLost` is the ONLY error that reconnects, and the asymmetry
1414 /// with `SendStalled` is deliberate. A stall means the peer is still there
1415 /// but has stopped acknowledging a quarter-megabyte of backlog — it has
1416 /// already spent the flush bound proving that, and at muxa's frame sizes
1417 /// it is very nearly unreachable — so a redial would be a second guess
1418 /// about a connection that never said it was gone.
1419 ///
1420 /// A redial that fails does not swallow the reason: it is recorded on the
1421 /// Conn and `ConnectionLost` is re-raised, so the verb reports what went
1422 /// wrong FIRST (the path tore) and what went wrong second (the redial),
1423 /// rather than only the second. See `Conn.reconnect_failure`.
1424 fn awaitReissuing( 1279 fn awaitReissuing(
1425 alloc: std.mem.Allocator, 1280 alloc: std.mem.Allocator,
1426 conn: *Conn, 1281 conn: *Conn,
@@ -1429,8 +1284,16 @@ fn awaitReissuing(
1429 deadline: i64, 1284 deadline: i64,
1430 ) !proto.AwaitReply { 1285 ) !proto.AwaitReply {
1431 return doAwait(alloc, conn, o, since_seq, deadline) catch |e| switch (e) { 1286 return doAwait(alloc, conn, o, since_seq, deadline) catch |e| switch (e) {
1287 // `SendStalled` never redials: the peer is still there, it has
1288 // just stopped acking, so a redial would be a second guess about
1289 // a connection that never said it was gone.
1432 error.ConnectionLost => { 1290 error.ConnectionLost => {
1291 // Once per process, not per await: a loop here is a client
1292 // that hides a daemon that is gone.
1433 if (conn.link != .quic or conn.link.quic.reconnected) return e; 1293 if (conn.link != .quic or conn.link.quic.reconnected) return e;
1294 // The deadline continues across the redial — four seconds
1295 // spent redialling are four seconds of the caller's wait, not
1296 // a fresh bound.
1434 conn.reconnect(deadline) catch |redial| { 1297 conn.reconnect(deadline) catch |redial| {
1435 conn.reconnect_failure = @errorName(redial); 1298 conn.reconnect_failure = @errorName(redial);
1436 return e; 1299 return e;
@@ -1443,6 +1306,10 @@ fn awaitReissuing(
1443 conn.reconnect_failure = @errorName(reattach); 1306 conn.reconnect_failure = @errorName(reattach);
1444 return e; 1307 return e;
1445 }; 1308 };
1309 // The SAME `since_seq`, re-read from nothing: the request is a
1310 // question about a watermark, so re-asking it is idempotent,
1311 // while a watermark taken from the new connection would sit
1312 // past a return that happened while we were disconnected.
1446 return doAwait(alloc, conn, o, since_seq, deadline); 1313 return doAwait(alloc, conn, o, since_seq, deadline);
1447 }, 1314 },
1448 else => e, 1315 else => e,
@@ -1450,13 +1317,14 @@ fn awaitReissuing(
1450 } 1317 }
1451 1318
1452 /// What a wait that ended without a reply says past the verb's own 1319 /// What a wait that ended without a reply says past the verb's own
1453 /// "no reply". Every error but one is its own name, exactly as before — 1320 /// "no reply". Every error but one is its own name — the socket
1454 /// the socket arm's failures are untouched — because `ConnectionLost` is 1321 /// arm's failures are untouched — because `ConnectionLost` is the
1455 /// the only one whose name is half the story. 1322 /// only one whose name is half the story.
1456 /// 1323 ///
1457 /// The three endings a lost connection has, and they are worth telling 1324 /// The three endings a lost connection has, and they are worth
1458 /// apart: the redial failed (why), the redial had already been spent (so 1325 /// telling apart: the redial failed (why), the redial had already
1459 /// this is the second tear of the same wait), or nothing tried to redial. 1326 /// been spent (so this is the second tear of the same wait), or
1327 /// nothing tried to redial.
1460 fn waitFailDetail(buf: []u8, conn: *const Conn, e: anyerror) []const u8 { 1328 fn waitFailDetail(buf: []u8, conn: *const Conn, e: anyerror) []const u8 {
1461 if (e != error.ConnectionLost) return @errorName(e); 1329 if (e != error.ConnectionLost) return @errorName(e);
1462 if (conn.reconnect_failure) |why| { 1330 if (conn.reconnect_failure) |why| {
@@ -1536,15 +1404,8 @@ test "stripSgr leaves text, drops SGR and OSC" {
1536 try std.testing.expectEqualStrings("red ok\nplain", got); 1404 try std.testing.expectEqualStrings("red ok\nplain", got);
1537 } 1405 }
1538 1406
1539 /// The rows a command occupied, as plain text. `end_row` is the row the D 1407 /// Rows go stale between reply and fetch, so failure here is a null
1540 /// mark landed on — the prompt redraw — so the span is [start_row, end_row) 1408 /// output, not a failed run.
1541 /// and an end at or before the start is simply no output.
1542 ///
1543 /// Rows are absolute screen rows and best-effort by construction (see
1544 /// MarkEvent.row): the alt screen and scrollback pruning can invalidate
1545 /// them between the reply and this fetch. Every failure mode here is
1546 /// therefore a null output, never a failed run — the exit code is the
1547 /// answer, and the transcript is the bonus.
1548 fn fetchSpan( 1409 fn fetchSpan(
1549 alloc: std.mem.Allocator, 1410 alloc: std.mem.Allocator,
1550 conn: *Conn, 1411 conn: *Conn,
@@ -1562,10 +1423,8 @@ fn fetchSpan(
1562 return try stripSgr(alloc, frame.payload[6..]); 1423 return try stripSgr(alloc, frame.payload[6..]);
1563 } 1424 }
1564 1425
1565 /// One JSON object: what ended the wait, what the session's command state 1426 /// `output` is absent when there is no transcript: an absent key and
1566 /// was when it ended, and how long we waited. `output` is present only when 1427 /// an empty string differ.
1567 /// there is a transcript to give — an absent key and an empty string are
1568 /// different answers.
1569 fn printAwaitReply( 1428 fn printAwaitReply(
1570 writer: anytype, 1429 writer: anytype,
1571 r: proto.AwaitReply, 1430 r: proto.AwaitReply,
@@ -1622,11 +1481,8 @@ fn printSessionEnded(writer: anytype, code: ?u8, duration_ms: i64) !void {
1622 try writer.print(",\"duration_ms\":{d}}}\n", .{duration_ms}); 1481 try writer.print(",\"duration_ms\":{d}}}\n", .{duration_ms});
1623 } 1482 }
1624 1483
1625 /// Print an await outcome and choose the exit code for it. A timeout is the 1484 /// Timeout is the only nonzero code: a command returning nonzero
1626 /// only nonzero one: the agent asked a question and got "still running", 1485 /// failed in `exit_code`, not here.
1627 /// which is a distinct thing to branch on, while `returned` and `settled`
1628 /// are both answers — including a command that returned nonzero, whose
1629 /// failure is in `exit_code`, not in muxa's.
1630 fn reportAwait( 1486 fn reportAwait(
1631 alloc: std.mem.Allocator, 1487 alloc: std.mem.Allocator,
1632 r: proto.AwaitReply, 1488 r: proto.AwaitReply,
@@ -1646,12 +1502,8 @@ fn reportSessionEnded(alloc: std.mem.Allocator, code: ?u8, duration_ms: i64) !u8
1646 return emit(out.items, 0); 1502 return emit(out.items, 0);
1647 } 1503 }
1648 1504
1649 /// `await` and `run` are one pipeline: attach claiming no grid, read the 1505 /// `run` is `await` with a command line put in: `cmdline` non-null is
1650 /// watermark, wait for the session to come to rest, report. `run` is that 1506 /// the whole difference.
1651 /// pipeline with a command line put in — the line is sent between the
1652 /// watermark and the wait, and the marks span is fetched at the end — so
1653 /// `cmdline` being non-null IS the difference between the two verbs, and
1654 /// they are written once rather than twice with the middle diverging.
1655 fn awaitVerb( 1507 fn awaitVerb(
1656 alloc: std.mem.Allocator, 1508 alloc: std.mem.Allocator,
1657 conn: *Conn, 1509 conn: *Conn,
@@ -1721,10 +1573,9 @@ fn elapsed(started: i64) i64 {
1721 return std.time.milliTimestamp() - started; 1573 return std.time.milliTimestamp() - started;
1722 } 1574 }
1723 1575
1724 /// This client's deadline for the await itself — the daemon's own bound 1576 /// This client's deadline: the daemon's own bound plus the grace
1725 /// plus the grace window (see await_grace_ms and Conn.graceMs, which is 1577 /// window (await_grace_ms, widened per transport by `Conn.graceMs`).
1726 /// where the transport gets to widen it). An unbounded request stays 1578 /// An unbounded request stays unbounded.
1727 /// unbounded here too.
1728 fn awaitDeadline(o: Opts, conn: *const Conn) i64 { 1579 fn awaitDeadline(o: Opts, conn: *const Conn) i64 {
1729 if (o.timeout_ms == 0) return std.math.maxInt(i64); 1580 if (o.timeout_ms == 0) return std.math.maxInt(i64);
1730 return std.time.milliTimestamp() + o.timeout_ms + conn.graceMs(); 1581 return std.time.milliTimestamp() + o.timeout_ms + conn.graceMs();
src/paint.zig
Old New
@@ -21,19 +21,8 @@ pub const sync_end = "\x1b[?25h\x1b[?2026l";
21 /// Inclusive grid columns of one row, painted inverted. 21 /// Inclusive grid columns of one row, painted inverted.
22 pub const Span = struct { from: u16, to: u16 }; 22 pub const Span = struct { from: u16, to: u16 };
23 23
24 /// Which columns of a grid row a painter must invert, asked per row. 24 /// A callback, not a shape: `select.zig` is layer 1 like this module, so
25 /// 25 /// neither may import the other.
26 /// A callback and not a list of rows, for a layering reason and a
27 /// practical one. The answer comes from `select.zig`, which is layer 1
28 /// like this module — neither may import the other — so the shape of a
29 /// selection cannot be named here; and a painter emits row by row anyway,
30 /// so per-row is the question it already has to ask. `cols` is handed
31 /// DOWN rather than remembered by the caller: the width the highlight is
32 /// clamped to must be the width this paint is about to use, and
33 /// `Engine.dumpVtRowSpan` asserts it.
34 ///
35 /// Null `span` — the default — is a paint with no selection on it, which
36 /// is nearly every paint.
37 pub const Highlight = struct { 26 pub const Highlight = struct {
38 ctx: ?*anyopaque = null, 27 ctx: ?*anyopaque = null,
39 span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null, 28 span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null,
@@ -55,10 +44,8 @@ pub fn clampCursor(cur: Engine.CursorPos, tty: proto.Size) Engine.CursorPos {
55 }; 44 };
56 } 45 }
57 46
58 /// Full repaint of the replica, clipped to the local tty. The replica is 47 /// The replica may exceed the tty under latest-wins; rows clip at the
59 /// grid-sized (may exceed the tty under latest-wins); rows beyond the tty 48 /// right edge because DECAWM is off from attach.
60 /// are skipped and long rows clip at the right edge because autowrap is
61 /// off (DECAWM, set at attach).
62 pub fn renderClipped( 49 pub fn renderClipped(
63 alloc: std.mem.Allocator, 50 alloc: std.mem.Allocator,
64 replica: *Engine, 51 replica: *Engine,
@@ -88,16 +75,8 @@ pub fn renderClipped(
88 try proto.writeAllFd(out_fd, paint.items); 75 try proto.writeAllFd(out_fd, paint.items);
89 } 76 }
90 77
91 /// Repaint the named grid rows and nothing else. 78 /// What a MOVING selection needs: a drag changes one or two rows, and a
92 /// 79 /// full repaint per cell crossed costs the whole screen.
93 /// What a MOVING selection needs. A drag reports on every cell the pointer
94 /// crosses and the anchor does not move, so one report changes the
95 /// highlight on one or two rows — and repainting the screen for each costs
96 /// the whole screen's bytes per cell crossed. Rows are grid rows; the
97 /// caller decides which ones changed, this draws them.
98 ///
99 /// Each row is cleared before it is redrawn (`\x1b[2K`), which the full
100 /// render does not have to do because it opens with a screen clear.
101 pub fn renderRowsClipped( 80 pub fn renderRowsClipped(
102 alloc: std.mem.Allocator, 81 alloc: std.mem.Allocator,
103 replica: *Engine, 82 replica: *Engine,
@@ -115,6 +94,8 @@ pub fn renderRowsClipped(
115 const limit = @min(grid_rows, tty.rows); 94 const limit = @min(grid_rows, tty.rows);
116 for (rows) |y| { 95 for (rows) |y| {
117 if (y >= limit) continue; 96 if (y >= limit) continue;
97 // \x1b[2K per row: unlike a full render this never clears the
98 // screen, so a shorter row would leave the old one showing.
118 var cup: [16]u8 = undefined; 99 var cup: [16]u8 = undefined;
119 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{@as(u32, y) + 1})); 100 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{@as(u32, y) + 1}));
120 const row = try dumpRow(alloc, replica, y, hl); 101 const row = try dumpRow(alloc, replica, y, hl);
@@ -129,24 +110,17 @@ pub fn renderRowsClipped(
129 try proto.writeAllFd(out_fd, paint.items); 110 try proto.writeAllFd(out_fd, paint.items);
130 } 111 }
131 112
132 /// Which columns of a delta row the selection covers, or null for a row to 113 /// Bounded by the REPLICA's grid, not the tty: the answer feeds
133 /// paint as the daemon sent it. 114 /// `dumpVtRowSpan`, which asserts its row exists, and under latest-wins
134 /// 115 /// the grid can be smaller than the terminal it is painted on.
135 /// Bounded by the REPLICA's grid rather than the tty: the answer feeds
136 /// `dumpVtRowSpan`, which addresses cells and asserts its row exists. Under
137 /// latest-wins the grid can be smaller than the terminal it is painted on,
138 /// and a row past its end is one no dump can produce.
139 fn deltaRowSpan(hl: Highlight, row: u16, grid_rows: u16, grid_cols: u16) ?Span { 116 fn deltaRowSpan(hl: Highlight, row: u16, grid_rows: u16, grid_cols: u16) ?Span {
140 if (row >= grid_rows) return null; 117 if (row >= grid_rows) return null;
141 const ask = hl.span orelse return null; 118 const ask = hl.span orelse return null;
142 return ask(hl.ctx, row, grid_cols); 119 return ask(hl.ctx, row, grid_cols);
143 } 120 }
144 121
145 /// Paint a delta directly, skipping rows outside the local tty. The 122 /// The replica is updated separately by `composeDelta`, unclipped; it is
146 /// replica is updated separately via composeDelta (full, unclipped), and 123 /// read here only for selected rows, after being fed this frame.
147 /// is read here only for rows the selection covers — by then it has
148 /// already been fed this frame, so a dumped row and the daemon's own bytes
149 /// carry the same content.
150 pub fn paintDeltaClipped( 124 pub fn paintDeltaClipped(
151 alloc: std.mem.Allocator, 125 alloc: std.mem.Allocator,
152 payload: []const u8, 126 payload: []const u8,
@@ -210,8 +184,7 @@ pub fn paintBanner(out_fd: std.posix.fd_t, size: proto.Size, label: []const u8)
210 proto.writeAllFd(out_fd, text) catch {}; 184 proto.writeAllFd(out_fd, text) catch {};
211 } 185 }
212 186
213 /// Paint a fetched history page: clear, rows, and an inverse [scroll] 187 /// The inverse [scroll] marker top-right says this is not live.
214 /// marker top-right so the user knows they're not live.
215 pub fn renderScrollback( 188 pub fn renderScrollback(
216 alloc: std.mem.Allocator, 189 alloc: std.mem.Allocator,
217 rows_vt: []const u8, 190 rows_vt: []const u8,
@@ -233,35 +206,14 @@ pub fn renderScrollback(
233 try proto.writeAllFd(out_fd, paint.items); 206 try proto.writeAllFd(out_fd, paint.items);
234 } 207 }
235 208
236 /// Paint one wall stripe: a `view.rows`-tall window of the replica grid 209 /// Two threads need this: the pump paints the window; the keyboard maps
237 /// onto terminal rows [row_off+1 .. row_off+view.rows], each cleared with 210 /// a click back to a grid row with no replica to recompute from.
238 /// \x1b[2K before painting. The window follows the cursor — the last
239 /// window row is the cursor row when the grid is taller than the view —
240 /// because that is where the session is happening: a fresh shell's
241 /// prompt sits in the top rows and a busy one's at the bottom, and a
242 /// fixed crop from either end blanks the other case (the bottom-crop
243 /// first version showed 14 empty rows of every fresh session).
244 /// No \x1b[2J — the rest of the screen belongs to other stripes.
245 /// The bracket closes WITHOUT the cursor-show, renderScrollback's deliberate
246 /// half-pair: the wall hides the cursor for its whole lifetime, and a
247 /// per-stripe show would park a visible cursor on whichever stripe painted
248 /// last. Full-width rows only: clipping at the right edge is the terminal's
249 /// (DECAWM off), which is why the wall stacks stripes instead of tiling
250 /// columns — an interior column would need VT-safe truncation this module
251 /// does not do.
252 /// The grid row a stripe window starts at: cursor-anchored, clamped inside
253 /// the grid.
254 ///
255 /// Its own function because two threads need the same answer. The PUMP
256 /// paints the window here; the KEYBOARD has to turn a click's terminal row
257 /// back into the grid row under it, and it has no replica to recompute
258 /// from. A second copy of this arithmetic that drifted would land clicks on
259 /// a different line than the one the user pointed at, and nothing on screen
260 /// would say so.
261 pub fn stripeWinStart(grid_rows: u16, cur_y: u16, view_rows: u16) u16 { 211 pub fn stripeWinStart(grid_rows: u16, cur_y: u16, view_rows: u16) u16 {
262 return @min(grid_rows -| view_rows, (cur_y + 1) -| view_rows); 212 return @min(grid_rows -| view_rows, (cur_y + 1) -| view_rows);
263 } 213 }
264 214
215 /// Full-width rows only: right-edge clipping is the terminal's (DECAWM
216 /// off), so the wall stacks stripes rather than tiling columns.
265 pub fn renderStripe( 217 pub fn renderStripe(
266 alloc: std.mem.Allocator, 218 alloc: std.mem.Allocator,
267 replica: *Engine, 219 replica: *Engine,
@@ -275,6 +227,9 @@ pub fn renderStripe(
275 try paint.appendSlice(alloc, "\x1b[?2026h"); 227 try paint.appendSlice(alloc, "\x1b[?2026h");
276 228
277 const grid_rows: u16 = @intCast(replica.term.rows); 229 const grid_rows: u16 = @intCast(replica.term.rows);
230 // Cursor-anchored, because that is where the session is happening: a
231 // fresh shell's prompt sits in the top rows and a busy one's at the
232 // bottom, so a fixed crop from either end blanks the other case.
278 const start = stripeWinStart(grid_rows, replica.cursorPos().y, view.rows); 233 const start = stripeWinStart(grid_rows, replica.cursorPos().y, view.rows);
279 var y: u16 = start; 234 var y: u16 = start;
280 var out_row: u16 = row_off + 1; 235 var out_row: u16 = row_off + 1;
@@ -296,6 +251,11 @@ pub fn renderStripe(
296 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{out_row})); 251 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{out_row}));
297 } 252 }
298 253
254 // Per-row clears, never \x1b[2J: the rest of the screen belongs to the
255 // other stripes. The close is unpaired for the same reason as
256 // renderScrollback's — the wall hides the cursor for its whole
257 // lifetime, and a per-stripe show would park a visible one on whichever
258 // stripe painted last.
299 try paint.appendSlice(alloc, "\x1b[?2026l"); 259 try paint.appendSlice(alloc, "\x1b[?2026l");
300 try proto.writeAllFd(out_fd, paint.items); 260 try proto.writeAllFd(out_fd, paint.items);
301 } 261 }
src/predict.zig
Old New
@@ -1,57 +1,24 @@
1 //! Speculative local echo, as an OVERLAY. 1 //! Speculative local echo, as an OVERLAY: predictions live in a queue
2 //! beside the replica and never enter it (CLAUDE.md's "Prediction is an
3 //! overlay"), so a wrong guess costs a repaint, never a desync.
2 //! 4 //!
3 //! The client's replica keeps tracking exactly what the daemon said, and 5 //! Engine-free: `reconcile` takes its grid duck-typed, so the policy is
4 //! nothing in here is ever fed into it. Predictions live in a small queue 6 //! tested with no engine, pty or daemon.
5 //! beside it, painted on top and reconciled cell by cell as authoritative
6 //! frames land. That separation is the whole design: a wrong prediction
7 //! costs a repaint, never a desync, and `muxd dump` and the client grid
8 //! stay comparable byte for byte at every moment.
9 //! 7 //!
10 //! Engine-free on purpose. Nothing here imports a terminal, so the entire 8 //! Judgment is about EVIDENCE, not arrival order: a frame showing the
11 //! policy — which contexts predict, what earns the right to be seen, what 9 //! predicted cell unchanged was probably built before the keystroke got
12 //! takes it away — is exercised by tests with no engine, no pty and no 10 //! there, so the prediction stays pending, and only a cell that moved to
13 //! daemon in the picture. `reconcile` takes its grid duck-typed: anything 11 //! something neither our guess nor what was there before refutes.
14 //! with `cellChar(row, col) ?u8` will do, and `PlainGrid` below is the
15 //! adapter over the plain dump a client already has.
16 //! 12 //!
17 //! Judgment is about EVIDENCE, not arrival order. A frame that shows the 13 //! The tiers describe TERMIOS and invite the wrong reading: readline
18 //! predicted cell still holding what it held when we guessed has said 14 //! echoes itself, so a bash or zsh prompt is `.adaptive` and never
19 //! nothing about the keystroke — it was very likely built before the 15 //! `.always`, which covers `cat`, a shell's `read`, dash. The bits move
20 //! keystroke reached the daemon — so it leaves the prediction pending 16 //! once or twice per command, and every move re-earns display, so the
21 //! rather than refuting it. Only a cell that has moved to something which 17 //! first keystrokes after each prompt are invisible.
22 //! is neither our guess nor what was there before is a refutation. Reading
23 //! "not yet" as "wrong" is what an earlier version did, and it meant a
24 //! typing burst refuted itself once per round trip: `h` confirms while
25 //! `e,l,l,o` are judged by a frame built before they were typed, all four
26 //! read as contradictions, and the queue flushes. Prediction that erases
27 //! itself every RTT is the opposite of the feature.
28 //! 18 //!
29 //! "Pending" is therefore bounded rather than open-ended: a prediction that 19 //! Predictions copy bytes, never slice a frame payload or an engine row,
30 //! goes unanswered for expire_after_frames judging frames, or 20 //! and the queue is read by index — a slice goes stale on the next
31 //! expire_after_ms of wall time, is given up on exactly as a contradiction 21 //! append.
32 //! would be. That bound is what stops a consumed keystroke — nvim taking a
33 //! `j` in normal mode and repainting some other row — from leaving a
34 //! phantom glyph on screen for the rest of the session.
35 //!
36 //! The three tiers describe TERMIOS, not user experience, and the names
37 //! invite exactly the wrong reading. An interactive bash or zsh prompt runs
38 //! at icanon=0, echo=0 — readline turns both off and does the echoing
39 //! itself — so the everyday shell prompt is `.adaptive`, and never
40 //! `.always`. `.always` covers the genuinely canonical readers: `cat`, a
41 //! shell's `read` builtin, dash without line editing. Two consequences
42 //! worth having in mind before reading the policy below. The mode bits move
43 //! once or twice per command at a normal prompt, as readline hands the
44 //! terminal back and forth to run each command; and since every move
45 //! re-earns display from scratch, the first couple of keystrokes after each
46 //! prompt are invisible predictions. That is the conservative trade this
47 //! module takes deliberately — per-context confidence memory is the banked
48 //! polish.
49 //!
50 //! Memory: the overlay owns everything it holds. Predictions are copies of
51 //! bytes, never slices into frame payloads or engine rows, and the queue is
52 //! read back by index rather than handed out as a slice — a slice would go
53 //! stale on the next append, which is the shape decisions.md's egress
54 //! records call the UAF-that-never-crashes.
55 const std = @import("std"); 22 const std = @import("std");
56 const proto = @import("protocol"); 23 const proto = @import("protocol");
57 24
@@ -158,10 +125,9 @@ pub const expire_after_ms: i64 = 1000;
158 /// Engine-free mirror of the engine's cursor position. 125 /// Engine-free mirror of the engine's cursor position.
159 pub const CursorPos = struct { x: u16 = 0, y: u16 = 0 }; 126 pub const CursorPos = struct { x: u16 = 0, y: u16 = 0 };
160 127
161 /// One keystroke offered for prediction. A struct rather than four 128 /// A struct, not four positional arguments: `ch` and `prev_ch` are
162 /// positional arguments because `ch` and `prev_ch` are both bytes and 129 /// adjacent bytes, so transposing them compiles silently and turns every
163 /// adjacent: transposed, they compile silently and turn every prediction 130 /// prediction into a no-op or a wrong guess.
164 /// into either a no-op or a wrong guess.
165 pub const Keystroke = struct { 131 pub const Keystroke = struct {
166 cursor: CursorPos, 132 cursor: CursorPos,
167 /// The byte the user typed. 133 /// The byte the user typed.
@@ -203,12 +169,10 @@ pub const PlainGrid = struct {
203 text: []const u8, 169 text: []const u8,
204 cols: u16, 170 cols: u16,
205 171
206 /// null means "outside the grid", which is a different answer from "a 172 /// null means "outside the grid", not "blank": a dump carries no
207 /// blank cell": a dump carries no trailing blanks, so a column past the 173 /// trailing blanks, so past a row's end or the dump's end is blank. A
208 /// end of a row, or a row past the end of the dump, is blank rather 174 /// multi-byte cell answers with its lead byte, which can never equal a
209 /// than missing. A cell holding a multi-byte character answers with its 175 /// predicted printable ASCII byte — so it contradicts, the safe way.
210 /// lead byte, which cannot equal a predicted printable ASCII byte —
211 /// so such a cell contradicts, which is the safe direction.
212 pub fn cellChar(self: PlainGrid, row: u16, col: u16) ?u8 { 176 pub fn cellChar(self: PlainGrid, row: u16, col: u16) ?u8 {
213 if (col >= self.cols) return null; 177 if (col >= self.cols) return null;
214 var y: u16 = 0; 178 var y: u16 = 0;
@@ -309,10 +273,7 @@ pub const Overlay = struct {
309 if (pending) self.flush(); 273 if (pending) self.flush();
310 } 274 }
311 275
312 /// Record the authoritative seq the client now holds. reconcile does 276 /// For a frame applied without judging it — a snapshot.
313 /// this itself; the client calls it directly on the paths that apply a
314 /// frame without judging anything, a snapshot being the one that
315 /// matters.
316 pub fn noteSeq(self: *Overlay, seq: u64) void { 277 pub fn noteSeq(self: *Overlay, seq: u64) void {
317 self.last_seq = seq; 278 self.last_seq = seq;
318 } 279 }
@@ -361,26 +322,15 @@ pub const Overlay = struct {
361 return .suppressed; 322 return .suppressed;
362 } 323 }
363 324
364 /// Count a refusal the CALLER made on its own authority — input it 325 /// A refusal the CALLER made: a plain-ASCII paste, whose lead byte is
365 /// declines to offer at all, a paste of plain ASCII being the case that 326 /// printable.
366 /// forced this: its lead byte is printable, so offering it would predict
367 /// the paste's first character rather than being refused.
368 ///
369 /// `suppressed` counts decisions, not which side of the interface made
370 /// them. Without this the counter silently disagreed with the behaviour
371 /// — nothing was predicted, and nothing said so.
372 pub fn recordSuppressed(self: *Overlay) void { 327 pub fn recordSuppressed(self: *Overlay) void {
373 self.counters.suppressed += 1; 328 self.counters.suppressed += 1;
374 } 329 }
375 330
376 /// Note that the prediction at `i` has reached the screen, counting it 331 /// A prediction queued while unconfident is invisible; a later
377 /// the first time only. 332 /// promotion makes the next repaint draw it. Count it once, however
378 /// 333 /// many repaints redraw the cell.
379 /// A prediction queued while unconfident is invisible, but a promotion
380 /// that happens while it is still pending makes the next repaint draw
381 /// it. `displayed` means "predictions that ever reached the screen", so
382 /// that later painting has to be counted — and counted once, however
383 /// many repaints redraw the same cell.
384 pub fn markPainted(self: *Overlay, i: usize) void { 334 pub fn markPainted(self: *Overlay, i: usize) void {
385 if (self.pending.items[i].painted) return; 335 if (self.pending.items[i].painted) return;
386 self.pending.items[i].painted = true; 336 self.pending.items[i].painted = true;
@@ -444,14 +394,9 @@ pub const Overlay = struct {
444 return verdict; 394 return verdict;
445 } 395 }
446 396
447 /// Give up on predictions that have gone unanswered too long in WALL 397 /// An app that swallows a keystroke then goes quiet produces no more
448 /// time, with no frame needed to trigger it. 398 /// frames, so `reconcile` never runs again and the phantom sits there
449 /// 399 /// forever. The client calls this from its idle path.
450 /// The frame bound in reconcile cannot catch the case that motivates
451 /// the guard: an application that swallows a keystroke and then goes
452 /// quiet produces no further frames, so reconcile is never called again
453 /// and the phantom would sit there forever. The client calls this on
454 /// its idle path — a poll timeout will do.
455 pub fn expire(self: *Overlay, now_ms: i64) Verdict { 400 pub fn expire(self: *Overlay, now_ms: i64) Verdict {
456 for (self.pending.items) |p| { 401 for (self.pending.items) |p| {
457 if (now_ms -| p.made_ms < expire_after_ms) continue; 402 if (now_ms -| p.made_ms < expire_after_ms) continue;
@@ -481,14 +426,8 @@ pub const Overlay = struct {
481 return .contradicted; 426 return .contradicted;
482 } 427 }
483 428
484 /// Drop every prediction without calling any of them wrong. For the 429 /// Snapshot, resize, scroll mode, reconnect: nothing here is wrong, so
485 /// events after which we can no longer find out — a snapshot, a resize, 430 /// confidence and the streak survive.
486 /// scroll mode, a reconnect. Counting these as contradictions would fire
487 /// the demotion machinery on a window resize.
488 /// Confidence and the streak deliberately survive: they were earned by
489 /// real confirmations, and a resize is not evidence against them. Demote
490 /// here and every window resize would cost the next promote_after
491 /// keystrokes their visibility.
492 pub fn flush(self: *Overlay) void { 431 pub fn flush(self: *Overlay) void {
493 self.counters.abandoned += self.pending.items.len; 432 self.counters.abandoned += self.pending.items.len;
494 self.pending.clearRetainingCapacity(); 433 self.pending.clearRetainingCapacity();
@@ -506,9 +445,8 @@ pub const Overlay = struct {
506 return self.pending.items.len; 445 return self.pending.items.len;
507 } 446 }
508 447
509 /// By value: the queue reallocates as it grows, so handing out a slice 448 /// By value: the queue reallocates, so a slice would go stale on the
510 /// into it would hand out something that goes stale on the next 449 /// next keystroke.
511 /// keystroke.
512 pub fn pendingAt(self: *const Overlay, i: usize) Pred { 450 pub fn pendingAt(self: *const Overlay, i: usize) Pred {
513 return self.pending.items[i]; 451 return self.pending.items[i];
514 } 452 }
@@ -533,8 +471,7 @@ fn typeAt(ov: *Overlay, x: u16, y: u16, ch: u8) Outcome {
533 }); 471 });
534 } 472 }
535 473
536 /// Reconcile against a grid written out as rows, with the clock standing 474 /// The clock stands still; tests about time use `seeRowsAt`.
537 /// still. Tests that are about time pass their own.
538 fn seeRows( 475 fn seeRows(
539 alloc: std.mem.Allocator, 476 alloc: std.mem.Allocator,
540 ov: *Overlay, 477 ov: *Overlay,
src/protocol.zig
Old New
@@ -71,9 +71,7 @@ pub const Frame = struct {
71 71
72 /// One frame's boundaries inside a buffer somebody else filled. `payload` 72 /// One frame's boundaries inside a buffer somebody else filled. `payload`
73 /// BORROWS from that buffer and is valid only until it is written to or 73 /// BORROWS from that buffer and is valid only until it is written to or
74 /// shifted, which is why this type is separate from `Frame`: the callers 74 /// shifted; a `Frame` owns its copy.
75 /// that need to keep a payload copy it out themselves, and the ones that
76 /// only need to read it never allocate at all.
77 pub const Delimited = struct { 75 pub const Delimited = struct {
78 type: MsgType, 76 type: MsgType,
79 payload: []const u8, 77 payload: []const u8,
@@ -84,17 +82,10 @@ pub const Delimited = struct {
84 82
85 /// Delimit the frame at the front of `buf`, without copying. 83 /// Delimit the frame at the front of `buf`, without copying.
86 /// 84 ///
87 /// Null means the tail is still partial — a header that has not all 85 /// Null means the tail is still partial, which is the ordinary state of a
88 /// arrived, or a payload still in flight — which is the ordinary state of 86 /// byte stream and never an error. `error.FrameTooLarge` means a length no
89 /// a byte stream and never an error. `error.FrameTooLarge` means a length 87 /// frame can legitimately carry: the stream is not what we think it is, and
90 /// no frame can legitimately carry: the stream is not what we think it is, 88 /// reading on would size an allocation from a number the peer chose.
91 /// and reading on would size an allocation from a number the peer chose.
92 ///
93 /// Pure, and takes a plain slice rather than any connection, so both ends
94 /// of the wire delimit with the same arithmetic and it can be exercised
95 /// against a canned buffer. The type byte is read through a non-exhaustive
96 /// enum on purpose: an unknown message type is the peer's business to have
97 /// sent and the caller's to ignore, not a reason to refuse the stream.
98 pub fn delimitFrame(buf: []const u8) !?Delimited { 89 pub fn delimitFrame(buf: []const u8) !?Delimited {
99 if (buf.len < frame_header_len) return null; 90 if (buf.len < frame_header_len) return null;
100 const len = std.mem.readInt(u32, buf[1..5], .little); 91 const len = std.mem.readInt(u32, buf[1..5], .little);
@@ -115,10 +106,7 @@ pub fn writeFrame(fd: std.posix.fd_t, t: MsgType, payload: []const u8) !void {
115 try writeAllFd(fd, payload); 106 try writeAllFd(fd, payload);
116 } 107 }
117 108
118 /// Append one frame's wire bytes (header + payload) to a list. The queued 109 /// The queued counterpart of `writeFrame`, for a peer too slow to block on.
119 /// counterpart of writeFrame: daemons buffer frames per client and flush
120 /// opportunistically instead of blocking on a slow peer. The bytes are
121 /// identical to what writeFrame puts on the wire — a golden test pins that.
122 pub fn appendFrame( 110 pub fn appendFrame(
123 list: *std.ArrayList(u8), 111 list: *std.ArrayList(u8),
124 alloc: std.mem.Allocator, 112 alloc: std.mem.Allocator,
@@ -254,18 +242,12 @@ pub fn decodeAgentId(payload: []const u8) !u32 {
254 } 242 }
255 243
256 /// The receive side of `agent_data_max`. A cap only the sender honours is 244 /// The receive side of `agent_data_max`. A cap only the sender honours is
257 /// an assumption about the peer, and both ends hand this payload straight 245 /// an assumption about the peer.
258 /// to a blocking `writeAllFd` — so a frame that claims more than one bite
259 /// is refused here rather than pumped. The channel is the unit of refusal:
260 /// dropping the frame instead would leave the agent stream short of bytes
261 /// its far end is still waiting on.
262 pub fn agentDataOversize(payload: []const u8) bool { 246 pub fn agentDataOversize(payload: []const u8) bool {
263 return payload.len > agent_id_len + agent_data_max; 247 return payload.len > agent_id_len + agent_data_max;
264 } 248 }
265 249
266 /// One endpoint of a terminal selection. Rows are absolute screen-space 250 /// Screen-space rows; the grid's owner normalizes.
267 /// rows; the protocol preserves both endpoints exactly and leaves ordering
268 /// and normalization to the component that owns the terminal grid.
269 pub const SelectionPoint = struct { 251 pub const SelectionPoint = struct {
270 row: u32, 252 row: u32,
271 col: u16, 253 col: u16,
@@ -336,10 +318,8 @@ pub const SelectionReply = struct {
336 text: []const u8, 318 text: []const u8,
337 }; 319 };
338 320
339 /// Append a selection reply to caller-owned storage. Protocol validation is 321 /// Validation completes before the first append, so `error.BadPayload`
340 /// completed before the first append, so `error.BadPayload` leaves a reused 322 /// leaves a reused `out` unchanged.
341 /// output buffer unchanged. Allocation errors retain normal ArrayList
342 /// semantics.
343 pub fn encodeSelectionReply( 323 pub fn encodeSelectionReply(
344 out: *std.ArrayList(u8), 324 out: *std.ArrayList(u8),
345 alloc: std.mem.Allocator, 325 alloc: std.mem.Allocator,
@@ -501,18 +481,8 @@ pub const AwaitReq = struct {
501 481
502 pub const await_req_len = 16; 482 pub const await_req_len = 16;
503 483
504 /// Writes only the fixed 16 bytes: the payload from before named sessions, 484 /// Writes only the fixed 16 bytes: the payload an empty name means on the
505 /// which is what an empty name means on the wire. 485 /// wire.
506 ///
507 /// The name must be empty, and that is asserted rather than documented.
508 /// This used to carry a comment admitting that a caller who set `.name`
509 /// and called this instead of `encodeAwaitReqNamed` would find it silently
510 /// dropped — a known way to send the wrong bytes, written down and left
511 /// live. Dropping `AwaitReq.name`'s default was considered and does not
512 /// fix it: it forces every literal to say `.name = ""` and still lets
513 /// `.name = "b"` reach this function. The assert is what makes the misuse
514 /// impossible to hold wrong quietly — it fires at the call site, in the
515 /// build modes the tests and the daemon run under.
516 pub fn encodeAwaitReq(r: AwaitReq) [await_req_len]u8 { 486 pub fn encodeAwaitReq(r: AwaitReq) [await_req_len]u8 {
517 std.debug.assert(r.name.len == 0); 487 std.debug.assert(r.name.len == 0);
518 return awaitReqFixed(r); 488 return awaitReqFixed(r);
@@ -561,9 +531,8 @@ pub fn decodeAwaitReply(payload: []const u8) !AwaitReply {
561 }; 531 };
562 } 532 }
563 533
564 /// One structured snapshot for `muxa status`. The grid facts a driving 534 /// One structured snapshot for `muxa status`: what a driving agent needs
565 /// agent needs before deciding how to interact: size, cursor, whether a 535 /// before deciding how to interact.
566 /// TUI holds the screen, who echoes keystrokes, and the command state.
567 pub const StatusReply = struct { 536 pub const StatusReply = struct {
568 cols: u16, 537 cols: u16,
569 rows: u16, 538 rows: u16,
@@ -604,23 +573,14 @@ pub fn decodeStatusReply(payload: []const u8) !StatusReply {
604 }; 573 };
605 } 574 }
606 575
607 /// The two line-discipline bits that decide who is going to echo a 576 /// Who is going to echo a keystroke, and therefore whether a client may
608 /// keystroke, and therefore whether a client may echo it early. Read off the 577 /// echo it early.
609 /// session's pty by the daemon and shipped verbatim: the client is told what
610 /// the terminal IS, never what to do about it.
611 ///
612 /// Only two of the eight bits are spoken for, and the other six go out zero.
613 /// A client that does not understand a bit set in a later version must treat
614 /// the whole byte as unpredictable rather than mask it away — which is why
615 /// the reserved bits are pinned to zero by a test rather than left to
616 /// whatever the encoder happened to have on the stack.
617 ///
618 /// `TermModes`' reserved bits work the opposite way, deliberately: an
619 /// unknown bit there is ignored, because those modes are independent host
620 /// settings rather than one prediction verdict.
621 pub const PtyModeFlags = packed struct(u8) { 578 pub const PtyModeFlags = packed struct(u8) {
622 icanon: bool, 579 icanon: bool,
623 echo: bool, 580 echo: bool,
581 // Reserved, and unlike `TermModes` NOT maskable: a client that meets a
582 // bit it does not understand must treat the whole byte as unpredictable,
583 // because these eight bits are one prediction verdict.
624 _pad: u6 = 0, 584 _pad: u6 = 0,
625 }; 585 };
626 586
@@ -640,21 +600,18 @@ pub fn decodePtyMode(payload: []const u8) !PtyModeFlags {
640 /// Sampled state, not events: read off the engine, sent when changed and 600 /// Sampled state, not events: read off the engine, sent when changed and
641 /// unconditionally on attach. 601 /// unconditionally on attach.
642 /// 602 ///
643 /// Nine bits are spoken for. The rest are reserved for focus reporting and 603 /// The reserved bits are for focus reporting and cursor shape, so that
644 /// cursor shape — deliberately, so that adding them later needs no new 604 /// adding them later needs no new frame type and no version check. They go
645 /// frame type and no version check. They go out zero from any value we 605 /// out zero and are ignored on receipt: decode does not mask them away, so
646 /// construct, and are ignored on receipt: decode does not mask them away, 606 /// a client that echoes modes back cannot silently downgrade bits set by a
647 /// so a client that ever echoes modes back cannot silently downgrade bits 607 /// newer daemon.
648 /// set by a daemon newer than it.
649 /// 608 ///
650 /// A daemon that predates the mouse bits sends them zero, which a new 609 /// A daemon predating the mouse bits sends them zero, which a new client
651 /// client reads as "no application wants the mouse" — so it keeps the 610 /// reads as "no application wants the mouse" — it keeps the wheel for
652 /// wheel for scrollback, the pre-mouse behaviour of every client. An old 611 /// scrollback, as every client did before.
653 /// client ignores them and behaves exactly as it did.
654 /// 612 ///
655 /// u32, not the dozen-ish bits the modes above would need: this frame is 613 /// u32, not the dozen-ish bits the modes need: this frame is rare enough
656 /// rare enough that four bytes is free, and a wire field cannot be 614 /// that four bytes is free, and a wire field cannot be narrowed later.
657 /// narrowed later.
658 pub const TermModes = packed struct(u32) { 615 pub const TermModes = packed struct(u32) {
659 bracketed_paste: bool, 616 bracketed_paste: bool,
660 // One bit per mouse DEC mode the session set, in `mouse_modes` order — 617 // One bit per mouse DEC mode the session set, in `mouse_modes` order —
@@ -673,10 +630,8 @@ pub const TermModes = packed struct(u32) {
673 mouse_sgr_pixels: bool = false, 630 mouse_sgr_pixels: bool = false,
674 _pad: u23 = 0, 631 _pad: u23 = 0,
675 632
676 /// Whether the session's application asked to be sent mouse events — 633 /// Whether the wheel belongs to the app rather than to scrollback — a
677 /// i.e. whether the wheel belongs to it rather than to the client's 634 /// format mode spells events, it does not ask for them.
678 /// scrollback. Only the tracking modes count: a format mode says how an
679 /// event is spelled, not that anyone wants one.
680 pub fn appMouse(self: TermModes) bool { 635 pub fn appMouse(self: TermModes) bool {
681 return self.mouse_x10 or self.mouse_normal or self.mouse_button or self.mouse_any; 636 return self.mouse_x10 or self.mouse_normal or self.mouse_button or self.mouse_any;
682 } 637 }
@@ -709,25 +664,16 @@ pub fn decodeTermModes(payload: []const u8) !TermModes {
709 } 664 }
710 665
711 /// A side channel the daemon's engine consumed and the client must replay 666 /// A side channel the daemon's engine consumed and the client must replay
712 /// onto the host terminal. Distinct from the sampled state in `term_modes` 667 /// onto the host terminal. Unlike the sampled state in `term_modes` and
713 /// and `term_title`: these happen once and leave nothing behind to read, 668 /// `term_title`, these happen once and leave nothing to read, so they are
714 /// so they are queued rather than polled. 669 /// queued rather than polled.
715 pub const TermEvent = union(Kind) { 670 pub const TermEvent = union(Kind) {
716 clipboard: Clipboard, 671 clipboard: Clipboard,
717 bell: void, 672 bell: void,
718 673
719 pub const Kind = enum(u8) { clipboard = 0, bell = 1 }; 674 pub const Kind = enum(u8) { clipboard = 0, bell = 1 };
720 675
721 /// `base64` is BORROWED from the frame payload and is valid only while 676 /// `base64` BORROWS from the frame payload, UNVALIDATED.
722 /// that payload lives — the same discipline `Delimited` uses. It stays
723 /// base64 the whole way: ghostty hands the OSC 52 payload over
724 /// undecoded, and every transform is a chance to corrupt bytes neither
725 /// end ever needs to read.
726 ///
727 /// The bytes are UNVALIDATED here — not even confirmed to be base64.
728 /// The daemon caps length on the way in and the client re-validates
729 /// before it lands in an `ESC]52;…BEL` written to a real tty; this
730 /// codec only carries the bytes between them.
731 pub const Clipboard = struct { 677 pub const Clipboard = struct {
732 target: u8, 678 target: u8,
733 base64: []const u8, 679 base64: []const u8,
@@ -759,10 +705,8 @@ pub const clipboard_base64_max: usize = 64 * 1024;
759 /// part of any contract mux is entitled to lean on. 705 /// part of any contract mux is entitled to lean on.
760 pub const term_title_max: usize = 1024; 706 pub const term_title_max: usize = 1024;
761 707
762 /// Appends into a caller-owned `ArrayList` rather than this file's usual 708 /// Appends rather than returning a fixed buffer: `clipboard_base64_max` is
763 /// caller-owned fixed buffer (see `encodeAttachNamed` and kin): a clipboard 709 /// too much stack to reserve per call.
764 /// payload can run to `clipboard_base64_max` (64 KiB), and that is too much
765 /// stack to reserve on every call just to cover the rare large paste.
766 pub fn encodeClipboardEvent( 710 pub fn encodeClipboardEvent(
767 out: *std.ArrayList(u8), 711 out: *std.ArrayList(u8),
768 alloc: std.mem.Allocator, 712 alloc: std.mem.Allocator,
@@ -774,11 +718,8 @@ pub fn encodeClipboardEvent(
774 try out.appendSlice(alloc, base64); 718 try out.appendSlice(alloc, base64);
775 } 719 }
776 720
777 /// Not the fixed-size shape the rest of this file uses for a one-byte 721 /// Appends like `encodeClipboardEvent` despite the one-byte payload: the
778 /// payload (contrast `encodePtyMode`): the server drain's `switch (ev.kind)` 722 /// server drain funnels both kinds into one `ArrayList`.
779 /// funnels both kinds into one shared `ArrayList` before a single
780 /// `queueFrame` call, so the two encoders share a shape on purpose — a
781 /// fixed-array return here would just move the append into the caller's arm.
782 pub fn encodeBellEvent(out: *std.ArrayList(u8), alloc: std.mem.Allocator) !void { 723 pub fn encodeBellEvent(out: *std.ArrayList(u8), alloc: std.mem.Allocator) !void {
783 try out.append(alloc, @intFromEnum(TermEvent.Kind.bell)); 724 try out.append(alloc, @intFromEnum(TermEvent.Kind.bell));
784 } 725 }
@@ -803,11 +744,9 @@ pub fn decodeTermEvent(payload: []const u8) !TermEvent {
803 }; 744 };
804 } 745 }
805 746
806 /// What a (re)attaching client already holds. `have_seq` is a sequence 747 /// What a (re)attaching client already holds. `have_seq` counts within ONE
807 /// number in ONE daemon instance's stream: after a restart the new daemon 748 /// daemon instance's stream, and `have_epoch` names that instance — a
808 /// counts from zero again, so the same number denotes different state. 749 /// mismatch (or 0, "I hold nothing") forces a full snapshot.
809 /// `have_epoch` names the instance those seqs came from — a mismatch (or 0,
810 /// "I hold nothing") makes have_seq unusable and forces a full snapshot.
811 pub const AttachReq = struct { 750 pub const AttachReq = struct {
812 cols: u16, 751 cols: u16,
813 rows: u16, 752 rows: u16,
@@ -832,12 +771,8 @@ pub const debug_dump_len = 1;
832 pub const debug_dump_max_len = debug_dump_len + session_name_max; 771 pub const debug_dump_max_len = debug_dump_len + session_name_max;
833 pub const default_session = "0"; 772 pub const default_session = "0";
834 773
835 /// The wire name a session-scoped verb actually means: an empty tail is 774 /// An empty tail is the default session's spelling: a fact about the WIRE,
836 /// the default session's spelling, and every reader of one has to say so. 775 /// not a per-call-site convention.
837 /// Spelled here rather than at each call site because "empty means
838 /// default" is a fact about the WIRE, not a convention five call sites in
839 /// the daemon happen to remember in the same way — and five copies is
840 /// five chances for one of them to be updated alone.
841 pub fn resolveName(wire_name: []const u8) []const u8 { 776 pub fn resolveName(wire_name: []const u8) []const u8 {
842 return if (wire_name.len == 0) default_session else wire_name; 777 return if (wire_name.len == 0) default_session else wire_name;
843 } 778 }
@@ -855,10 +790,8 @@ pub const sock_env = "MUX_SOCK";
855 pub const session_env = "MUX_SESSION"; 790 pub const session_env = "MUX_SESSION";
856 791
857 /// A name a user may spell: printable ASCII, no space; '#' is muxweb's 792 /// A name a user may spell: printable ASCII, no space; '#' is muxweb's
858 /// TARGET separator (a name holding one could never be addressed) and '/' 793 /// TARGET separator and '/' is reserved. The empty string is valid ON THE
859 /// is reserved. The empty string is valid ON THE WIRE (it means default) 794 /// WIRE (it means default) but not as a user-supplied name.
860 /// but not as a user-supplied name — callers that take names from users
861 /// check here, the decoders do not.
862 pub fn validSessionName(name: []const u8) bool { 795 pub fn validSessionName(name: []const u8) bool {
863 if (name.len == 0 or name.len > session_name_max) return false; 796 if (name.len == 0 or name.len > session_name_max) return false;
864 for (name) |c| { 797 for (name) |c| {
@@ -891,12 +824,6 @@ pub fn encodeAttachNamed(
891 } 824 }
892 825
893 /// `debug_dump`'s payload: the vt-mode byte, then the session-name tail. 826 /// `debug_dump`'s payload: the vt-mode byte, then the session-name tail.
894 /// The third verb to take this shape, and the third place it was being
895 /// hand-assembled — `muxd dump` and `muxa capture` had byte-identical
896 /// copies. Wire layout belongs to the wire module: two binaries knowing
897 /// the dump payload's shape is two binaries that can disagree about it.
898 /// An empty name writes exactly the one-byte payload from before named
899 /// sessions.
900 pub fn encodeDebugDumpNamed( 827 pub fn encodeDebugDumpNamed(
901 buf: *[debug_dump_max_len]u8, 828 buf: *[debug_dump_max_len]u8,
902 vt: bool, 829 vt: bool,
@@ -930,14 +857,8 @@ pub fn decodeAttach(payload: []const u8) !AttachReq {
930 }; 857 };
931 } 858 }
932 859
933 /// Fixed prefix of every snapshot payload. Carries the grid size because 860 /// The grid size is here because the replica follows the grid, not its own
934 /// under the latest-wins resize policy a client's tty may not match 861 /// tty.
935 /// the authoritative grid; the replica must follow the grid, not the tty.
936 /// `epoch` identifies the daemon instance that produced `seq`: a client
937 /// echoes it back on reattach so the daemon can tell "you are current" from
938 /// "you are current in a session that no longer exists". Deltas carry no
939 /// epoch — they only ever arrive on a live connection to the instance whose
940 /// snapshot opened it, and that connection dies with the daemon.
941 pub const SnapshotPrefix = struct { 862 pub const SnapshotPrefix = struct {
942 seq: u64, 863 seq: u64,
943 history_rows: u32, 864 history_rows: u32,
@@ -967,6 +888,7 @@ pub fn readSnapshotPrefix(payload: []const u8) !SnapshotPrefix {
967 }; 888 };
968 } 889 }
969 890
891 /// No epoch: a delta only arrives on the connection its snapshot opened.
970 pub const DeltaHeader = struct { 892 pub const DeltaHeader = struct {
971 seq: u64, 893 seq: u64,
972 history_rows: u32, 894 history_rows: u32,
src/proxy.zig
Old New
@@ -6,26 +6,9 @@
6 const std = @import("std"); 6 const std = @import("std");
7 const TmpDir = @import("testtmp").TmpDir; 7 const TmpDir = @import("testtmp").TmpDir;
8 8
9 /// Make a hangup on any of this process's pipes surface as EPIPE from 9 /// Make a hangup surface as EPIPE from write() instead of killing the
10 /// write() instead of killing it. 10 /// process. SIG_IGN survives exec where a handler does not: a caller that
11 /// 11 /// SPAWNS must install this after the spawn or the child inherits it.
12 /// Defence in depth, not a fix: Zig's std/start.zig already installs a noop
13 /// SIGPIPE handler, so `pump`'s write-error returns are reachable without
14 /// this. What it pins is that the reachability belongs to this code instead
15 /// of to a std default (`std.options.keep_sigpipe`) another module could
16 /// flip.
17 ///
18 /// Exported so that every process which writes to a pipe it does not own
19 /// installs the identical ignore rather than its own copy. No protocol
20 /// knowledge crosses the boundary, which is the only thing this file's
21 /// import list forbids.
22 ///
23 /// The one real difference from the std default: SIG_IGN survives exec, a
24 /// handler does not. So a caller that SPAWNS must install this after the
25 /// spawn, never before, or the child inherits the ignore across its exec —
26 /// which is why wallview.zig installs its ignore only after opening the
27 /// transport, why `muxd endpoint` calls this after its auto-start,
28 /// and why the order does not matter here (the proxy spawns nothing).
29 pub fn ignoreSigpipe() void { 12 pub fn ignoreSigpipe() void {
30 var ign: std.posix.Sigaction = .{ 13 var ign: std.posix.Sigaction = .{
31 .handler = .{ .handler = std.posix.SIG.IGN }, 14 .handler = .{ .handler = std.posix.SIG.IGN },
@@ -191,10 +174,10 @@ const PipeDrainer = struct {
191 } 174 }
192 }; 175 };
193 176
194 /// Shrink a socket's buffers so a transfer larger than them cannot be swallowed 177 /// Shrink a socket's buffers so a transfer larger than them cannot be
195 /// whole by the kernel. Set on the listener, it is inherited by the accepted 178 /// swallowed whole by the kernel. Set on the listener, inherited by the
196 /// connection. Linux doubles and clamps the request, so this is a floor 179 /// accepted connection. Linux doubles and clamps the request, so this is a
197 /// request, not a promise — the tests depend only on it being small. 180 /// floor request, not a promise.
198 fn shrinkBufs(fd: std.posix.fd_t) void { 181 fn shrinkBufs(fd: std.posix.fd_t) void {
199 const v: c_int = 1024; 182 const v: c_int = 1024;
200 std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.RCVBUF, std.mem.asBytes(&v)) catch {}; 183 std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.RCVBUF, std.mem.asBytes(&v)) catch {};
src/pty.zig
Old New
@@ -177,11 +177,9 @@ pub const Pty = struct {
177 return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO }; 177 return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO };
178 } 178 }
179 179
180 /// The foreground process group of the session, read off the master 180 /// Equal to `child` means no foreground job: the kernel's "command
181 /// with TIOCGPGRP. When it equals `child` (the shell, session leader 181 /// returned", with zero shell cooperation. No exit code and no output
182 /// post-forkpty), no foreground job is running — the kernel's own 182 /// span; marks are for that.
183 /// "the command returned", available with zero shell cooperation.
184 /// No exit code and no output span; that is what marks are for.
185 pub fn fgPgid(self: *const Pty) !std.posix.pid_t { 183 pub fn fgPgid(self: *const Pty) !std.posix.pid_t {
186 var pgid: c.pid_t = 0; 184 var pgid: c.pid_t = 0;
187 if (c.ioctl(self.master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed; 185 if (c.ioctl(self.master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed;
@@ -312,9 +310,8 @@ test "Pty: spawn /bin/sh, echo round trip" {
312 try std.testing.expect(std.mem.indexOf(u8, out.items, "m1-pty-ok") != null); 310 try std.testing.expect(std.mem.indexOf(u8, out.items, "m1-pty-ok") != null);
313 } 311 }
314 312
315 /// Read from the pty until `needle` shows up or `budget_ms` runs out. 313 /// Returns everything read, so a caller asserting absence can show what
316 /// Returns everything read, so a caller asserting absence can still show 314 /// it got.
317 /// what it got. The caller owns the returned list.
318 fn readUntil( 315 fn readUntil(
319 alloc: std.mem.Allocator, 316 alloc: std.mem.Allocator,
320 pty: *Pty, 317 pty: *Pty,
src/quic.zig
Old New
@@ -16,8 +16,7 @@ const std = @import("std");
16 16
17 /// The C view of the QUIC stack. Exported because the client transport 17 /// The C view of the QUIC stack. Exported because the client transport
18 /// shares it: two @cImport blocks over the same headers produce two 18 /// shares it: two @cImport blocks over the same headers produce two
19 /// *distinct* Zig types, so a client with its own would find that 19 /// *distinct* Zig types. One import, one type universe.
20 /// `ngtcp2_vec` is not `ngtcp2_vec`. One import, one type universe.
21 pub const c = @cImport({ 20 pub const c = @cImport({
22 @cInclude("ngtcp2/ngtcp2.h"); 21 @cInclude("ngtcp2/ngtcp2.h");
23 @cInclude("ngtcp2/ngtcp2_crypto.h"); 22 @cInclude("ngtcp2/ngtcp2_crypto.h");
@@ -220,20 +219,15 @@ pub const Key = struct {
220 /// rather than deriving safe. 219 /// rather than deriving safe.
221 pub const key_refusal_len = std.fs.max_path_bytes + 128; 220 pub const key_refusal_len = std.fs.max_path_bytes + 128;
222 221
223 /// The middle sentence of every key refusal, in every binary — one owner 222 /// The middle sentence of every key refusal, in every binary — one owner so
224 /// because four literal copies were held in sync by prose comments, and 223 /// the catch-alls cannot drift apart. Callers add their own prefix and suffix.
225 /// their catch-alls had already drifted. Callers add their own prefix and
226 /// suffix.
227 /// 224 ///
228 /// `err` is `anyerror` rather than `Key.LoadError` because `load` widens 225 /// `err` is `anyerror` rather than `Key.LoadError` because `load` widens past
229 /// past its own set: a stat or a read that fails arrives here as a plain 226 /// its own set: a stat or a read that fails arrives here as a plain posix
230 /// posix error, and the catch-all is what those are for. Classification is 227 /// error, and the catch-all is what those are for.
231 /// therefore by value, and identical at every caller — which is the point,
232 /// since a key is rejected for the same reasons whichever binary read it.
233 /// 228 ///
234 /// Truncating rather than failing is `failedMsg`'s policy in client.zig, 229 /// Truncating rather than failing: this line is the user's only account of the
235 /// for its reason: this line is the user's only account of the refusal, so 230 /// refusal, so a clipped one beats none.
236 /// a clipped one beats none.
237 pub fn keyRefusalBody(buf: []u8, err: anyerror, path: []const u8) []const u8 { 231 pub fn keyRefusalBody(buf: []u8, err: anyerror, path: []const u8) []const u8 {
238 var w: std.Io.Writer = .fixed(buf); 232 var w: std.Io.Writer = .fixed(buf);
239 switch (err) { 233 switch (err) {
@@ -466,8 +460,7 @@ pub const Egress = struct {
466 return 2; 460 return 2;
467 } 461 }
468 462
469 /// ngtcp2 took `n` bytes off the unsent region. They stay exactly where 463 /// The bytes stay exactly where they are — ngtcp2 now holds pointers to them.
470 /// they are — it now has pointers to them.
471 pub fn took(self: *Egress, n: usize) void { 464 pub fn took(self: *Egress, n: usize) void {
472 self.unsent -= @min(n, self.unsent); 465 self.unsent -= @min(n, self.unsent);
473 } 466 }
@@ -487,15 +480,9 @@ pub const Egress = struct {
487 } 480 }
488 }; 481 };
489 482
490 /// What one `writev_stream` return means for the egress ring — and, more to 483 /// One `writev_stream` return, in ORDER: ngtcp2 can commit `ndatalen` and
491 /// the point, IN WHAT ORDER. 484 /// still return an error afterwards, so account for the bytes first or they
492 /// 485 /// are re-offered at an offset the peer has moved past.
493 /// The ordering is the whole reason this is a function rather than three
494 /// copies of two ifs. ngtcp2 can commit `ndatalen` — advancing the stream
495 /// offset it will retransmit from — and still return an error afterwards
496 /// (NOMEM out of rtb_add, say). Account for the bytes first or they stay
497 /// counted as unsent, get offered again at an offset the peer has moved
498 /// past, and the stream desynchronises somewhere far away from here.
499 pub const WriteAction = enum { 486 pub const WriteAction = enum {
500 /// The call failed. Stop draining; the bytes are already accounted. 487 /// The call failed. Stop draining; the bytes are already accounted.
501 stop, 488 stop,
@@ -613,10 +600,7 @@ pub fn timestampNs() u64 {
613 return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec)); 600 return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec));
614 } 601 }
615 602
616 /// A third of the idle timeout, so two keepalives can go unanswered before 603 /// Never zero: ngtcp2 reads a zero timeout as "disabled", like UINT64_MAX.
617 /// the connection is called dead. Never zero: ngtcp2 reads a zero timeout as
618 /// "disabled", exactly as it reads UINT64_MAX, so a small idle_ms rounding
619 /// down would silently restore the behaviour the keepalive exists to prevent.
620 pub fn keepAliveNs(idle_ms: u64) u64 { 604 pub fn keepAliveNs(idle_ms: u64) u64 {
621 return @max(1, idle_ms / 3) * 1_000_000; 605 return @max(1, idle_ms / 3) * 1_000_000;
622 } 606 }
src/quic_client.zig
Old New
@@ -1,31 +1,23 @@
1 //! mux's QUIC client transport: one UDP socket, one connection, one 1 //! mux's QUIC client transport: one UDP socket, one connection, one
2 //! bidirectional stream of opaque bytes. 2 //! bidirectional stream of opaque bytes.
3 //! 3 //!
4 //! The mirror of `quic_server.zig` and held to the same discipline: it knows 4 //! The mirror of `quic_server.zig`: it knows NOTHING about the frame
5 //! NOTHING about the frame protocol it carries. It hands the caller a byte 5 //! protocol it carries, which is why there is no `proto` import here.
6 //! stream and takes bytes back; `client.zig` does the framing. That is what 6 //! Everything both ends must agree on is imported from quic.zig, so there
7 //! keeps "the transport is a swap" a checkable claim rather than a slogan, 7 //! is one copy to drift.
8 //! and it is why there is no `proto` import here.
9 //! 8 //!
10 //! The pieces both ends must agree on are IMPORTED from quic.zig, not 9 //! Three ngtcp2 constraints:
11 //! reimplemented here: the key, the wire constants, the `Egress` ring, the
12 //! write accounting, the clock. That is why a drift between the two sides
13 //! is not a thing that can happen quietly — there is only one copy to drift.
14 //!
15 //! Three of those imports carry hard-won reasons with them, restated here
16 //! because the cost of relearning them is measured in freezes:
17 //! 10 //!
18 //! 1. **ngtcp2 does not copy stream payload.** It keeps the vector it is 11 //! 1. **ngtcp2 does not copy stream payload.** It keeps the vector it is
19 //! handed and re-reads those bytes to retransmit, so outbound bytes 12 //! handed and re-reads those bytes to retransmit, so outbound bytes
20 //! must not move or be reused until the peer acknowledges them. Same 13 //! must not move or be reused until the peer acknowledges them.
21 //! `Egress` ring as the listener, same invariant, same reason.
22 //! 2. **A blocked stream is not a dead one.** NGTCP2_ERR_STREAM_DATA_BLOCKED 14 //! 2. **A blocked stream is not a dead one.** NGTCP2_ERR_STREAM_DATA_BLOCKED
23 //! is a documented return; abandoning the egress loop on it also 15 //! is a documented return; abandoning the egress loop on it also
24 //! abandons the ACKs that would reopen the window. 16 //! abandons the ACKs that would reopen the window.
25 //! 3. **Both flow-control windows get extended on consume.** Extending 17 //! 3. **Both flow-control windows get extended on consume.** Extending
26 //! only the stream's leaves the connection window to close instead, 18 //! only the stream's leaves the connection window to close, and the
27 //! and the DAEMON stalls a few hundred kilobytes in — a freeze with no 19 //! DAEMON stalls a few hundred kilobytes in — a freeze with no error
28 //! error on either side. 20 //! on either side.
29 const std = @import("std"); 21 const std = @import("std");
30 const quic = @import("quic"); 22 const quic = @import("quic");
31 23
@@ -304,16 +296,13 @@ pub const Client = struct {
304 self.alloc.destroy(self); 296 self.alloc.destroy(self);
305 } 297 }
306 298
307 /// The descriptor to poll. Readable does NOT mean "a frame is waiting": 299 /// Readable means a datagram arrived, not that a frame is waiting.
308 /// it means a datagram arrived, which may be an ack, a handshake flight,
309 /// or part of a frame. See `Incoming` in client.zig.
310 pub fn pollFd(self: *const Client) std.posix.fd_t { 300 pub fn pollFd(self: *const Client) std.posix.fd_t {
311 return self.fd; 301 return self.fd;
312 } 302 }
313 303
314 /// Ready to carry the caller's bytes: handshake finished AND the stream 304 /// Handshake finished AND the stream open: a handshake without a stream
315 /// open. Both, because a handshake without a stream has nowhere to put 305 /// silently holds everything in the ring.
316 /// them and would silently hold everything in the ring.
317 pub fn isReady(self: *const Client) bool { 306 pub fn isReady(self: *const Client) bool {
318 return self.handshake_done and self.stream_id != -1 and !self.dead; 307 return self.handshake_done and self.stream_id != -1 and !self.dead;
319 } 308 }
@@ -332,8 +321,8 @@ pub const Client = struct {
332 return @intCast(@min(@as(u64, @intCast(cap_ms)), (expiry - now) / 1_000_000)); 321 return @intCast(@min(@as(u64, @intCast(cap_ms)), (expiry - now) / 1_000_000));
333 } 322 }
334 323
335 /// One service pass: take what the socket has, run whatever timers are 324 /// One service pass: read, run due timers, push ready egress. Safe to call
336 /// due, push whatever egress is ready. Safe to call at any time. 325 /// at any time.
337 pub fn pump(self: *Client) void { 326 pub fn pump(self: *Client) void {
338 if (self.dead) return; 327 if (self.dead) return;
339 self.readable(); 328 self.readable();
@@ -341,18 +330,8 @@ pub const Client = struct {
341 self.drain(); 330 self.drain();
342 } 331 }
343 332
344 /// The one verdict both socket paths share: ECONNREFUSED on a 333 /// ECONNREFUSED is fatal and reaches whichever syscall runs first, so
345 /// connected UDP socket is an ICMP unreachable — a dead transport, 334 /// both paths must act.
346 /// not a blip. recv sees it after a failed flight; send sees it
347 /// when the queued error is delivered on the NEXT syscall, which
348 /// on a quiet connection is drain's send. Both must agree, because
349 /// the error goes to whichever syscall runs first after it is
350 /// queued and is cleared by it — if that path does not act, nothing
351 /// else ever sees it.
352 ///
353 /// The parameter is the union of the two call sites' error sets
354 /// rather than `anyerror`, so a misspelled prong below is a compile
355 /// error instead of an arm that silently never matches.
356 fn sendRecvFailed( 335 fn sendRecvFailed(
357 self: *Client, 336 self: *Client,
358 err: (std.posix.RecvFromError || std.posix.SendError), 337 err: (std.posix.RecvFromError || std.posix.SendError),
@@ -457,11 +436,9 @@ pub const Client = struct {
457 } 436 }
458 } 437 }
459 438
460 /// Take what fits and report how much. A short return is the caller's 439 /// A short return is the caller's signal to keep the rest and offer it
461 /// signal to keep the rest and offer it again — the same contract the 440 /// again: the ring is bounded, so the backlog belongs with somebody who
462 /// daemon's sink obeys, and for the same reason: the ring is bounded, so 441 /// can see how big it is.
463 /// somebody has to hold the backlog and it should be somebody who can
464 /// see how big it is.
465 pub fn send(self: *Client, bytes: []const u8) usize { 442 pub fn send(self: *Client, bytes: []const u8) usize {
466 if (self.dead or self.stream_id == -1) return 0; 443 if (self.dead or self.stream_id == -1) return 0;
467 const n = self.out.push(bytes); 444 const n = self.out.push(bytes);
src/quic_server.zig
Old New
@@ -8,11 +8,8 @@
8 //! exported for server.zig's benefit and is scaffolding, not a transport. 8 //! exported for server.zig's benefit and is scaffolding, not a transport.
9 //! The shipping client is quic_client.zig. 9 //! The shipping client is quic_client.zig.
10 //! 10 //!
11 //! This file follows proxy.zig's discipline and for the same reason: it 11 //! This file follows proxy.zig's discipline: it knows NOTHING about the
12 //! knows NOTHING about the frame protocol it carries. It moves bytes 12 //! frame protocol it carries. If a `proto.` import ever appears here,
13 //! between a QUIC stream and a callback the daemon supplies, and the daemon
14 //! does the framing — so the claim "the transport is a swap" stays checkable
15 //! rather than aspirational. If a `proto.` import ever appears here,
16 //! something has gone wrong. 13 //! something has gone wrong.
17 //! 14 //!
18 //! Authentication is TLS 1.3 external PSK: both ends hold the same 32-byte 15 //! Authentication is TLS 1.3 external PSK: both ends hold the same 32-byte
@@ -178,9 +175,8 @@ fn cidEql(cid: *const c.ngtcp2_cid, bytes: []const u8) bool {
178 return std.mem.eql(u8, cid.data[0..cid.datalen], bytes); 175 return std.mem.eql(u8, cid.data[0..cid.datalen], bytes);
179 } 176 }
180 177
181 /// The server's variant: mints the CID exactly as the shared callback does, 178 /// Separate from `getNewCidCb` because the test client shares that one and
182 /// and marks the connection's cache stale. Separate from `getNewCidCb` 179 /// its user_data is not a Conn. Marks the CID cache stale.
183 /// because the test client shares that one and its user_data is not a Conn.
184 fn serverGetNewCidCb( 180 fn serverGetNewCidCb(
185 conn: ?*c.ngtcp2_conn, 181 conn: ?*c.ngtcp2_conn,
186 cid: [*c]c.ngtcp2_cid, 182 cid: [*c]c.ngtcp2_cid,
@@ -194,8 +190,8 @@ fn serverGetNewCidCb(
194 return rv; 190 return rv;
195 } 191 }
196 192
197 /// A CID was retired: the cache must stop matching it, or this listener 193 /// A retired CID must stop matching, or this listener answers to a name
198 /// would keep answering to a name the peer has been told to forget. 194 /// the peer forgot.
199 fn serverRemoveCidCb( 195 fn serverRemoveCidCb(
200 _: ?*c.ngtcp2_conn, 196 _: ?*c.ngtcp2_conn,
201 _: [*c]const c.ngtcp2_cid, 197 _: [*c]const c.ngtcp2_cid,
@@ -213,9 +209,8 @@ fn handshakeCompletedCb(_: ?*c.ngtcp2_conn, user_data: ?*anyopaque) callconv(.c)
213 return 0; 209 return 0;
214 } 210 }
215 211
216 /// The peer acknowledged stream bytes, so the space they occupy can be 212 /// The ONLY thing that frees egress space; registering it is what makes
217 /// reused. This callback is the ONLY thing that frees egress space, and 213 /// the no-move invariant affordable.
218 /// registering it is what makes the buffer's no-move invariant affordable.
219 fn ackedStreamDataCb( 214 fn ackedStreamDataCb(
220 _: ?*c.ngtcp2_conn, 215 _: ?*c.ngtcp2_conn,
221 _: i64, 216 _: i64,
@@ -309,14 +304,8 @@ pub const Listener = struct {
309 /// silently disables every teardown that consults it. 304 /// silently disables every teardown that consults it.
310 ngtcp2_depth: u8 = 0, 305 ngtcp2_depth: u8 = 0,
311 306
312 /// Bind the socket and stand up TLS: everything that can fail for 307 /// Bound BEFORE the daemon's socket: a refused UDP port must not cost a
313 /// reasons outside this process. Split from the handler because the 308 /// live shell.
314 /// daemon that will own these connections does not exist until its own
315 /// socket is bound, and this one has to be bound FIRST — otherwise a
316 /// refused UDP port has already cost a session socket and a live shell.
317 ///
318 /// A listener returned from here drops anything that arrives until
319 /// `setHandler` is called. Nothing polls it before then.
320 pub fn bind( 309 pub fn bind(
321 alloc: std.mem.Allocator, 310 alloc: std.mem.Allocator,
322 bind_addr: std.net.Address, 311 bind_addr: std.net.Address,
@@ -335,8 +324,7 @@ pub const Listener = struct {
335 self.handler = h; 324 self.handler = h;
336 } 325 }
337 326
338 /// Does ngtcp2 own the stack right now? Anything that frees a 327 /// Anything that frees or writes a connection has to ask.
339 /// connection, or writes to one, has to ask.
340 fn inNgtcp2(self: *const Listener) bool { 328 fn inNgtcp2(self: *const Listener) bool {
341 return self.ngtcp2_depth > 0; 329 return self.ngtcp2_depth > 0;
342 } 330 }
@@ -405,8 +393,7 @@ pub const Listener = struct {
405 self.alloc.destroy(self); 393 self.alloc.destroy(self);
406 } 394 }
407 395
408 /// The descriptor the daemon polls. One socket for every peer — which is 396 /// One socket for every peer — a client slot cannot be an fd.
409 /// the whole reason a client slot cannot be identified by an fd.
410 pub fn pollFd(self: *const Listener) std.posix.fd_t { 397 pub fn pollFd(self: *const Listener) std.posix.fd_t {
411 return self.fd; 398 return self.fd;
412 } 399 }
@@ -420,42 +407,15 @@ pub const Listener = struct {
420 return null; 407 return null;
421 } 408 }
422 409
423 /// Queue what fits of `bytes` for a peer's stream and report how many 410 /// QUEUE ONLY; a short return is backpressure. ngtcp2 is not re-entrant,
424 /// were taken. Egress is drained here and again on every event, so a 411 /// and draining from inside its stream callback aborts. `drainAll` drains.
425 /// caller never has to think about flushing.
426 ///
427 /// A short return is the backpressure signal, and it has to exist: while
428 /// this accepted everything unconditionally, a peer that stopped reading
429 /// grew an unbounded buffer down here, where nothing watches it, instead
430 /// of tripping the daemon's `pending_cap` up where something does. The
431 /// contract now matches the socket sink's — take what you can, tell the
432 /// truth about how much — so one rule bounds both kinds of client.
433 /// QUEUE ONLY — never drains, and that is the fix for a real defect
434 /// rather than a stylistic preference.
435 ///
436 /// ngtcp2 is not re-entrant, and draining here re-entered it. The chain
437 /// was synchronous and entirely ordinary: read_pkt -> recv_stream_data
438 /// callback -> the daemon's frame handling -> a reply queued -> send ->
439 /// drain -> writev_stream on the SAME connection while read_pkt was
440 /// still on the stack below. Two things go wrong there. Monotonic
441 /// timestamps move backwards within one read_pkt, which quietly corrupts
442 /// loss detection; and when a datagram carries a STREAM frame ahead of
443 /// an ACK, the nested write mutates the retransmission buffer that the
444 /// outer ack walk is about to traverse. ngtcp2_unreachable() aborts
445 /// unconditionally even under NDEBUG, so the symptom is a bare SIGABRT
446 /// with no panic banner and no defers run — and it depends on traffic
447 /// shape, which is why it presented as a test failing once in hundreds.
448 ///
449 /// Draining now happens only where the stack is ours: after read_pkt
450 /// returns, in tick, and in the daemon's explicit drainAll.
451 pub fn send(self: *Listener, id: u64, bytes: []const u8) !usize { 412 pub fn send(self: *Listener, id: u64, bytes: []const u8) !usize {
452 const cn = self.find(id) orelse return error.NoSuchConn; 413 const cn = self.find(id) orelse return error.NoSuchConn;
453 return cn.out.push(bytes); 414 return cn.out.push(bytes);
454 } 415 }
455 416
456 /// Push every connection's egress. The daemon calls this once per pump, 417 /// Push every connection's egress. Called once per pump after frame
457 /// after all frame handling, which is what keeps queue-only from costing 418 /// handling, so queue-only costs no latency.
458 /// a poll cycle of latency on every reply.
459 pub fn drainAll(self: *Listener) void { 419 pub fn drainAll(self: *Listener) void {
460 for (&self.conns) |*slot| { 420 for (&self.conns) |*slot| {
461 const cn = slot.* orelse continue; 421 const cn = slot.* orelse continue;
@@ -463,40 +423,29 @@ pub const Listener = struct {
463 } 423 }
464 } 424 }
465 425
466 /// Bytes accepted from the owner that the peer has not acknowledged. 426 /// Bytes accepted from the owner but not acknowledged. A QUIC send is
467 /// Zero means this connection owes nothing — the only honest answer to 427 /// done at the ack, not at the syscall.
468 /// "has it all gone out", since a QUIC send is not done when the syscall
469 /// returns but when the ack arrives.
470 pub fn pendingBytes(self: *Listener, id: u64) usize { 428 pub fn pendingBytes(self: *Listener, id: u64) usize {
471 const cn = self.find(id) orelse return 0; 429 const cn = self.find(id) orelse return 0;
472 return cn.out.held; 430 return cn.out.held;
473 } 431 }
474 432
475 /// Push a connection's egress along without an event to hang it off — 433 /// Push a connection's egress with no event to hang it off: draining
476 /// what the daemon calls when it is draining on the way out, or when 434 /// out, or after acks free room.
477 /// acks have just freed room that queued bytes are waiting for.
478 pub fn kick(self: *Listener, id: u64) void { 435 pub fn kick(self: *Listener, id: u64) void {
479 const cn = self.find(id) orelse return; 436 const cn = self.find(id) orelse return;
480 self.drain(cn); 437 self.drain(cn);
481 } 438 }
482 439
483 /// Close ONE connection and nothing else — never the socket it shares. 440 /// Close ONE connection and nothing else — never the socket it shares.
441 /// Every QUIC peer is multiplexed over one UDP socket, so closing "the
442 /// client's transport" would take down every other session.
484 /// 443 ///
485 /// The distinction is the whole reason a client slot stopped being a 444 /// Two things it deliberately does NOT do. It does not invoke `onClose`:
486 /// descriptor: every QUIC peer on this daemon is multiplexed over one 445 /// calling back into a handler mid-teardown is how re-entrancy bugs start,
487 /// UDP socket, so closing "the client's transport" because one session 446 /// and only `kill` calls back. And it does not send CONNECTION_CLOSE, so
488 /// ended would take down every other session with it. 447 /// the peer finds out when its idle timer expires; that cost is accepted,
489 /// 448 /// and a graceful close belongs with the client transport.
490 /// Two things this deliberately does NOT do, both of which callers have
491 /// to know. It does not invoke `onClose`: a close the owner asked for
492 /// needs no callback telling the owner what it just did, and calling
493 /// back into a handler mid-teardown is how re-entrancy bugs start. Only
494 /// `kill` — the listener deciding a connection is finished — calls back.
495 /// And it does not send CONNECTION_CLOSE: the peer finds out when its
496 /// idle timer expires. That is a real cost (a client learns of a
497 /// deliberate close no faster than of a crash) and it is accepted for
498 /// now rather than unnoticed; a graceful close belongs with the client
499 /// transport, which is the side that would act on it.
500 pub fn closeConn(self: *Listener, id: u64) void { 449 pub fn closeConn(self: *Listener, id: u64) void {
501 for (&self.conns) |*slot| { 450 for (&self.conns) |*slot| {
502 if (slot.*) |cn| { 451 if (slot.*) |cn| {
@@ -538,9 +487,8 @@ pub const Listener = struct {
538 } 487 }
539 } 488 }
540 489
541 /// An unknown connection ID. Either a new peer or noise; either way the 490 /// An unknown connection ID. The address is unvalidated, so amplification
542 /// address is unvalidated, so this is where amplification protection 491 /// protection lives here.
543 /// lives.
544 fn accept( 492 fn accept(
545 self: *Listener, 493 self: *Listener,
546 pkt: []const u8, 494 pkt: []const u8,
@@ -1497,10 +1445,8 @@ fn openStream(l: *Listener, cl: *TestClient, owner: *EchoOwner) !void {
1497 cl.echoed = 0; 1445 cl.echoed = 0;
1498 } 1446 }
1499 1447
1500 /// Drive both ends for a wall-clock budget, unconditionally. `pump` counts 1448 /// `pump` counts only quiet iterations toward its deadline, which is wrong
1501 /// only quiet iterations toward its deadline, which is right for waiting on 1449 /// for a bulk transfer that is busy throughout.
1502 /// an event and wrong for a bulk transfer that is busy the whole time and
1503 /// must still be prevented from hanging a suite.
1504 fn pumpUntil( 1450 fn pumpUntil(
1505 l: *Listener, 1451 l: *Listener,
1506 cl: *TestClient, 1452 cl: *TestClient,
src/replica.zig
Old New
@@ -106,22 +106,14 @@ pub const Replica = struct {
106 106
107 pub const AttachArgs = struct { have_seq: u64, have_epoch: u64 }; 107 pub const AttachArgs = struct { have_seq: u64, have_epoch: u64 };
108 108
109 /// What an attach frame quotes: (0,0) until the first snapshot — a 109 /// (0,0) until the first snapshot. The delta-resync re-attach quotes
110 /// fresh replica holds nothing — and (last_seq, session_epoch) after. 110 /// (0,0) at its call site instead: what we hold is untrusted.
111 /// The delta-resync re-attach quotes (0,0) EXPLICITLY at its call site
112 /// instead of using this: there the point is that what we hold is
113 /// untrusted.
114 pub fn attachArgs(self: *const Replica) AttachArgs { 111 pub fn attachArgs(self: *const Replica) AttachArgs {
115 return .{ .have_seq = self.last_seq, .have_epoch = self.session_epoch }; 112 return .{ .have_seq = self.last_seq, .have_epoch = self.session_epoch };
116 } 113 }
117 114
118 /// Screen-space start row for a view scrolled `rows_up` rows back: the 115 /// Rows, not pages: the keys scroll a screenful, the wheel a few
119 /// view begins that far above the live viewport top (row index 116 /// lines; only one can be the wire's.
120 /// history_rows). Saturates at the oldest retained row.
121 ///
122 /// Rows, not pages, because the two things that scroll disagree about
123 /// the unit — the keys move a screenful, the wheel a few lines — and
124 /// only one of them can be the wire's.
125 pub fn scrollStart(self: *const Replica, rows_up: u32) u32 { 117 pub fn scrollStart(self: *const Replica, rows_up: u32) u32 {
126 return self.history_rows -| rows_up; 118 return self.history_rows -| rows_up;
127 } 119 }
src/select.zig
Old New
@@ -8,10 +8,9 @@
8 //! lets two drivers in different layers share one meaning: a zoomed 8 //! lets two drivers in different layers share one meaning: a zoomed
9 //! `interact.Core` at layer 2 and the wall's keyboard loop at layer 4. 9 //! `interact.Core` at layer 2 and the wall's keyboard loop at layer 4.
10 //! 10 //!
11 //! In particular it must not know about `wallview.Stripe`, which is layer 11 //! It must not know about `wallview.Stripe`, layer 4, which would invert
12 //! 4 and would invert the graph. Resolving a terminal row to a session 12 //! the graph. Resolving a terminal row to a session line stays with
13 //! line stays with whoever owns the layout; a column layout later changes 13 //! whoever owns the layout.
14 //! that hit-test and nothing here.
15 //! 14 //!
16 //! Rows are ABSOLUTE — counted from the oldest row the daemon still 15 //! Rows are ABSOLUTE — counted from the oldest row the daemon still
17 //! retains, the coordinate space `protocol.SelectionReq` speaks. A drag 16 //! retains, the coordinate space `protocol.SelectionReq` speaks. A drag
@@ -47,10 +46,6 @@ pub const Range = struct {
47 from: Hit, 46 from: Hit,
48 to: Hit, 47 to: Hit,
49 48
50 /// Which columns of absolute row `row` in tile `tile` this selection
51 /// highlights, on a grid `cols` wide, or null for a row it does not
52 /// cover.
53 ///
54 /// On `Range` rather than `Drag` because two of them get compared: a 49 /// On `Range` rather than `Drag` because two of them get compared: a
55 /// drag that moved repaints the rows whose span CHANGED, which needs 50 /// drag that moved repaints the rows whose span CHANGED, which needs
56 /// the span of a selection that is no longer the live one. 51 /// the span of a selection that is no longer the live one.
@@ -101,10 +96,9 @@ pub const Drag = struct {
101 anchor: Hit = .{ .tile = 0, .row = 0, .col = 0 }, 96 anchor: Hit = .{ .tile = 0, .row = 0, .col = 0 },
102 active: Hit = .{ .tile = 0, .row = 0, .col = 0 }, 97 active: Hit = .{ .tile = 0, .row = 0, .col = 0 },
103 98
104 /// A button went down. Whatever was held is dropped here, however the 99 /// Whatever was held is dropped however the press turns out: a new
105 /// press turns out: a new press is a new selection, and a press on 100 /// press is a new selection, and a press on nothing (a label bar) is
106 /// nothing (a label bar, a row past the last stripe) is the user 101 /// the user putting the old one away.
107 /// putting the old one away.
108 pub fn press(self: *Drag, cell: Cell, hit: ?Hit) void { 102 pub fn press(self: *Drag, cell: Cell, hit: ?Hit) void {
109 const h = hit orelse { 103 const h = hit orelse {
110 self.* = .{}; 104 self.* = .{};
@@ -113,18 +107,12 @@ pub const Drag = struct {
113 self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h }; 107 self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h };
114 } 108 }
115 109
116 /// The pointer moved with the button down. 110 /// Cell, not pixel: `?1002h` reports a CELL change, and a hand
117 /// 111 /// trembling inside one cell still points at one line. Once it IS
118 /// Cell granularity, not pixel: `?1002h` reports motion when the 112 /// a drag it stays one, even back over the press cell.
119 /// pointer changes CELL, and a hand that trembles inside one cell is
120 /// still pointing at one line. Once it IS a drag it stays one, even
121 /// coming back over the press cell — a selection dragged out and back
122 /// is a selection of one cell, not a click.
123 /// 113 ///
124 /// A drag is confined to the tile it started in. Off that tile the 114 /// A drag is confined to its starting tile; off it the active end
125 /// active end simply stops moving: a wall is panes, and a selection 115 /// stops moving, since a leak would ask the wrong session for text.
126 /// that leaked into the neighbour would ask the wrong daemon session
127 /// for its text.
128 pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void { 116 pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void {
129 switch (self.phase) { 117 switch (self.phase) {
130 .idle, .held => return, 118 .idle, .held => return,
@@ -155,23 +143,13 @@ pub const Drag = struct {
155 } 143 }
156 } 144 }
157 145
158 /// Drop the drag and the highlight both. 146 /// Coordinates stopped meaning what they meant.
159 ///
160 /// Every caller is a moment when the coordinates stop meaning what
161 /// they meant: a relayout or a forget re-cuts the stripes under the
162 /// anchor, a zoom transition hands the screen to somebody else, and a
163 /// resync renames the absolute row space outright — a highlight kept
164 /// across one of those is a highlight over rows nobody selected.
165 pub fn clear(self: *Drag) void { 147 pub fn clear(self: *Drag) void {
166 self.* = .{}; 148 self.* = .{};
167 } 149 }
168 150
169 /// Which tile this drag belongs to, or null when there is no drag. 151 /// A press that has not moved yet counts; `range`, by contrast,
170 /// 152 /// answers only about what is on screen.
171 /// A press that has not moved yet counts: its anchor is already on a
172 /// tile, and a caller dropping that tile's coordinates has to drop it
173 /// too. That is the difference from `range`, which answers only about
174 /// what is on screen.
175 pub fn on(self: *const Drag) ?usize { 153 pub fn on(self: *const Drag) ?usize {
176 return if (self.phase == .idle) null else self.anchor.tile; 154 return if (self.phase == .idle) null else self.anchor.tile;
177 } 155 }
src/server.zig
Old New
@@ -39,9 +39,8 @@ const max_observers = 4;
39 /// deadline. 39 /// deadline.
40 const max_drain_stalls = 64; 40 const max_drain_stalls = 64;
41 41
42 /// Has drainPending seen enough consecutive nothing to give up? Progress 42 /// The bound is on a RUN of unproductive wakeups, not their total: a slow peer
43 /// resets the count — the bound is on a RUN of unproductive wakeups, not on 43 /// that keeps taking bytes is not stuck.
44 /// their total, since a slow peer that keeps taking bytes is not stuck.
45 fn stallExhausted(stalls: *usize, progressed: bool) bool { 44 fn stallExhausted(stalls: *usize, progressed: bool) bool {
46 if (progressed) { 45 if (progressed) {
47 stalls.* = 0; 46 stalls.* = 0;
@@ -63,32 +62,18 @@ fn selectionReplyStatus(status: ?Engine.SelectionExtract.Status) proto.Selection
63 }; 62 };
64 } 63 }
65 64
66 /// How long one drainPending wakeup may sleep: never past what ngtcp2 wants 65 /// Both bounds are load-bearing: sleeping past ngtcp2's expiry starves the PTO
67 /// doing next, never less than a millisecond. 66 /// timer, so a lost packet is never resent; an unfloored timeout spins hot on
68 /// 67 /// an expiry ngtcp2 reports as already past.
69 /// Both bounds are load-bearing and each fixes a different failure. Sleeping
70 /// past an expiry starves the PTO timer, and since 207ebbb retransmission
71 /// happens ONLY in tick() — so a lost packet is never resent, the peer has
72 /// nothing to acknowledge, the socket never becomes readable, and the
73 /// connection is wedged for the whole budget. And ngtcp2 reports an expiry
74 /// that is already past until the event clearing it arrives, so an unfloored
75 /// timeout returns instantly and spins the loop hot for the whole budget
76 /// instead of waiting for the acknowledgement that would end it.
77 fn drainWaitMs(remaining: i64, quic_hint: ?i32) i32 { 68 fn drainWaitMs(remaining: i64, quic_hint: ?i32) i32 {
78 const rem: i32 = @intCast(@min(remaining, @as(i64, std.math.maxInt(i32)))); 69 const rem: i32 = @intCast(@min(remaining, @as(i64, std.math.maxInt(i32))));
79 const hint = quic_hint orelse return rem; 70 const hint = quic_hint orelse return rem;
80 return @max(1, @min(hint, rem)); 71 return @max(1, @min(hint, rem));
81 } 72 }
82 73
83 /// The port the kernel actually gave a listener, asked of the socket rather 74 /// Asked of the socket rather than remembered: a lazy bind names port 0 and
84 /// than remembered: a lazy bind names port 0 and only getsockname knows what 75 /// only getsockname knows what came back. 0 on failure, which is the same "no
85 /// came back. 0 on failure, which is the same "no port to announce" the 76 /// port to announce" `endpoint_reply` already spells that way.
86 /// endpoint_reply already spells that way.
87 ///
88 /// Here rather than on Listener beside pollFd, deliberately: the daemon and
89 /// this file's own quic test helper are the only things outside
90 /// quic_server.zig that have ever needed to ask, and that is not enough to
91 /// widen that type's surface.
92 fn boundUdpPort(l: *quic_server.Listener) u16 { 77 fn boundUdpPort(l: *quic_server.Listener) u16 {
93 var actual: std.posix.sockaddr.storage = undefined; 78 var actual: std.posix.sockaddr.storage = undefined;
94 var len: std.posix.socklen_t = @sizeOf(@TypeOf(actual)); 79 var len: std.posix.socklen_t = @sizeOf(@TypeOf(actual));
@@ -164,10 +149,8 @@ const Sink = union(enum) {
164 /// connection id within it. Note what is NOT here — a descriptor. 149 /// connection id within it. Note what is NOT here — a descriptor.
165 quic: struct { listener: *quic_server.Listener, id: u64 }, 150 quic: struct { listener: *quic_server.Listener, id: u64 },
166 151
167 /// The descriptor to poll for this client, or -1 for "nothing of its 152 /// -1 for QUIC: poll(2) ignores negative fds; its readability is the
168 /// own" — poll(2) ignores negative fds, which is exactly the behaviour 153 /// listener's shared socket.
169 /// a QUIC client needs, since its readability is the shared UDP
170 /// socket's and is polled once by the listener rather than per client.
171 fn pollFd(self: Sink) std.posix.fd_t { 154 fn pollFd(self: Sink) std.posix.fd_t {
172 return switch (self) { 155 return switch (self) {
173 .socket => |fd| fd, 156 .socket => |fd| fd,
@@ -175,16 +158,9 @@ const Sink = union(enum) {
175 }; 158 };
176 } 159 }
177 160
178 /// Hand bytes onward without blocking, returning how many were taken. 161 /// A short return leaves the rest in `pending` for the cap to judge; while
179 /// 162 /// the QUIC arm accepted everything, `pending_cap` could never trip and
180 /// For a socket that is send(2) straight to the kernel. For QUIC it is a 163 /// unbounded growth just moved into the listener.
181 /// queue-and-drain into the connection's bounded egress ring, which
182 /// takes what it has room for and says so. Both therefore obey the same
183 /// contract — a short return means the rest stays in `pending` and the
184 /// cap gets to judge it — and that symmetry is load-bearing: while the
185 /// QUIC arm accepted everything, `pending_cap` could never trip for a
186 /// QUIC client, and the unbounded growth simply moved down into the
187 /// listener where nothing was watching for it.
188 fn send(self: Sink, bytes: []const u8) !usize { 164 fn send(self: Sink, bytes: []const u8) !usize {
189 return switch (self) { 165 return switch (self) {
190 .socket => |fd| std.posix.send( 166 .socket => |fd| std.posix.send(
@@ -196,10 +172,8 @@ const Sink = union(enum) {
196 }; 172 };
197 } 173 }
198 174
199 /// Bytes this sink has taken but not yet got off the box. Zero for a 175 /// Zero for a socket: once the kernel has it, it is the kernel's problem.
200 /// socket: once the kernel has it, it is the kernel's problem. For QUIC 176 /// QUIC is not done until the peer acks.
201 /// the send is not finished until the peer acknowledges, so the listener
202 /// is the only thing that knows.
203 fn inFlight(self: Sink) usize { 177 fn inFlight(self: Sink) usize {
204 return switch (self) { 178 return switch (self) {
205 .socket => 0, 179 .socket => 0,
@@ -207,14 +181,8 @@ const Sink = union(enum) {
207 }; 181 };
208 } 182 }
209 183
210 /// Close THIS CLIENT'S channel — never the transport it shares. 184 /// THIS CLIENT'S channel, never the transport it shares: one UDP socket
211 /// 185 /// carries every QUIC client on this daemon.
212 /// For a socket those are the same object, which is exactly why the
213 /// distinction has to be written down before QUIC exists: a QUIC client
214 /// shares one UDP socket with every other session on this daemon, so
215 /// closing that socket because one client left would take down every
216 /// other client with it. `closeConn` tears down the one connection and
217 /// leaves the listener's socket alone. Pinned by a test.
218 fn close(self: Sink) void { 186 fn close(self: Sink) void {
219 switch (self) { 187 switch (self) {
220 .socket => |fd| std.posix.close(fd), 188 .socket => |fd| std.posix.close(fd),
@@ -302,11 +270,9 @@ const AgentSock = struct {
302 fd: std.posix.fd_t, 270 fd: std.posix.fd_t,
303 path: [:0]const u8, 271 path: [:0]const u8,
304 272
305 /// Close AND unlink, always as one act. The descriptor is this 273 /// Close AND unlink as one act: a leftover socket file makes the next
306 /// daemon's, but the name is the filesystem's: a socket file left 274 /// session of that name fail to bind, and `Server.deinit` is too late for
307 /// behind after its session is gone is what makes the next session of 275 /// a live daemon.
308 /// the same name fail to bind, and `Server.deinit`'s deleteTree is too
309 /// late for a daemon that goes on running.
310 fn release(self: AgentSock, alloc: std.mem.Allocator) void { 276 fn release(self: AgentSock, alloc: std.mem.Allocator) void {
311 std.posix.close(self.fd); 277 std.posix.close(self.fd);
312 std.fs.cwd().deleteFile(self.path) catch {}; 278 std.fs.cwd().deleteFile(self.path) catch {};
@@ -319,16 +285,12 @@ const AgentSock = struct {
319 /// from the same number. 285 /// from the same number.
320 const max_agent_chans = proto.agent_chans_max; 286 const max_agent_chans = proto.agent_chans_max;
321 287
322 /// One live agent connection, from the daemon's accept to either end's 288 /// `client` and `session` decide who may speak for a channel — the client
323 /// close. `id` is what both ends call it on the wire; `client` and `session` 289 /// because ids are daemon-wide and a guessed one must not reach a stranger's
324 /// are the two indices that decide who may speak for it — the client because
325 /// ids are daemon-wide and a guessed one must not reach a stranger's
326 /// ssh-agent, the session because a channel dies with the shell that dialled 290 /// ssh-agent, the session because a channel dies with the shell that dialled
327 /// it even when its client attaches elsewhere first. 291 /// it even when its client attaches elsewhere first.
328 /// 292 ///
329 /// No buffer here, deliberately: the daemon never holds agent bytes. What it 293 /// No buffer here: the daemon never holds agent bytes.
330 /// reads it queues at once, and what it is handed it writes at once — see
331 /// `serviceAgentChan`.
332 const AgentChan = struct { 294 const AgentChan = struct {
333 fd: std.posix.fd_t, 295 fd: std.posix.fd_t,
334 id: u32, 296 id: u32,
@@ -463,38 +425,17 @@ const Session = struct {
463 425
464 const kinds = std.enums.values(Engine.SideEvent.Kind); 426 const kinds = std.enums.values(Engine.SideEvent.Kind);
465 427
466 /// Every pending slot, derived from the enum rather than hand-listed. 428 /// Derived from the enum: a new `SideEvent.Kind` fails to compile at
467 /// 429 /// `pendingSlot` instead of leaking unreplayed. Enum order is
468 /// This is what makes the rules above enforceable instead of merely 430 /// `replayPending`'s wire order.
469 /// stated. The three consumers — replay, expiry, teardown — each used to
470 /// write out `{ &pending_clipboard, &pending_bell }`, so a third
471 /// SideEvent.Kind would have compiled clean and been recorded, never
472 /// replayed, never expired, and leaked on teardown: the privacy contract
473 /// would have silently stopped applying to it, which is the one kind of
474 /// rule that must not be able to lapse quietly. Now a new kind fails to
475 /// compile at `pendingSlot`'s switch, and once it has a field every
476 /// consumer picks it up with no edit at all.
477 ///
478 /// The returned order is the enum's declaration order, which is also the
479 /// order `replayPending` puts events on the wire.
480 fn pendingSlots(self: *Session) [kinds.len]*?PendingEvent { 431 fn pendingSlots(self: *Session) [kinds.len]*?PendingEvent {
481 var out: [kinds.len]*?PendingEvent = undefined; 432 var out: [kinds.len]*?PendingEvent = undefined;
482 inline for (kinds, 0..) |k, i| out[i] = self.pendingSlot(k); 433 inline for (kinds, 0..) |k, i| out[i] = self.pendingSlot(k);
483 return out; 434 return out;
484 } 435 }
485 436
486 /// Remember one side-channel event for whoever reconnects into this gap. 437 /// Guarded by the expiry's predicate: an event recorded while `canServe`
487 /// 438 /// is false could never be replayed.
488 /// Guarded by the SAME predicate the expiry uses, and that is the point:
489 /// `canServe(tracker.seq)` is false exactly while this tracker could not
490 /// answer any reattach with a delta — before the first attach built it,
491 /// or after a rebuild that failed — so an event recorded then could never
492 /// be replayed to anyone. Refusing to store it is refusing to hold the
493 /// user's copied text for no possible reader.
494 ///
495 /// A failed dupe drops the event silently, exactly as a failed encode in
496 /// drainSideEvents does and for the same reason: there is nowhere here to
497 /// say so, and what is lost is one replay of one event.
498 fn recordPending( 439 fn recordPending(
499 self: *Session, 440 self: *Session,
500 alloc: std.mem.Allocator, 441 alloc: std.mem.Allocator,
@@ -510,10 +451,9 @@ const Session = struct {
510 slot.* = .{ .seq = self.tracker.seq, .payload = owned }; 451 slot.* = .{ .seq = self.tracker.seq, .payload = owned };
511 } 452 }
512 453
513 /// Drop every pending event the tracker can no longer serve a delta for. 454 /// Called wherever the tracker is rebuilt: a rebuild is the only thing
514 /// Called wherever the tracker is rebuilt — see Server.rebuildTracker — 455 /// that moves reset_seq, and so the only thing that can put a recorded
515 /// because a rebuild is the only thing that moves reset_seq, and so the 456 /// event permanently out of reach.
516 /// only thing that can put a recorded event permanently out of reach.
517 fn dropUnservablePending(self: *Session, alloc: std.mem.Allocator) void { 457 fn dropUnservablePending(self: *Session, alloc: std.mem.Allocator) void {
518 for (self.pendingSlots()) |slot| { 458 for (self.pendingSlots()) |slot| {
519 const p = slot.* orelse continue; 459 const p = slot.* orelse continue;
@@ -581,13 +521,8 @@ pub const Server = struct {
581 /// giving it a Sink would be generality with no second case. If that 521 /// giving it a Sink would be generality with no second case. If that
582 /// ever changes, this is the comment that was wrong. 522 /// ever changes, this is the comment that was wrong.
583 observers: [max_observers]?std.posix.fd_t = @splat(null), 523 observers: [max_observers]?std.posix.fd_t = @splat(null),
584 /// Who owns the QUIC listener. borrowed = attachQuic'd by a caller
585 /// whose deinit it is; owned = lazyBindQuic bound it and deinit
586 /// returns it. The old ?*Listener + bool pair could type the
587 /// unrepresentable (null, owned) state; this cannot.
588 ///
589 /// `.none` is a first-class answer, not a failure: QUIC is opt-in per 524 /// `.none` is a first-class answer, not a failure: QUIC is opt-in per
590 /// invocation and the unix socket is unaffected by its presence. 525 /// invocation.
591 quic: union(enum) { 526 quic: union(enum) {
592 none, 527 none,
593 borrowed: *quic_server.Listener, 528 borrowed: *quic_server.Listener,
@@ -714,23 +649,15 @@ pub const Server = struct {
714 return srv; 649 return srv;
715 } 650 }
716 651
717 /// The directory this daemon's agent sockets live in, or null if it 652 /// Null if it could not be made. Created exclusively at 0700 under a name
718 /// could not be made. 653 /// with a random half: that parent is a shared `/tmp` whenever there is no
654 /// `$XDG_RUNTIME_DIR`, a pid alone is guessable, and an entry pre-created
655 /// there by another user as a symlink would put this daemon's sockets
656 /// somewhere it does not own.
719 /// 657 ///
720 /// Beside the socket, for the reasons `prepareSpawn` gives for putting 658 /// Degrades to null rather than failing the daemon: a session with no
721 /// the shims there — and created exclusively at 0700 under a name with 659 /// forwarded agent is a working session. Said on stderr, because from
722 /// a random half, for the reasons `shellint.install` wrote down at 660 /// inside the shell an absent `SSH_AUTH_SOCK` looks exactly like a client
723 /// length: that parent is a shared `/tmp` whenever there is no
724 /// `$XDG_RUNTIME_DIR`, a pid alone is guessable, and an entry
725 /// pre-created there by another user as a symlink would put this
726 /// daemon's sockets somewhere it does not own. It also settles the
727 /// mundane collision — a SIGKILLed predecessor whose pid we redraw.
728 ///
729 /// Degrades to null rather than failing the daemon, the same posture
730 /// shellint takes: a session with no forwarded agent is a working
731 /// session, and refusing to start over an optional enhancement would
732 /// be the tail wagging the dog. Said on stderr, because from inside
733 /// the shell an absent `SSH_AUTH_SOCK` looks exactly like a client
734 /// that never asked to forward one. 661 /// that never asked to forward one.
735 fn makeAgentDir(alloc: std.mem.Allocator, sock_path: []const u8) ?[]const u8 { 662 fn makeAgentDir(alloc: std.mem.Allocator, sock_path: []const u8) ?[]const u8 {
736 const parent = std.fs.path.dirname(sock_path) orelse "."; 663 const parent = std.fs.path.dirname(sock_path) orelse ".";
@@ -792,15 +719,8 @@ pub const Server = struct {
792 return .{ .fd = listener.stream.handle, .path = path }; 719 return .{ .fd = listener.stream.handle, .path = path };
793 } 720 }
794 721
795 /// Everything one session's shell needs stood up: engine, pty, epoch, 722 /// Takes ownership of `agent`: it lands on the Session or is released
796 /// name. The plan comes in from the caller rather than being derived 723 /// here; no teardown path can reach it otherwise.
797 /// here — see the `spawn_plan` field for why one plan is shared by
798 /// every session this daemon will ever spawn.
799 ///
800 /// Takes ownership of `agent`: it lands on the returned Session, or is
801 /// released here if the spawn fails. A caller binding a socket for a
802 /// session that never starts would otherwise hold the one fd and the
803 /// one filename that no teardown path can reach.
804 fn createSession( 724 fn createSession(
805 alloc: std.mem.Allocator, 725 alloc: std.mem.Allocator,
806 plan: SpawnPlan, 726 plan: SpawnPlan,
@@ -1011,23 +931,14 @@ pub const Server = struct {
1011 self.shellint_arena.deinit(); 931 self.shellint_arena.deinit();
1012 } 932 }
1013 933
1014 /// Callers hold a live `si` or they do not call: the pump's loops walk 934 /// Callers hold a live `si` or they do not call; nothing invents an index.
1015 /// only non-null slots, and the frame handlers derive their index from
1016 /// the client slot an attach stored it in (or from resolveSession, on
1017 /// the attach itself). Nothing invents an index.
1018 fn ses(self: *Server, si: usize) *Session { 935 fn ses(self: *Server, si: usize) *Session {
1019 return &self.sessions[si].?; 936 return &self.sessions[si].?;
1020 } 937 }
1021 938
1022 /// A wire name rendered safe to PRINT. The decoders deliberately do not 939 /// Decoders deliberately do not validate names, so an unfiltered `{s}`
1023 /// validate names (protocol.zig says so, and it is the right call — the 940 /// would hand an operator's terminal ANSI and OSC a peer chose and can
1024 /// daemon answers "no such session" to nonsense rather than inventing a 941 /// repeat.
1025 /// second refusal for it). But stderr is a different audience from the
1026 /// peer that sent the bytes: `status_req`'s payload is the whole frame,
1027 /// bounded only by max_payload, so an unfiltered `{s}` hands an
1028 /// operator's terminal arbitrary bytes — ANSI and OSC included — that a
1029 /// peer chose and can repeat by reconnecting. Validate at the boundary
1030 /// being crossed, which here is daemon → console, not peer → daemon.
1031 fn safeName(wire_name: []const u8) []const u8 { 942 fn safeName(wire_name: []const u8) []const u8 {
1032 const name = proto.resolveName(wire_name); 943 const name = proto.resolveName(wire_name);
1033 return if (proto.validSessionName(name)) name else "<invalid>"; 944 return if (proto.validSessionName(name)) name else "<invalid>";
@@ -1045,30 +956,19 @@ pub const Server = struct {
1045 return null; 956 return null;
1046 } 957 }
1047 958
1048 /// Attach-or-create. Creation demands a size the session can actually 959 /// Attach-or-create. Creation demands a size the session can live at, the
1049 /// live at, and that is the SAME threshold applySize enforces — not a 960 /// SAME threshold `applySize` enforces. A 0x0 attach makes no size claim
1050 /// weaker one. A 0x0 attach makes no size claim at all — muxa, the CLI 961 /// at all — muxa, the wall's stripes, an unzoomed browser tile — and a
1051 /// wall's stripes and an unzoomed browser tile all spell passivity that 962 /// client with no size must never be the reason a shell spawns.
1052 /// way — and a client with no size must never be the reason a shell
1053 /// spawns.
1054 /// 963 ///
1055 /// The threshold has to be the real one and not merely "nonzero", 964 /// 1x1 is a size a client genuinely sends, so gating on merely nonzero
1056 /// because 1x1 is a size a client genuinely sends: a terminal can be 965 /// creates a session `applySize` then refuses to move, which nobody can
1057 /// one column wide. Gating on nonzero forked a shell whose engine, pty 966 /// use or fix.
1058 /// and winsize were all 1x1 — and then applySize refused to move it,
1059 /// recordSize was skipped, the slot stayed 0x0, and claimGrid could
1060 /// never claim, so the client that caused the size could never fix it.
1061 /// A session nobody can use is worse than a refusal nobody can miss.
1062 /// One rule, spelled once, in both places that decide it.
1063 /// 967 ///
1064 /// Null is a refusal (bad name, table full, too small to create at, or 968 /// Null is a refusal: bad name, table full, too small, or a failed spawn.
1065 /// a spawn that failed) — the caller answers exit_status 1, the same 969 /// Only the failed spawn logs, being the one operational cause among four.
1066 /// honest no a full client table gives. Only the failed spawn logs: it 970 /// `createSession` gets the resolved name, so a live session never holds
1067 /// is the one operational cause among four, and the wire says nothing 971 /// an empty one.
1068 /// but "no", so stderr is where an operator tells a crashed fork from a
1069 /// typo'd name. The name handed to createSession is the resolved one —
1070 /// after the ""→default mapping — so a live session never holds an
1071 /// empty name.
1072 fn resolveSession(self: *Server, wire_name: []const u8, cols: u16, rows: u16) ?usize { 972 fn resolveSession(self: *Server, wire_name: []const u8, cols: u16, rows: u16) ?usize {
1073 const name = proto.resolveName(wire_name); 973 const name = proto.resolveName(wire_name);
1074 if (!proto.validSessionName(name)) return null; 974 if (!proto.validSessionName(name)) return null;
@@ -1389,19 +1289,11 @@ pub const Server = struct {
1389 conn.stream.close(); // out of slots 1289 conn.stream.close(); // out of slots
1390 } 1290 }
1391 1291
1392 /// Who answers an agent dial into session `si`: the latest-active client 1292 /// Latest wins, the doctrine the grid follows: the person typing is the
1393 /// among those that offered, or nobody. 1293 /// person whose agent signs. Decided once per connection — an in-flight
1394 /// 1294 /// channel stays pinned to the client it opened on, because swapping
1395 /// Latest wins, the same doctrine the grid follows, applied to keys — 1295 /// identities under a mid-exchange ssh fails the signature rather than
1396 /// the person typing is the person whose agent signs. Anything else 1296 /// moving it.
1397 /// makes "which of my machines just authenticated" a question about
1398 /// connection order. Decided once per connection: an in-flight channel
1399 /// stays pinned to the client it opened on, because the ssh at the far
1400 /// end is mid-exchange and swapping identities under it would fail the
1401 /// signature rather than move it.
1402 ///
1403 /// A slot that has never attached has no session and no activity, so it
1404 /// is excluded by the session test before the ranking ever sees it.
1405 fn agentAnswerer(self: *const Server, si: usize) ?usize { 1297 fn agentAnswerer(self: *const Server, si: usize) ?usize {
1406 var best: ?usize = null; 1298 var best: ?usize = null;
1407 for (self.clients, 0..) |c, i| { 1299 for (self.clients, 0..) |c, i| {
@@ -1412,10 +1304,8 @@ pub const Server = struct {
1412 return best; 1304 return best;
1413 } 1305 }
1414 1306
1415 /// Channels open right now, for `stats`. The refusal counters only mean 1307 /// Refusal counts only mean something next to it: "refused 40, holding 8"
1416 /// something next to it: "refused 40, holding 8" is a full table, while 1308 /// is a full table, "refused 40, holding 0" is nobody offering.
1417 /// "refused 40, holding 0" is nobody offering, and those are different
1418 /// problems with the same symptom.
1419 fn liveAgentChans(self: *const Server) usize { 1309 fn liveAgentChans(self: *const Server) usize {
1420 var n: usize = 0; 1310 var n: usize = 0;
1421 for (self.agent_chans) |slot| { 1311 for (self.agent_chans) |slot| {
@@ -1431,10 +1321,9 @@ pub const Server = struct {
1431 return null; 1321 return null;
1432 } 1322 }
1433 1323
1434 /// An id no live channel holds. Ids are opaque to both ends and only 1324 /// Ids need only be unique among the live channels; skipping the ones in
1435 /// have to be unique among the at most max_agent_chans live channels; 1325 /// the table keeps that true across the counter's wrap instead of arguing
1436 /// skipping the ones in the table keeps that true across the counter's 1326 /// four billion dials cannot happen.
1437 /// wrap instead of arguing that four billion dials cannot happen.
1438 fn nextAgentId(self: *Server) u32 { 1327 fn nextAgentId(self: *Server) u32 {
1439 outer: while (true) { 1328 outer: while (true) {
1440 const id = self.next_agent_id; 1329 const id = self.next_agent_id;
@@ -1448,10 +1337,9 @@ pub const Server = struct {
1448 } 1337 }
1449 } 1338 }
1450 1339
1451 /// The slot holding channel `id` FOR client `owner`, or null. Matching 1340 /// Matching on both is the access rule, not a convenience: ids are daemon-
1452 /// on both is the access rule, not a convenience: ids are daemon-wide, 1341 /// wide, so a client that guessed another's number would otherwise be
1453 /// so a client that guessed another's number would otherwise be talking 1342 /// talking into a stranger's ssh-agent.
1454 /// into a stranger's ssh-agent.
1455 fn findAgentChan(self: *const Server, id: u32, owner: usize) ?usize { 1343 fn findAgentChan(self: *const Server, id: u32, owner: usize) ?usize {
1456 for (self.agent_chans, 0..) |slot, s| { 1344 for (self.agent_chans, 0..) |slot, s| {
1457 const ch = slot orelse continue; 1345 const ch = slot orelse continue;
@@ -1581,9 +1469,9 @@ pub const Server = struct {
1581 } 1469 }
1582 } 1470 }
1583 1471
1584 /// Close every channel into this session. Reachable with a client still 1472 /// Reachable with a client still alive — one that reattached elsewhere
1585 /// alive — a client that reattached elsewhere keeps its channels while 1473 /// keeps its channels while their session dies underneath them — so this
1586 /// their session dies underneath them — so this one notifies. 1474 /// one notifies.
1587 fn closeAgentChansOfSession(self: *Server, si: usize) void { 1475 fn closeAgentChansOfSession(self: *Server, si: usize) void {
1588 for (0..max_agent_chans) |s| { 1476 for (0..max_agent_chans) |s| {
1589 const ch = self.agent_chans[s] orelse continue; 1477 const ch = self.agent_chans[s] orelse continue;
@@ -1632,19 +1520,8 @@ pub const Server = struct {
1632 return self.clients[i] != null; 1520 return self.clients[i] != null;
1633 } 1521 }
1634 1522
1635 /// Encode one correlated selection result in temporary storage, then 1523 /// The lane is TOTAL: a decodable request leaves with exactly one answer,
1636 /// hand the complete payload to the normal per-client queue. Failure in 1524 /// so the fallback reserves capacity first.
1637 /// this isolated scratch buffer has not touched the client's pending
1638 /// bytes, so it costs this reply only; queueFrame retains its own rule
1639 /// for failures after the complete payload reaches the real queue.
1640 ///
1641 /// The lane is TOTAL: a request the daemon could decode leaves with
1642 /// exactly one correlated answer, or the client waits out its timeout
1643 /// for nothing. So a failed encode falls back to `.unavailable` with no
1644 /// text — the one reply that cannot fail validation, leaving allocation
1645 /// as its only remaining way to be lost. The capacity that fallback
1646 /// needs is reserved BEFORE the attempt that can fail, so the retry
1647 /// itself asks the allocator for nothing.
1648 fn queueSelectionReply( 1525 fn queueSelectionReply(
1649 self: *Server, 1526 self: *Server,
1650 i: usize, 1527 i: usize,
@@ -1883,9 +1760,8 @@ pub const Server = struct {
1883 }; 1760 };
1884 } 1761 }
1885 1762
1886 /// The listener regardless of who owns it — for every site that only 1763 /// Ownership is deinit's question alone, and it switches exhaustively
1887 /// wants to service it. Ownership is deinit's question alone, and that 1764 /// rather than calling this.
1888 /// one switches exhaustively rather than calling this.
1889 fn quicListener(self: *const Server) ?*quic_server.Listener { 1765 fn quicListener(self: *const Server) ?*quic_server.Listener {
1890 return switch (self.quic) { 1766 return switch (self.quic) {
1891 .none => null, 1767 .none => null,
@@ -1906,11 +1782,8 @@ pub const Server = struct {
1906 self.quic = .{ .borrowed = listener }; 1782 self.quic = .{ .borrowed = listener };
1907 } 1783 }
1908 1784
1909 /// The endpoint_req answer: the bound QUIC port, standing a listener up 1785 /// 0 means "could not", and the reason goes to the daemon log rather than
1910 /// on demand if none exists. 0 means "could not", and the reason goes to 1786 /// into the frame: the asker can do nothing with it but relay.
1911 /// the daemon log (stderr) rather than into the frame — the asker can
1912 /// do nothing with it but relay, and `muxd endpoint`'s announce-none
1913 /// already tells the client everything it can act on.
1914 fn endpointPort(self: *Server) u16 { 1787 fn endpointPort(self: *Server) u16 {
1915 return self.endpointPortFrom( 1788 return self.endpointPortFrom(
1916 std.posix.getenv("MUX_KEY_FILE"), 1789 std.posix.getenv("MUX_KEY_FILE"),
@@ -1919,15 +1792,8 @@ pub const Server = struct {
1919 ); 1792 );
1920 } 1793 }
1921 1794
1922 /// Env handed in, nothing read: the xdg *From pattern, for the same 1795 /// Env handed in: tests cannot setenv. Refusals log as `muxd: <wire-verb>:
1923 /// reason — tests cannot setenv. Key resolution is MUX_KEY_FILE then 1796 /// <what>`, the arriving verb.
1924 /// the default path; there is no --key half because a daemon being
1925 /// asked lazily is one that was never handed a flag.
1926 ///
1927 /// Every refusal here logs as `muxd: <wire-verb>: <what>` — the verb that
1928 /// arrived, not a CLI invocation, because whoever is reading the daemon
1929 /// log is looking at what the daemon was asked rather than at what
1930 /// somebody typed. New refusals keep that shape.
1931 fn endpointPortFrom( 1797 fn endpointPortFrom(
1932 self: *Server, 1798 self: *Server,
1933 env_key: ?[]const u8, 1799 env_key: ?[]const u8,
@@ -2002,10 +1868,9 @@ pub const Server = struct {
2002 return boundUdpPort(l); 1868 return boundUdpPort(l);
2003 } 1869 }
2004 1870
2005 /// The one frame the QUIC path speaks before a client slot exists: 1871 /// The one frame the QUIC path speaks before a client slot exists: the
2006 /// the full-session refusal. Fixed bytes, no parameters — there is no 1872 /// full-session refusal. Fixed bytes, because there is no second pre-slot
2007 /// second pre-slot frame to generalize for, and a builder taking a 1873 /// frame to generalize for.
2008 /// payload would need a copy loop no caller would ever exercise.
2009 fn refusalFrame() [6]u8 { 1874 fn refusalFrame() [6]u8 {
2010 var buf: [6]u8 = undefined; 1875 var buf: [6]u8 = undefined;
2011 buf[0] = @intFromEnum(proto.MsgType.exit_status); 1876 buf[0] = @intFromEnum(proto.MsgType.exit_status);
@@ -2068,31 +1933,17 @@ pub const Server = struct {
2068 _ = self.queueFrame(i, .pty_mode, &proto.encodePtyMode(flags)); 1933 _ = self.queueFrame(i, .pty_mode, &proto.encodePtyMode(flags));
2069 } 1934 }
2070 1935
2071 /// Tell one client the command state it arrived too late to witness. 1936 /// Gated on marks_seen: a push means a mark was read, never a heuristic
2072 /// Same gap sendPtyModeTo closes, one level up: cmd_state is only ever 1937 /// guess. Sent after the resync — start_row and end_row point into a grid
2073 /// pushed on a transition, so a client attaching between two commands 1938 /// the client must already hold.
2074 /// would otherwise know nothing until the next one happened.
2075 ///
2076 /// Gated on marks_seen, which keeps the rule that a cmd_state push means
2077 /// a mark was read and never that a heuristic guessed — a session that
2078 /// has never spoken marks has nothing honest to say here, and says
2079 /// nothing. (`muxa status` is the way to ask about such a session; it
2080 /// reports the regime rather than claiming a transition.)
2081 ///
2082 /// Sent after the resync rather than before it, which is the opposite of
2083 /// sendPtyModeTo's ordering and for the same underlying reason: start_row
2084 /// and end_row point into the grid, so the client must already hold the
2085 /// grid they point into. Mode bits describe how to read bytes that have
2086 /// not arrived yet; rows describe bytes that have.
2087 fn sendCmdStateTo(self: *Server, si: usize, i: usize) void { 1939 fn sendCmdStateTo(self: *Server, si: usize, i: usize) void {
2088 if (!self.ses(si).cmd.marks_seen) return; 1940 if (!self.ses(si).cmd.marks_seen) return;
2089 _ = self.queueFrame(i, .cmd_state, &proto.encodeCmdState(self.cmdState(si, .marks))); 1941 _ = self.queueFrame(i, .cmd_state, &proto.encodeCmdState(self.cmdState(si, .marks)));
2090 } 1942 }
2091 1943
2092 /// True when client `i` is attached to session `si` — the one filter 1944 /// The one filter every per-session broadcast applies. A promoted-but-
2093 /// every per-session broadcast applies. A promoted-but-unattached slot 1945 /// unattached slot holds no session and receives nothing until it says
2094 /// (a QUIC handshake done, no attach yet) holds no session, matches no 1946 /// which shell it wants.
2095 /// `si`, and so receives nothing until it says which shell it wants.
2096 fn inSession(self: *const Server, i: usize, si: usize) bool { 1947 fn inSession(self: *const Server, i: usize, si: usize) bool {
2097 const slot = self.clients[i] orelse return false; 1948 const slot = self.clients[i] orelse return false;
2098 return (slot.session orelse return false) == si; 1949 return (slot.session orelse return false) == si;
@@ -2809,12 +2660,9 @@ pub const Server = struct {
2809 s.eng.clearPtyOutput(); 2660 s.eng.clearPtyOutput();
2810 } 2661 }
2811 2662
2812 /// Record the grid as client `i`'s size. ONLY call this after an 2663 /// ONLY after an applySize that returned true. After a refusal you record
2813 /// applySize that returned true: the grid is then the size this client 2664 /// some other client's size, which this client then claims the moment it
2814 /// asked for, and recording it is recording the client's own size. Call 2665 /// typed.
2815 /// it after a refusal and you record whatever size the grid happens to
2816 /// hold — some other client's — which this client would then claim as
2817 /// its own the moment it typed.
2818 fn recordSize(self: *Server, si: usize, i: usize) void { 2666 fn recordSize(self: *Server, si: usize, i: usize) void {
2819 if (self.clients[i] == null) return; 2667 if (self.clients[i] == null) return;
2820 self.clients[i].?.cols = self.colsNow(si); 2668 self.clients[i].?.cols = self.colsNow(si);
@@ -2837,27 +2685,18 @@ pub const Server = struct {
2837 self.resyncSnapshot(si); 2685 self.resyncSnapshot(si);
2838 } 2686 }
2839 2687
2840 /// Move client `i` to the front of the activity order. Called from the 2688 /// Called from the three activity verbs — attach, input, resize — and
2841 /// three activity verbs — attach, input, resize — and nowhere else; the 2689 /// nowhere else. Four call sites, not three: attach has two arms.
2842 /// `.input` arm's comment argues which frames those are and why paging
2843 /// scrollback or asking for stats is not one of them. Four call sites,
2844 /// not three: attach has two arms, because a socket client's first
2845 /// attach promotes an observer while every other attach is a frame on an
2846 /// established connection.
2847 fn bumpActivity(self: *Server, i: usize) void { 2690 fn bumpActivity(self: *Server, i: usize) void {
2848 if (self.clients[i] == null) return; 2691 if (self.clients[i] == null) return;
2849 self.activity_clock += 1; 2692 self.activity_clock += 1;
2850 self.clients[i].?.activity = self.activity_clock; 2693 self.clients[i].?.activity = self.activity_clock;
2851 } 2694 }
2852 2695
2853 /// Counterfactual: what a snapshot-only daemon would have sent for 2696 /// Accrued once per update, not once per recipient, so the delta-vs-
2854 /// this one update as a 2697 /// snapshot ratio means the same however many clients attach. Pays a full
2855 /// full snapshot. Accrued once per update event, not once per 2698 /// serialization per update purely to measure the saving; fine for a
2856 /// recipient, so the delta-vs-snapshot ratio keeps meaning the same 2699 /// prototype.
2857 /// thing however many clients are attached.
2858 /// This deliberately pays for a full snapshot serialization per update
2859 /// purely to measure the saving, which is fine for a prototype; put it
2860 /// behind an option if it ever costs anything.
2861 fn accrueSnapshotEquiv(self: *Server, si: usize) void { 2700 fn accrueSnapshotEquiv(self: *Server, si: usize) void {
2862 if (self.ses(si).eng.dumpState(self.alloc)) |state| { 2701 if (self.ses(si).eng.dumpState(self.alloc)) |state| {
2863 self.stats.snapshot_equiv_bytes += proto.snapshot_prefix_len + state.len; 2702 self.stats.snapshot_equiv_bytes += proto.snapshot_prefix_len + state.len;
@@ -3087,15 +2926,12 @@ pub const Server = struct {
3087 }; 2926 };
3088 } 2927 }
3089 2928
3090 /// Sample the session's terminal modes and tell its clients when they 2929 /// No history is kept: a reattaching client needs the current value, which
3091 /// changed. Sampled rather than intercepted because a mode has no 2930 /// is also why sendResync sends it unconditionally.
3092 /// history worth keeping: a reattaching client needs the current value,
3093 /// which is also why sendResync sends it unconditionally.
3094 /// 2931 ///
3095 /// The early return is the whole point. A frame per pty chunk would be a 2932 /// The early return is the whole point. Modes change perhaps twice in a
3096 /// bandwidth regression on a protocol whose discipline is "bytes 2933 /// session's life while chunks arrive by the thousand, and a frame per
3097 /// proportional to what changed", and modes change perhaps twice in a 2934 /// chunk would break the "bytes proportional to what changed" discipline.
3098 /// session's life while chunks arrive by the thousand.
3099 fn sampleTermModes(self: *Server, si: usize) void { 2935 fn sampleTermModes(self: *Server, si: usize) void {
3100 const s = self.ses(si); 2936 const s = self.ses(si);
3101 const now = sampledModes(s.eng); 2937 const now = sampledModes(s.eng);
@@ -3109,19 +2945,14 @@ pub const Server = struct {
3109 } 2945 }
3110 } 2946 }
3111 2947
3112 /// Sample the session's window title and tell its clients when it
3113 /// changed. Same sampled-state discipline as `sampleTermModes`, and the
3114 /// same early return for the same reason: a title changes when you cd or
3115 /// start an editor, not per chunk.
3116 ///
3117 /// An empty title is NOT sent, and that is the whole of mux's policy on 2948 /// An empty title is NOT sent, and that is the whole of mux's policy on
3118 /// clearing. `ESC]0;BEL` on the client would wipe whatever the user's 2949 /// clearing. `ESC]0;BEL` on the client would wipe whatever the user's own
3119 /// own terminal had in its title bar, and a session that never set a 2950 /// terminal had in its title bar, and a session that never set a title has
3120 /// title has said nothing that entitles mux to do that — silence is not 2951 /// said nothing that entitles mux to do that — silence is not "set it to
3121 /// "set it to empty". The consequence accepted: a session that sets a 2952 /// empty". The consequence accepted: a session that sets a title and then
3122 /// title and then genuinely clears it leaves the last one standing. 2953 /// genuinely clears it leaves the last one standing. `sendResync` applies
3123 /// `sendResync` applies the same two rules; they must agree, or an 2954 /// the same two rules; they must agree, or an attach would assert
3124 /// attach would assert something the sampler would never have sent. 2955 /// something the sampler would never have sent.
3125 fn sampleTermTitle(self: *Server, si: usize) void { 2956 fn sampleTermTitle(self: *Server, si: usize) void {
3126 const s = self.ses(si); 2957 const s = self.ses(si);
3127 const now = s.eng.title(); 2958 const now = s.eng.title();
@@ -3159,24 +2990,8 @@ pub const Server = struct {
3159 }; 2990 };
3160 } 2991 }
3161 2992
3162 /// The state an await resolved by something OTHER than the marks stream 2993 /// The seq override swaps cmdState's RETURN watermark for the tracker's
3163 /// answers with: `cmdState` for the live picture, then the overrides 2994 /// seq; `proto.CmdState.seq` documents both meanings.
3164 /// that mechanism is entitled to make. One owner for all of them,
3165 /// because the three fallback arms in `checkAwaits` differ only in
3166 /// which overrides they take, and hand-patching the struct at each site
3167 /// made a set of deliberate differences look like three drifting copies.
3168 ///
3169 /// The seq override is unconditional and is the subtle one: it replaces
3170 /// cmdState's RETURN watermark with the delta tracker's current seq.
3171 /// That is deliberate — it orders the reply against the grid content
3172 /// the client has, which is what a fallback answer is actually about —
3173 /// and it is why proto.CmdState.seq documents two meanings. A client
3174 /// that took its next `since_seq` from here would be using a number
3175 /// from the wrong series; see that doc comment for the rule and for the
3176 /// open question of whether these arms should move the watermark at all.
3177 ///
3178 /// `phase` and `clear_exit_code` default to leaving what cmdState built:
3179 /// a mechanism overrides only what it can actually claim to know.
3180 fn fallbackState( 2995 fn fallbackState(
3181 self: *Server, 2996 self: *Server,
3182 si: usize, 2997 si: usize,
@@ -3238,33 +3053,9 @@ pub const Server = struct {
3238 return payload; 3053 return payload;
3239 } 3054 }
3240 3055
3241 /// Re-hash the session's grid, and drop any pending side-channel event 3056 /// One call because a rebuild is the ONLY thing that narrows the servable
3242 /// the rebuild just put out of reach. False means the rebuild failed and 3057 /// span. The `defer` registers before the call because a FAILED rebuild
3243 /// the caller has nothing to send. 3058 /// narrows it too — payloads permanently undeliverable and still resident.
3244 ///
3245 /// The pair is one call because a rebuild is the ONLY thing that narrows
3246 /// the servable span: it moves reset_seq forward, and it is the only
3247 /// writer of that field. Every other change to the tracker raises seq,
3248 /// which can only widen the span. So this is exactly the set of moments
3249 /// at which the user's copied text stops having any possible reader, and
3250 /// a rebuild site that skipped the drop would be a session holding a
3251 /// clipboard nobody can ever be given.
3252 ///
3253 /// `defer`, and registered BEFORE the call, because a FAILED rebuild
3254 /// narrows the span too — which the "only widens" reasoning above does
3255 /// not cover, and an early return would have missed. `rebuild` can fail
3256 /// at two POINTS, not in two ways — both are allocation failures — and
3257 /// where it fails is what matters (delta.zig): a failure in the resize
3258 /// block backs out before touching any field, so the drop correctly
3259 /// retains everything; a failure inside the dump loop happens after
3260 /// reset_seq has already advanced and leaves `rows` at 0, which makes
3261 /// canServe false for every seq in existence. That is precisely the
3262 /// state the privacy rule names — payloads permanently undeliverable and
3263 /// still resident — arrived at on the one path that used to skip the
3264 /// drop. Unconditional is safe because the drop is predicated on
3265 /// canServe, not on which branch got here; both halves of that predicate
3266 /// are pinned by direct call in the expiry tests, since neither failure
3267 /// point is reachable from a test.
3268 fn rebuildTracker(self: *Server, si: usize) bool { 3059 fn rebuildTracker(self: *Server, si: usize) bool {
3269 const s = self.ses(si); 3060 const s = self.ses(si);
3270 defer s.dropUnservablePending(self.alloc); 3061 defer s.dropUnservablePending(self.alloc);
@@ -3272,28 +3063,15 @@ pub const Server = struct {
3272 return true; 3063 return true;
3273 } 3064 }
3274 3065
3275 /// Rebuild tracking and broadcast a full snapshot (resize, screen 3066 /// Every client of THIS session gets it: these events change the grid
3276 /// switch, or an update that could not be expressed as a delta). Every 3067 /// under everyone watching it. The rebuild happens even with nobody
3277 /// client of THIS session gets it: these events change the grid under 3068 /// attached, so the tracker stays usable for the next attach.
3278 /// everyone watching it, and nobody else can see the grid it repaints.
3279 /// The rebuild happens even with nobody attached, so the tracker stays
3280 /// usable for the next attach.
3281 ///
3282 /// That "still do the bookkeeping, then bail on the sending" shape used
3283 /// to be shared with `drainSideEvents`, which cited it. It no longer is,
3284 /// and the divergence is deliberate: the pending slots gave that drain
3285 /// something to fold into session state on the way past, so it now
3286 /// encodes for nobody on purpose. The doctrine here is unchanged — this
3287 /// is the note for anyone auditing it top-down and finding one sibling
3288 /// out of step.
3289 /// 3069 ///
3290 /// Carries no term_modes, unlike sendResync, and the reason is not local 3070 /// Carries no term_modes, unlike sendResync: no event reaching here
3291 /// to this function: no event reaching here changes a mode, and every 3071 /// changes a mode, and every attached client has already been told the
3292 /// attached client has already been told the current value — 3072 /// current value — sampleTermModes broadcasts on change, and sendResync
3293 /// sampleTermModes broadcasts on change, and sendResync runs on BOTH 3073 /// runs on BOTH attach paths. A caller that reaches here for a client
3294 /// attach paths (the client arm and the observer promotion). A caller 3074 /// which has never been through an attach breaks that, silently.
3295 /// that reaches here for a client which has never been through an attach
3296 /// is what breaks that, and it breaks silently.
3297 fn resyncSnapshot(self: *Server, si: usize) void { 3075 fn resyncSnapshot(self: *Server, si: usize) void {
3298 if (!self.rebuildTracker(si)) return; 3076 if (!self.rebuildTracker(si)) return;
3299 if (!self.hasClientsIn(si)) return; 3077 if (!self.hasClientsIn(si)) return;
@@ -3314,13 +3092,11 @@ pub const Server = struct {
3314 if (sent) self.stats.snapshot_equiv_bytes += payload.len; 3092 if (sent) self.stats.snapshot_equiv_bytes += payload.len;
3315 } 3093 }
3316 3094
3317 /// Rebuild tracking and snapshot exactly one client. A join at the 3095 /// A join at the current size is a discontinuity for the joiner alone:
3318 /// current size is a discontinuity for the joiner alone: everyone else 3096 /// everyone else is already current, so sending them a full repaint would
3319 /// is already current, so sending them a full repaint would be pure 3097 /// be pure waste. The rebuild still bumps seq for all of them, which they
3320 /// waste. The rebuild still bumps seq for all of them, which they 3098 /// absorb silently — a client reads the seq it is given and never checks
3321 /// absorb silently — a client reads the seq it is given and never 3099 /// it for contiguity, so the next delta simply carries a higher number.
3322 /// checks it for contiguity, so the next delta simply carries a
3323 /// higher number.
3324 fn snapshotTo(self: *Server, si: usize, i: usize) void { 3100 fn snapshotTo(self: *Server, si: usize, i: usize) void {
3325 if (!self.rebuildTracker(si)) return; 3101 if (!self.rebuildTracker(si)) return;
3326 if (self.clients[i] == null) return; 3102 if (self.clients[i] == null) return;
@@ -3389,51 +3165,16 @@ pub const Server = struct {
3389 } 3165 }
3390 } 3166 }
3391 3167
3392 /// Hand a delta-served reattach the side-channel events it slept through. 3168 /// The delta branch of `sendResync` is the only caller: a client the grid
3393 /// The delta branch of `sendResync` is the only caller, and that IS the 3169 /// believes watched continuously is owed its gap, while one repainted from
3394 /// rule: a client the grid believes has been watching continuously is 3170 /// scratch is a stranger — replaying a stale clipboard write would hijack
3395 /// owed what happened in its gap, and one being repainted from scratch is 3171 /// its user's clipboard now.
3396 /// a stranger to this session — a twenty-minute-old clipboard write
3397 /// hijacking its user's clipboard now would be a bug wearing a feature's
3398 /// clothes.
3399 /// 3172 ///
3400 /// The snapshot branch is guarded twice over, which was found by mutation 3173 /// Wire order is delta → events: the host terminal ACTS on a bell or
3401 /// rather than designed: every snapshot path goes through a rebuild, the 3174 /// clipboard set, and acting before the repaint dings about a screen the
3402 /// rebuild moves reset_seq past every recorded seq, and rebuildTracker 3175 /// user cannot see yet.
3403 /// drops what it has just put out of reach. So a call to this added AFTER
3404 /// a snapshotTo finds both slots already empty and changes nothing. The
3405 /// two guards are not redundant — a rebuild that fails leaves only the
3406 /// branch holding the line — but it does mean no test can tell that
3407 /// particular mutation from the real thing.
3408 /// 3176 ///
3409 /// Called from inside the branch rather than deferred alongside the modes 3177 /// `> have_seq`, not `>=`: an event at the client's own seq is one it saw.
3410 /// and title, so the wire order is delta → events → modes → title. Only
3411 /// the first of those arrows is load-bearing: a bell or a clipboard set
3412 /// is something the host terminal ACTS on, and acting before the repaint
3413 /// lands is dinging about a screen the user cannot see yet. Modes and
3414 /// title are inert state with no relationship to either, so putting the
3415 /// events after them would have bought nothing and cost the branch its
3416 /// one straight-line reading. (Deferring them later is not even
3417 /// available: defers unwind last-registered-first, so anything registered
3418 /// here would run BEFORE the sampled-state block at the top.)
3419 ///
3420 /// `> have_seq`, not `>=`: an event stamped at the seq the client already
3421 /// quotes is one it was there for, and replaying it would set the
3422 /// clipboard twice for a client that never missed anything.
3423 ///
3424 /// That used to cost an invisible chunk its replay: update() answers
3425 /// `.none` when no cell moved, so a bare BEL or an OSC 52 with no redraw
3426 /// behind it stayed stamped at the seq the gap began on, and this loop
3427 /// refused it. It no longer does — and not by design. A gap is by
3428 /// definition unattached, `noteBlind` has no `.none` case, so every pty
3429 /// chunk during one advances seq and every event drained after it lands
3430 /// strictly above the departed client's watermark. Pinned in delta.zig
3431 /// ("a blind chunk that changed nothing still advances seq"), because
3432 /// nothing on this side would notice it going away again.
3433 ///
3434 /// The `>` still binds for an ATTACHED client, which is what it was
3435 /// written for: the alternative is handing duplicates to everyone who
3436 /// was watching, and that is still the wrong trade.
3437 fn replayPending(self: *Server, si: usize, i: usize, have_seq: u64) void { 3178 fn replayPending(self: *Server, si: usize, i: usize, have_seq: u64) void {
3438 const s = self.ses(si); 3179 const s = self.ses(si);
3439 // Enum declaration order, so clipboard precedes bell. Fixed rather 3180 // Enum declaration order, so clipboard precedes bell. Fixed rather
@@ -3510,13 +3251,8 @@ pub const Server = struct {
3510 return w.buffered(); 3251 return w.buffered();
3511 } 3252 }
3512 3253
3513 /// How many client slots are occupied right now. 3254 /// A gauge, not a counter: a QUIC handshake that never attaches holds a
3514 /// 3255 /// slot until its idle timeout, unobservably.
3515 /// Not a counter but a gauge, and the only field here that can go down.
3516 /// It exists because slot occupancy was previously unobservable from
3517 /// outside: a QUIC connection that completes its handshake and never
3518 /// attaches holds a slot until its idle timeout, and there was no way to
3519 /// see that happening — or to see it clear — without attaching a debugger.
3520 fn liveClients(self: *const Server) usize { 3256 fn liveClients(self: *const Server) usize {
3521 var n: usize = 0; 3257 var n: usize = 0;
3522 for (self.clients) |slot| { 3258 for (self.clients) |slot| {
@@ -3525,9 +3261,8 @@ pub const Server = struct {
3525 return n; 3261 return n;
3526 } 3262 }
3527 3263
3528 /// How many live sessions there are right now — the count `sessions=` 3264 /// The count `sessions=` on the stats main line, and the number of per-
3529 /// on the stats main line, and the number of per-session tail segments 3265 /// session tail segments to expect after it.
3530 /// to expect after it.
3531 fn liveSessions(self: *const Server) usize { 3266 fn liveSessions(self: *const Server) usize {
3532 var n: usize = 0; 3267 var n: usize = 0;
3533 for (self.sessions) |slot| { 3268 for (self.sessions) |slot| {
@@ -3536,10 +3271,8 @@ pub const Server = struct {
3536 return n; 3271 return n;
3537 } 3272 }
3538 3273
3539 /// Client slots attached to session `si` specifically, as opposed to 3274 /// The number the per-session stats segment reports: who is watching THIS
3540 /// `liveClients`'s daemon-wide count — the number the per-session 3275 /// shell, not how many sockets are open at all.
3541 /// stats segment reports, so it answers "who is watching THIS shell"
3542 /// rather than "how many sockets are open at all".
3543 fn clientsInSession(self: *const Server, si: usize) usize { 3276 fn clientsInSession(self: *const Server, si: usize) usize {
3544 var n: usize = 0; 3277 var n: usize = 0;
3545 for (0..max_clients) |i| { 3278 for (0..max_clients) |i| {
@@ -3548,24 +3281,20 @@ pub const Server = struct {
3548 return n; 3281 return n;
3549 } 3282 }
3550 3283
3551 /// Text, but machine-parsed: the bench harness and the e2e tests split 3284 /// Text, but machine-parsed: the bench harness and the e2e tests split on
3552 /// on these key=value pairs. Renaming or reordering fields breaks them. 3285 /// these key=value pairs. Renaming or reordering fields breaks them.
3553 /// 3286 ///
3554 /// Byte counters are accrued when a frame is ACCEPTED INTO A CLIENT'S 3287 /// Byte counters are accrued when a frame is ACCEPTED INTO A CLIENT'S
3555 /// QUEUE, not when the kernel takes it — "sent" is now a small lie, and 3288 /// QUEUE, not when the kernel takes it — "sent" is a small lie, and
3556 /// pending_cap is what bounds it: no client can be more than one cap 3289 /// pending_cap bounds it: no client can be more than one cap behind before
3557 /// behind before it is dropped. The bench measures a live single client 3290 /// it is dropped.
3558 /// whose queue drains every pump, so the ratio it reports is unaffected.
3559 /// 3291 ///
3560 /// Daemon-global now: the old leading `seq=` field was ONE 3292 /// Daemon-global: a leading `seq=` would be ONE session's tracker, which
3561 /// session's tracker, which had no honest answer once there could be 3293 /// has no honest answer once there can be more than one. The main line
3562 /// more than one — so it moved off the main line entirely. The main 3294 /// keeps the global counters plus `sessions=N`, and every live session
3563 /// line keeps the truly global counters plus `sessions=N`, and every 3295 /// gets its own appended `session NAME clients=N seq=N` segment in slot
3564 /// live session gets its own appended `session NAME clients=N seq=N` 3296 /// order. Still one line, so nothing here introduces a newline for a
3565 /// segment, walked in slot order. Still one line, space-separated, same 3297 /// caller to trip on.
3566 /// as before: nothing here introduces a newline for a caller to trip
3567 /// on, and the fields that were always parsed by name (`snapshots=`,
3568 /// `clients=`) keep meaning what they always meant on the main line.
3569 fn statsText(self: *const Server, buf: []u8) ![]const u8 { 3298 fn statsText(self: *const Server, buf: []u8) ![]const u8 {
3570 var w: std.Io.Writer = .fixed(buf); 3299 var w: std.Io.Writer = .fixed(buf);
3571 try w.print( 3300 try w.print(
@@ -4049,10 +3778,8 @@ fn connectedPair(dir_path: []const u8, name: []const u8) !SockPair {
4049 return .{ .daemon = conn.stream.handle, .peer = peer.handle }; 3778 return .{ .daemon = conn.stream.handle, .peer = peer.handle };
4050 } 3779 }
4051 3780
4052 /// Shrink a socket's send buffer so a peer that never reads backs it up in a 3781 /// So a peer that never reads backs the socket up in a few KB rather than a
4053 /// few KB rather than a few hundred. Linux doubles the request and clamps it 3782 /// few hundred. Linux doubles and clamps the request: a floor, not a promise.
4054 /// up to SOCK_MIN_SNDBUF, so this is a floor request, not a promise — the
4055 /// tests below only depend on the result being small, never on its value.
4056 fn shrinkSendBuf(fd: std.posix.fd_t) !void { 3783 fn shrinkSendBuf(fd: std.posix.fd_t) !void {
4057 const v: c_int = 1024; 3784 const v: c_int = 1024;
4058 try std.posix.setsockopt( 3785 try std.posix.setsockopt(
@@ -4598,10 +4325,8 @@ test "Server: latest attacher's size wins; earlier client is resnapshotted at th
4598 try std.testing.expectEqual(@as(u16, 30), @as(u16, @intCast(srv.sessions[0].?.eng.term.rows))); 4325 try std.testing.expectEqual(@as(u16, 30), @as(u16, @intCast(srv.sessions[0].?.eng.term.rows)));
4599 } 4326 }
4600 4327
4601 /// Test helper: read `fd` until a snapshot arrives whose prefix reports 4328 /// Lenient about what precedes it: the join snapshot and the shell's deltas
4602 /// `cols` x `rows`, discarding everything else. Lenient about what precedes 4329 /// share the stream.
4603 /// it because a client's own join snapshot and the shell's deltas share the
4604 /// stream with the broadcast under test.
4605 fn awaitSnapshotSize( 4330 fn awaitSnapshotSize(
4606 alloc: std.mem.Allocator, 4331 alloc: std.mem.Allocator,
4607 fd: std.posix.fd_t, 4332 fd: std.posix.fd_t,
@@ -4689,10 +4414,8 @@ test "Server: typing claims the grid for the typist (latest-wins on input)" {
4689 try std.testing.expectEqual(proto.MsgType.delta, first.?.type); 4414 try std.testing.expectEqual(proto.MsgType.delta, first.?.type);
4690 } 4415 }
4691 4416
4692 /// Test helper: watch `fd` until `marker` shows up in a replica built from 4417 /// The replica starts blank on purpose — a delta carries every row it changed,
4693 /// what it sends, and fail if any of it is a snapshot. The replica starts 4418 /// so the row the marker lands on arrives whole.
4694 /// blank on purpose — a delta carries every row it changed, so the row the
4695 /// marker lands on arrives whole.
4696 fn awaitMarkerWithoutSnapshot( 4419 fn awaitMarkerWithoutSnapshot(
4697 alloc: std.mem.Allocator, 4420 alloc: std.mem.Allocator,
4698 fd: std.posix.fd_t, 4421 fd: std.posix.fd_t,
@@ -4997,8 +4720,7 @@ test "Server: scrollback fetch is per-client and independent" {
4997 try std.testing.expect(b_live); 4720 try std.testing.expect(b_live);
4998 } 4721 }
4999 4722
5000 /// Drive the pump until one selection reply lands on `peer`, and return the 4723 /// Returns the parts of the reply that outlive the frame's payload.
5001 /// parts of it that outlive the frame's payload.
5002 fn awaitSelectionReply( 4724 fn awaitSelectionReply(
5003 alloc: std.mem.Allocator, 4725 alloc: std.mem.Allocator,
5004 srv: *Server, 4726 srv: *Server,
@@ -5453,15 +5175,10 @@ test "Server: a daemon restart invalidates have_seq even with the old epoch pres
5453 } 5175 }
5454 } 5176 }
5455 5177
5456 /// Test helper: assert `Server.init` refuses `path` with `want`. 5178 /// The teardown on the failing branch is not tidiness: a Server that got built
5457 /// 5179 /// owns a live shell on a pty, and discarding it leaves that shell holding the
5458 /// The teardown on the failing branch is not tidiness. A Server that got 5180 /// test runner's stdout — the build never sees EOF and hangs instead of
5459 /// built when it should not have owns a live shell on a pty, and letting 5181 /// printing a failure.
5460 /// the test discard it leaves that shell holding the test runner's stdout:
5461 /// the build then never sees EOF and hangs for hours instead of printing a
5462 /// failure. A regression in the refusal must cost one red test, not a wedged
5463 /// CI worker — which is exactly how the daemon-stealing bug hid in the first
5464 /// place.
5465 fn expectInitRefused(alloc: std.mem.Allocator, path: []const u8, want: anyerror) !void { 5182 fn expectInitRefused(alloc: std.mem.Allocator, path: []const u8, want: anyerror) !void {
5466 if (Server.init(alloc, .{ .sock_path = path, .shell = "/bin/sh" })) |built| { 5183 if (Server.init(alloc, .{ .sock_path = path, .shell = "/bin/sh" })) |built| {
5467 var stolen = built; 5184 var stolen = built;
@@ -5520,10 +5237,8 @@ test "Server: injected bytes reach frame handling, split anywhere" {
5520 try std.testing.expectEqual(@as(usize, 0), srv.clients[0].?.inbound.items.len); 5237 try std.testing.expectEqual(@as(usize, 0), srv.clients[0].?.inbound.items.len);
5521 } 5238 }
5522 5239
5523 /// Pump the daemon once and take whatever that made readable off a client's 5240 /// Returns the exit code if the child died — a caller asserting about frames
5524 /// socket, recording the flags of every pty_mode frame seen. Returns the 5241 /// wants to hear that rather than spin.
5525 /// session's exit code if the child died — a caller asserting about frames
5526 /// wants to hear about that rather than spin.
5527 fn pumpAndCollectModes( 5242 fn pumpAndCollectModes(
5528 alloc: std.mem.Allocator, 5243 alloc: std.mem.Allocator,
5529 srv: *Server, 5244 srv: *Server,
@@ -5535,9 +5250,8 @@ fn pumpAndCollectModes(
5535 return code; 5250 return code;
5536 } 5251 }
5537 5252
5538 /// Take whatever is already readable off a client's socket, recording the 5253 /// No pump: for paths that answer synchronously, where pumping would blur what
5539 /// flags of every pty_mode frame in it. No pump: for the paths that answer 5254 /// caused the frame.
5540 /// a client synchronously, where pumping would blur what caused the frame.
5541 fn drainModes( 5255 fn drainModes(
5542 alloc: std.mem.Allocator, 5256 alloc: std.mem.Allocator,
5543 fd: std.posix.fd_t, 5257 fd: std.posix.fd_t,
@@ -6014,9 +5728,8 @@ fn quicTestServer(srv: *Server, key: quic.Key) !struct { l: *quic_server.Listene
6014 return .{ .l = l, .addr = std.net.Address.initPosix(@ptrCast(@alignCast(&actual))) }; 5728 return .{ .l = l, .addr = std.net.Address.initPosix(@ptrCast(@alignCast(&actual))) };
6015 } 5729 }
6016 5730
6017 /// Drive the daemon and up to two QUIC clients until `done`, or the budget 5731 /// Single-threaded: the daemon's own pump is what services the listener, which
6018 /// runs out. Single-threaded: the daemon's own pump is what services the 5732 /// is the integration under test.
6019 /// listener, which is the integration under test.
6020 fn quicPump( 5733 fn quicPump(
6021 srv: *Server, 5734 srv: *Server,
6022 clients: []*quic_server.TestClient, 5735 clients: []*quic_server.TestClient,
@@ -6672,20 +6385,8 @@ test "Server: stop_req from an attached client is honored too" {
6672 // can fail to. 6385 // can fail to.
6673 // --------------------------------------------------------------------------- 6386 // ---------------------------------------------------------------------------
6674 6387
6675 /// Pump the daemon until a frame of `want` arrives on `fd`, or the budget of 6388 /// The read after the poll is blocking: safe only because these replies land
6676 /// iterations runs out. Single-threaded on purpose: the loop that services 6389 /// in one write.
6677 /// the socket is the same one that has to answer, so the pump and the read
6678 /// have to interleave. Caller owns the returned frame.
6679 ///
6680 /// `iters` is roughly 6ms of wall clock each (a 5ms pump plus a 1ms poll), so
6681 /// 200 is about a second and a quarter — bounded so a regression fails here
6682 /// instead of hanging the suite.
6683 ///
6684 /// The read after the poll is blocking, and that is only safe because these
6685 /// replies are small enough to land in one write: a frame split across two
6686 /// writes would block here forever. Nothing in this test path can produce
6687 /// one — see shrinkSendBuf for the deliberate work it takes to make the
6688 /// daemon's socket buffer too small to swallow a frame whole.
6689 fn awaitFrame( 6390 fn awaitFrame(
6690 alloc: std.mem.Allocator, 6391 alloc: std.mem.Allocator,
6691 srv: *Server, 6392 srv: *Server,
@@ -6901,9 +6602,8 @@ test "Server: OSC 133 marks reach attached clients as cmd_state pushes" {
6901 // stubbed. 6602 // stubbed.
6902 // --------------------------------------------------------------------------- 6603 // ---------------------------------------------------------------------------
6903 6604
6904 /// Pump for `budget_ms` and report whether any `returned` push turned up. 6605 /// The absence half of the phantom-mark assertions: run nothing, claim
6905 /// The absence half of the phantom-mark assertions: a session that has been 6606 /// nothing.
6906 /// told to run nothing must claim nothing returned.
6907 fn anyReturnWithin( 6607 fn anyReturnWithin(
6908 alloc: std.mem.Allocator, 6608 alloc: std.mem.Allocator,
6909 srv: *Server, 6609 srv: *Server,
@@ -6926,10 +6626,8 @@ fn anyReturnWithin(
6926 return null; 6626 return null;
6927 } 6627 }
6928 6628
6929 /// Pump until the session's grid contains `needle`. The liveness half: an 6629 /// The liveness half: "no mark arrived" is worthless against a shell that
6930 /// assertion that no mark arrived is worthless against a shell that never 6630 /// never started.
6931 /// started, so every absence check below waits for the shell to say hello
6932 /// on the grid first.
6933 fn awaitGridText( 6631 fn awaitGridText(
6934 alloc: std.mem.Allocator, 6632 alloc: std.mem.Allocator,
6935 srv: *Server, 6633 srv: *Server,
@@ -7023,10 +6721,8 @@ const IntegratedSession = struct {
7023 } 6721 }
7024 }; 6722 };
7025 6723
7026 /// The bash rc a real box hands the shim: Arch's /etc/bash.bashrc appends a 6724 /// Planted explicitly, not read from /etc/bash.bashrc, so the test states its
7027 /// PROMPT_COMMAND member under any xterm* TERM, and muxd sets exactly that. 6725 /// own premise.
7028 /// This plants the same shape explicitly rather than relying on the system
7029 /// file being there, so the test states its own premise.
7030 const bash_rc_with_prompt_member = 6726 const bash_rc_with_prompt_member =
7031 \\PS1='MUXPROMPT>' 6727 \\PS1='MUXPROMPT>'
7032 \\PROMPT_COMMAND+=(': mux-test-member') 6728 \\PROMPT_COMMAND+=(': mux-test-member')
@@ -7098,17 +6794,8 @@ test "Server: bash emits one mark pair per command, and none at an idle prompt"
7098 } 6794 }
7099 } 6795 }
7100 6796
7101 /// zsh's equivalent premise: a precmd hook of the user's own, registered 6797 /// Returns 3 deliberately: the commands under test exit 0 or 1, so a shim
7102 /// BEFORE mux's — the shim sources the user's rc first, so theirs is first 6798 /// reading the wrong $? would still look right half the time.
7103 /// in precmd_functions and runs first.
7104 ///
7105 /// It returns 3 deliberately. A hook returning 0 would prove nothing: the
7106 /// commands under test exit 0 or 1, and a shim reading the wrong $? would
7107 /// still look right half the time. 3 is a value only the hook can produce,
7108 /// so the reported code names its own source. (zsh hands each precmd hook
7109 /// the original command's status rather than the previous hook's, which is
7110 /// what makes mux's reading safe; measured on this box before it was
7111 /// asserted here.)
7112 const zsh_rc_with_precmd_hook = 6799 const zsh_rc_with_precmd_hook =
7113 \\PS1='MUXPROMPT>' 6800 \\PS1='MUXPROMPT>'
7114 \\autoload -Uz add-zsh-hook 6801 \\autoload -Uz add-zsh-hook
@@ -7747,10 +7434,8 @@ fn attachNamed(fd: std.posix.fd_t, cols: u16, rows: u16, name: []const u8) !void
7747 try proto.writeFrame(fd, .attach, proto.encodeAttachNamed(&buf, cols, rows, 0, 0, name)); 7434 try proto.writeFrame(fd, .attach, proto.encodeAttachNamed(&buf, cols, rows, 0, 0, name));
7748 } 7435 }
7749 7436
7750 /// Pump the daemon and fold every state frame on `fd` into `rep` until its 7437 /// Doubles as an absence probe: with the positives asserted, a bounded `false`
7751 /// grid contains `needle` or `iters` pumps pass. Doubles as an absence 7438 /// means they never crossed onto this connection.
7752 /// probe: with the positives already asserted, a bounded `false` here means
7753 /// the needle's frames never crossed onto this connection.
7754 fn pumpUntilReplicaSees( 7439 fn pumpUntilReplicaSees(
7755 alloc: std.mem.Allocator, 7440 alloc: std.mem.Allocator,
7756 srv: *Server, 7441 srv: *Server,
@@ -8172,10 +7857,9 @@ test "Server: re-attaching to another session drops the await it left behind" {
8172 try std.testing.expect(moved); 7857 try std.testing.expect(moved);
8173 } 7858 }
8174 7859
8175 /// Is any client attached to `si` holding an outstanding await? Test-only: 7860 /// The await lives on the CLIENT slot — one client's question — while the
8176 /// the await lives on the CLIENT slot (one client's question) while the 7861 /// watermark it carries belongs to the session, which is why the two must not
8177 /// watermark it carries belongs to the session, which is the whole reason 7862 /// drift apart.
8178 /// the two must not drift apart.
8179 fn slotAwaiting(srv: *Server, si: usize) bool { 7863 fn slotAwaiting(srv: *Server, si: usize) bool {
8180 for (srv.clients) |slot| { 7864 for (srv.clients) |slot| {
8181 const cs = slot orelse continue; 7865 const cs = slot orelse continue;
@@ -8361,11 +8045,9 @@ test "Server: a promoted-but-unattached slot receives nothing" {
8361 try std.testing.expectEqual(@as(usize, 0), try std.posix.poll(&pfd, 0)); 8045 try std.testing.expectEqual(@as(usize, 0), try std.posix.poll(&pfd, 0));
8362 } 8046 }
8363 8047
8364 /// The scripted shell the per-session death tests share: echoes what it is 8048 /// One script serves every session the daemon spawns (there is one
8365 /// told with a prefix no pty echo can fake, and exits — with the code it 8049 /// spawn_plan), so "session a dies while b lives" is spelled by what each
8366 /// was handed — only when told to die. One script serves every session the 8050 /// client TYPES, not by what each shell is.
8367 /// daemon spawns (there is one spawn_plan), so "session a dies while b
8368 /// lives" is spelled by what each client TYPES, not by what each shell is.
8369 fn writeMortalScript(tmp: *TmpDir) !void { 8051 fn writeMortalScript(tmp: *TmpDir) !void {
8370 try tmp.dir.writeFile(.{ 8052 try tmp.dir.writeFile(.{
8371 .sub_path = "mortal.sh", 8053 .sub_path = "mortal.sh",
@@ -9154,67 +8836,13 @@ test "Server: a bell in a later chunk is its own frame, not folded into the firs
9154 // it again), and the fourth pins the expiry, which no client can observe. 8836 // it again), and the fourth pins the expiry, which no client can observe.
9155 // --------------------------------------------------------------------------- 8837 // ---------------------------------------------------------------------------
9156 8838
9157 /// The fixture every gap test spawns. The escapes are emitted ON DEMAND 8839 /// The escapes are emitted ON DEMAND because they must land in the gap: after
9158 /// rather than at spawn because they have to land in the gap — after one 8840 /// one client has gone, before the next arrives.
9159 /// client has gone and before the next arrives — and a spawn-time escape
9160 /// would be long drained by the time any client had earned a watermark.
9161 /// `read` is what holds the shell there; the tests release it by writing down
9162 /// the pty master, which needs no client attached.
9163 ///
9164 /// Four things happen in the gap, and they are load-bearing in three
9165 /// different senses. Saying which is the point: "load-bearing" is a claim
9166 /// about an outcome, and this header has already made it once where it was
9167 /// not true.
9168 /// 8841 ///
9169 /// - `after-osc`, the SECOND clipboard set and the BEL: remove any of them 8842 /// `gap-open` first keeps these tests off the pty's echo of `go`; TWO
9170 /// and a test below goes red. 8843 /// clipboard sets make "last one wins" observable; the BEL fills the second
9171 /// - the FIRST clipboard set: removing it leaves the suite green. What it 8844 /// slot so the bell arm is reached; `after-osc` last is the gate, since one
9172 /// holds up is a MUTATION — it is the only reason the CLIPBOARD slot is 8845 /// pump drains every side event before it.
9173 /// ever written twice, so without it `recordPending` keeping the first
9174 /// occupant instead of the last becomes indistinguishable from correct.
9175 /// Specifically that one, and not the replacement free next to it: the
9176 /// bell tests write the bell slot repeatedly all by themselves, so with
9177 /// this set deleted AND the free deleted a bell test still reports the
9178 /// leak. (Both checked by mutation; the first draft of this bullet named
9179 /// the free and was wrong.)
9180 /// - `gap-open`: removing it leaves the suite green and no mutation
9181 /// uncovered. It removes a dependency instead; see its bullet.
9182 ///
9183 /// - `gap-open` FIRST, and visible. This was load-bearing back when only a
9184 /// changed cell, a moved cursor or history growth advanced tracker.seq: a
9185 /// burst of pure escapes could then drain at the same seq the departing
9186 /// client already held, and the replay is `> have_seq`, which would
9187 /// refuse them. Printing something visible first put every stamp below it
9188 /// strictly above the watermark, whatever the pty happened to chunk.
9189 ///
9190 /// A gap is unattached, and the unattached path now advances seq on every
9191 /// chunk (`noteBlind`; the consequence is spelled out at replayPending),
9192 /// so the escapes would clear the watermark unaided. Kept regardless: it
9193 /// costs one printf, it keeps this fixture honest if the blind path ever
9194 /// regains a `.none`, and the dependency argument below never rested on
9195 /// the seq rule in the first place.
9196 ///
9197 /// Removing it leaves the suite GREEN, and that is not an argument for
9198 /// removing it. What it removes is a DEPENDENCY: without it these tests
9199 /// pass because the pty echoes `go`, so they would keep passing right up
9200 /// until they ran somewhere with ECHO off, and then fail describing the
9201 /// replay rule rather than the terminal setting. A guard against a state
9202 /// no test reaches cannot be pinned by a test — saying so is the honest
9203 /// version, and "load-bearing" would have claimed an outcome it does not
9204 /// have.
9205 /// - TWO clipboard sets, so the slot is written twice. That exercises the
9206 /// free of the previous occupant, which the bell tests also do, and makes
9207 /// "last one wins" observable, which nothing else does: the replay must
9208 /// carry `second`, never `first`.
9209 /// - a BEL, so both slots are occupied at once and the bell arm of the
9210 /// replay is reached by something. Every comment on that path is
9211 /// clipboard-flavoured, which is exactly how a well-meant tightening
9212 /// ("the privacy rule is about the clipboard") could quietly drop it.
9213 /// - `after-osc` LAST, in its own printf, as the gate. A pump feeds the
9214 /// engine and drains its side events in one pass, so a marker printed
9215 /// after the escapes cannot reach the grid until they have been drained
9216 /// — true however the pty splits the writes. Waiting on the marker is
9217 /// waiting on the drain, without a test having to read daemon internals.
9218 fn writeGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 { 8846 fn writeGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
9219 try tmp.dir.writeFile(.{ 8847 try tmp.dir.writeFile(.{
9220 .sub_path = "gap.sh", 8848 .sub_path = "gap.sh",
@@ -9233,23 +8861,13 @@ fn writeGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
9233 return std.fmt.allocPrintSentinel(alloc, "{s}/gap.sh", .{tmp.path()}, 0); 8861 return std.fmt.allocPrintSentinel(alloc, "{s}/gap.sh", .{tmp.path()}, 0);
9234 } 8862 }
9235 8863
9236 /// The gap fixture again, but the shell EXITS instead of sleeping — which is 8864 /// The shell EXITS instead of sleeping: a session dies through `reapSessions`,
9237 /// the whole point, and the one thing `writeGapShell` cannot be made to do 8865 /// not `Server.deinit`, and both honour the teardown half of the pending
9238 /// while the tests above need their session alive to reattach to. 8866 /// contract; the tests above reach only deinit.
9239 ///
9240 /// A session dies through `reapSessions`, not through `Server.deinit`, and
9241 /// those are the two places the teardown half of the pending contract is
9242 /// honoured. The gap tests above hold their shell open, so every one of them
9243 /// exercises only the deinit site; nothing reached the reap site at all, and
9244 /// a shell that exits between the copy and the next attach is the ordinary
9245 /// case — you copy something, the command finishes, the shell exits.
9246 /// 8867 ///
9247 /// The second `read` is what makes the death separately triggerable: the test 8868 /// The second `read` makes the death triggerable on its own: the slots must be
9248 /// needs to observe the slots FULL before the session dies, or the leak it 8869 /// seen FULL before the session dies. Both kinds, because the teardown frees
9249 /// pins could not happen and the test would pass for want of anything to 8870 /// the whole set.
9250 /// free. Both kinds are emitted for the same reason — the teardown frees the
9251 /// whole set, so pinning it with one slot occupied would leave the other's
9252 /// free deletable.
9253 fn writeDyingGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 { 8871 fn writeDyingGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
9254 try tmp.dir.writeFile(.{ 8872 try tmp.dir.writeFile(.{
9255 .sub_path = "gapdie.sh", 8873 .sub_path = "gapdie.sh",
@@ -9278,10 +8896,8 @@ const gap_clip_second = "c2Vjb25k";
9278 /// to choose its branch. 8896 /// to choose its branch.
9279 const GapWatermark = struct { seq: u64, epoch: u64 }; 8897 const GapWatermark = struct { seq: u64, epoch: u64 };
9280 8898
9281 /// Drive `srv` to the state every gap test reattaches into: a client attaches 8899 /// An attach is the only thing that builds the tracker, and so earns a
9282 /// (which is the only thing that builds the tracker, and so the only way to 8900 /// servable seq.
9283 /// earn a servable seq), leaves, and only then does the session emit the
9284 /// escapes.
9285 fn clipboardIntoGap( 8901 fn clipboardIntoGap(
9286 alloc: std.mem.Allocator, 8902 alloc: std.mem.Allocator,
9287 srv: *Server, 8903 srv: *Server,
@@ -9755,9 +9371,8 @@ test "Server: a session that dies holding a pending event frees it" {
9755 // reapSessions and this test reports the payloads as leaked. 9371 // reapSessions and this test reports the payloads as leaked.
9756 } 9372 }
9757 9373
9758 /// Answer "is there a socket at this path" without opening it. `statFile` 9374 /// `statFile` opens, and open(2) on a unix socket is ENXIO, so the obvious
9759 /// opens, and open(2) on a unix socket is ENXIO, so the obvious spelling 9375 /// spelling reports a missing socket for one that is right there.
9760 /// reports a missing socket for a socket that is right there.
9761 fn isSocketAt(path: [:0]const u8) bool { 9376 fn isSocketAt(path: [:0]const u8) bool {
9762 const st = std.posix.fstatatZ(std.posix.AT.FDCWD, path, 0) catch return false; 9377 const st = std.posix.fstatatZ(std.posix.AT.FDCWD, path, 0) catch return false;
9763 return std.posix.S.ISSOCK(st.mode); 9378 return std.posix.S.ISSOCK(st.mode);
@@ -10278,11 +9893,7 @@ test "Server: a joiner that resizes the grid is still told the session's title"
10278 try std.testing.expectEqualStrings("vim", b_title orelse return error.NoTitleOnResizingJoin); 9893 try std.testing.expectEqualStrings("vim", b_title orelse return error.NoTitleOnResizingJoin);
10279 } 9894 }
10280 9895
10281 /// Pump until a resync's term_modes lands on `fd`, reporting the content 9896 /// awaitFrame drops every frame but the one asked for, including the branch's.
10282 /// frame that came with it. Separate from awaitFrame because the question is
10283 /// about a PAIR — which branch ran, and what it said about the modes — and
10284 /// awaitFrame drops everything that is not what it was asked for, including
10285 /// the very frame that names the branch.
10286 fn modesWithResync( 9897 fn modesWithResync(
10287 alloc: std.mem.Allocator, 9898 alloc: std.mem.Allocator,
10288 srv: *Server, 9899 srv: *Server,
src/shellint.zig
Old New
@@ -5,16 +5,8 @@
5 const std = @import("std"); 5 const std = @import("std");
6 const xdg = @import("xdg"); 6 const xdg = @import("xdg");
7 7
8 /// The precmd hook, character for character the same in zsh and bash: both 8 /// One copy for both shells: a second would drift, silently, in one of
9 /// shells spell `$?`, `local` and `printf` alike, and the mark it emits is 9 /// them. `local code=$?` must stay FIRST: any line above it clobbers $?.
10 /// the protocol's, not either shell's. Spliced into both scripts below
11 /// rather than written twice — two copies of the hook that decides whether
12 /// a command's exit code is knowable is two places for that decision to
13 /// drift, and a drift would be silent in exactly one shell.
14 ///
15 /// `local code=$?` is the FIRST line for a reason that outlives any edit:
16 /// $? is clobbered by the next command to run, and every line added above
17 /// this one would be that command.
18 const precmd_fn = 10 const precmd_fn =
19 \\_mux_precmd() { 11 \\_mux_precmd() {
20 \\ local code=$? 12 \\ local code=$?
@@ -158,38 +150,23 @@ pub const Injection = struct {
158 /// nothing on disk to remove. 150 /// nothing on disk to remove.
159 pub const no_injection: Injection = .{ .extra_argv = &.{}, .env = &.{}, .dir = null }; 151 pub const no_injection: Injection = .{ .extra_argv = &.{}, .env = &.{}, .dir = null };
160 152
161 /// Prepare the shim under `parent_dir` and hand back what the spawn must 153 /// Degraded, never fatal: without marks a session runs on pgid and
162 /// add, degrading to `no_injection` rather than failing. 154 /// settle fallbacks.
163 ///
164 /// Degraded, never fatal. A session without marks is a working session —
165 /// it runs on the pgid and settle fallbacks, which is what every unknown
166 /// shell does — so refusing to start the daemon over an optional
167 /// enhancement would invert this module's premise. Said out loud, because
168 /// a silent fallback here would look exactly like a shell that ignores
169 /// its rc.
170 ///
171 /// The `mux-shellint-<pid>-<random>` naming lives here rather than at the
172 /// call site: it is the same fact as what `prepare` writes and what the
173 /// returned `dir` promises to delete, and the pid is what keeps two
174 /// daemons sharing one runtime directory legible in a directory listing.
175 ///
176 /// The random half is not decoration. `parent_dir` is the socket's
177 /// directory, which is `$XDG_RUNTIME_DIR` when there is one and a shared
178 /// `/tmp` when there is not — and a pid is guessable. On the `/tmp` box
179 /// another user could pre-create the exact name this daemon was going to
180 /// pick, as a symlink to a directory of ours, and the shim files (which
181 /// the session shell then SOURCES) would land through it. `prepare`
182 /// creating the directory exclusively is what closes that; the random
183 /// half is what keeps the attempt from being cheap to aim.
184 ///
185 /// It also fixes the mundane version of the same collision: a predecessor
186 /// SIGKILLed before teardown leaves `mux-shellint-<its pid>` behind, and a
187 /// later daemon drawing that pid used to lose its marks to the leftover.
188 pub fn install( 155 pub fn install(
189 arena: std.mem.Allocator, 156 arena: std.mem.Allocator,
190 parent_dir: []const u8, 157 parent_dir: []const u8,
191 shell_path: []const u8, 158 shell_path: []const u8,
192 ) Injection { 159 ) Injection {
160 // The pid keeps two daemons sharing one runtime directory legible in a
161 // listing; the random half is not decoration. `parent_dir` is the
162 // socket's directory, a shared `/tmp` when `$XDG_RUNTIME_DIR` is unset,
163 // and a pid is guessable: another user could pre-create the exact name
164 // as a symlink to a directory of ours, and the shim files the session
165 // shell then SOURCES would land through it. `prepare` creating the
166 // directory exclusively closes that; the random half keeps the attempt
167 // from being cheap to aim. It also ends the mundane collision, where a
168 // predecessor SIGKILLed before teardown left its name behind for a
169 // later daemon drawing that pid.
193 const dir = std.fmt.allocPrint( 170 const dir = std.fmt.allocPrint(
194 arena, 171 arena,
195 "{s}/mux-shellint-{d}-{x:0>12}", 172 "{s}/mux-shellint-{d}-{x:0>12}",
@@ -212,23 +189,16 @@ pub fn install(
212 }; 189 };
213 } 190 }
214 191
215 /// Prepare shim files under `dir` (created private, 0700) for `shell_path` 192 /// Creates `dir` EXCLUSIVELY: adopting one is a symlink attack.
216 /// and return what spawn must add. All returned slices are allocated from
217 /// `arena` — hand it an arena that lives as long as the daemon.
218 ///
219 /// `dir` is created EXCLUSIVELY, so everything written below it is written
220 /// through path components this process made: an entry already at `dir` is
221 /// `error.DirExists` and the session does without marks. Nothing here may
222 /// adopt a directory it did not create — see xdg.makeNewPrivateDir for why
223 /// the difference is a symlink attack and not a preference.
224 ///
225 /// `install` is what the daemon calls; this stays public for the tests,
226 /// which need to name their own directory.
227 pub fn prepare( 193 pub fn prepare(
228 arena: std.mem.Allocator, 194 arena: std.mem.Allocator,
229 dir: []const u8, 195 dir: []const u8,
230 shell_path: []const u8, 196 shell_path: []const u8,
231 ) !Injection { 197 ) !Injection {
198 // Everything written below `dir` then goes through path components this
199 // process made. An entry already there is `error.DirExists` and the
200 // session does without marks. All returned slices come from `arena`,
201 // which must live as long as the daemon.
232 switch (detect(shell_path)) { 202 switch (detect(shell_path)) {
233 .zsh => { 203 .zsh => {
234 try xdg.makeNewPrivateDir(dir); 204 try xdg.makeNewPrivateDir(dir);
@@ -297,10 +267,8 @@ pub fn prepare(
297 } 267 }
298 } 268 }
299 269
300 /// The shim's contents are 0600 either way; the 0700 on the directory is 270 /// 0600 here; the directory's 0700 (`xdg.makePrivateDir`) is what keeps
301 /// what keeps it from publishing that this daemon exists and what it named 271 /// this from publishing that the daemon exists and what it named its files.
302 /// its files — the same reason, and now the same code, as the key file's
303 /// parent (see xdg.makePrivateDir).
304 fn writeFilePrivate(path: []const u8, contents: []const u8) !void { 272 fn writeFilePrivate(path: []const u8, contents: []const u8) !void {
305 const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600 }); 273 const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600 });
306 defer f.close(); 274 defer f.close();
src/sockpath.zig
Old New
@@ -15,19 +15,8 @@ const std = @import("std");
15 /// the kernel's and belongs in one place, the wording is theirs. 15 /// the kernel's and belongs in one place, the wording is theirs.
16 pub const max_sun_path = 107; 16 pub const max_sun_path = 107;
17 17
18 /// Where a binary looks when nobody named a socket. The default path is 18 /// No fallback when `$XDG_RUNTIME_DIR` is unset: a guess cannot make two
19 /// part of a socket path's identity too: it is what makes two binaries 19 /// binaries agree on one daemon, so the caller names it.
20 /// started with no `--sock` land on the SAME daemon, so it lives here with
21 /// the bound rather than once per binary.
22 ///
23 /// Which is why there is no fallback when `$XDG_RUNTIME_DIR` is unset.
24 /// A guessed `/tmp/muxd-<uid>.sock` used to stand in, and it broke the
25 /// one property above: a tmux server started before logind exported the
26 /// variable hands every pane an environment without it, so panes went to
27 /// /tmp while the daemon a pane had started owned the runtime dir — one
28 /// uid, one box, two daemons, and `muxd stop` reporting nothing there.
29 /// A default that cannot make two binaries agree is not a default, so
30 /// this refuses and the caller names the path.
31 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { 20 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
32 return sockPathFrom(alloc, std.posix.getenv("XDG_RUNTIME_DIR")); 21 return sockPathFrom(alloc, std.posix.getenv("XDG_RUNTIME_DIR"));
33 } 22 }
@@ -54,17 +43,15 @@ pub const PathId = struct {
54 dev: u64, 43 dev: u64,
55 ino: u64, 44 ino: u64,
56 45
57 /// Stat the path we just created, not the descriptor: this is the 46 /// The path, not the descriptor: teardown re-reads it through this
58 /// record teardown compares against, and it has to be taken through 47 /// same lens, and the two lenses do not compare.
59 /// the same lens it will be re-read through.
60 pub fn of(path: []const u8) !PathId { 48 pub fn of(path: []const u8) !PathId {
61 const st = try std.posix.fstatat(std.posix.AT.FDCWD, path, 0); 49 const st = try std.posix.fstatat(std.posix.AT.FDCWD, path, 0);
62 return .{ .dev = @intCast(st.dev), .ino = @intCast(st.ino) }; 50 return .{ .dev = @intCast(st.dev), .ino = @intCast(st.ino) };
63 } 51 }
64 52
65 /// Does `path` still name this exact file? A path that cannot be 53 /// A path that cannot be stat'd is not ours: gone, or something we
66 /// stat'd is not ours — it is gone, or it is something we may not 54 /// may not identify, and either way callers want "leave it".
67 /// identify — and either way the answer callers want is "leave it".
68 pub fn stillAt(self: PathId, path: []const u8) bool { 55 pub fn stillAt(self: PathId, path: []const u8) bool {
69 const pst = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch return false; 56 const pst = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch return false;
70 return pst.dev == self.dev and pst.ino == self.ino; 57 return pst.dev == self.dev and pst.ino == self.ino;
src/spawn.zig
Old New
@@ -60,18 +60,9 @@ pub const Progress = struct {
60 } 60 }
61 }; 61 };
62 62
63 /// Probe `sock_path`; if nothing answers, exec `exe_path run <run_args...>` 63 /// `NeverAnswered` does not kill the spawned pid: a daemon up at 2.5s is
64 /// detached (setsid, stdin /dev/null, stdout+stderr to the log `log_spec` 64 /// there for the retry. Racers sort themselves out: the loser exits on
65 /// names) and poll every 50ms until the socket accepts or `deadline_ms` 65 /// DaemonAlreadyRunning.
66 /// passes.
67 ///
68 /// On `NeverAnswered` the spawned pid is deliberately NOT killed: a daemon
69 /// that comes up at 2.5s should be there for the retry, not murdered for
70 /// tardiness. The failure line names the log, which holds its stderr.
71 ///
72 /// Two racers both spawning is handled by the daemon itself: the loser
73 /// exits on DaemonAlreadyRunning (server.zig claimSockPath) and the
74 /// loser's poll connects to the winner.
75 pub fn ensureDaemon( 66 pub fn ensureDaemon(
76 alloc: std.mem.Allocator, 67 alloc: std.mem.Allocator,
77 exe_path: []const u8, 68 exe_path: []const u8,
@@ -215,8 +206,7 @@ pub fn ensureDaemon(
215 } 206 }
216 } 207 }
217 208
218 /// True when something accepted a connection on `sock_path`. False covers 209 /// False covers both "no daemon" and "a stale socket file": nothing
219 /// both "no daemon" and "a socket file a crash left behind" — nothing
220 /// answered either way, and the caller's next move is the same. 210 /// answered either way, and the caller's next move is the same.
221 pub fn probe(sock_path: []const u8) bool { 211 pub fn probe(sock_path: []const u8) bool {
222 const s = std.net.connectUnixSocket(sock_path) catch return false; 212 const s = std.net.connectUnixSocket(sock_path) catch return false;
@@ -224,21 +214,8 @@ pub fn probe(sock_path: []const u8) bool {
224 return true; 214 return true;
225 } 215 }
226 216
227 /// The attach shape, owned in one place: both auto-start call sites (`muxd 217 /// Auto-start's one shape. `muxd start` is deliberately not this: it owes
228 /// proxy` and `mux`) want exactly this — a BARE `run --sock <path>`, the 218 /// the user a verdict on a daemon.
229 /// shared deadline, stderr progress under the caller's own prefix, and a
230 /// log that is appended to rather than truncated.
231 ///
232 /// True means there is a daemon to attach to, started or already up. False
233 /// means give up: the reason is already on stderr, so a caller's `return 1`
234 /// needs no message of its own.
235 ///
236 /// `muxd start` deliberately does NOT go through here. It forwards the
237 /// user's own flags rather than a fixed pair, it truncates the log, and it
238 /// REPORTS already-running where this stays silent — because only one of
239 /// the two was asked for. Someone who typed `muxd start` asked about a
240 /// daemon and is owed a verdict on one; someone who typed `mux` asked for a
241 /// session and is about to get it.
242 pub fn ensureForAttach( 219 pub fn ensureForAttach(
243 alloc: std.mem.Allocator, 220 alloc: std.mem.Allocator,
244 exe: []const u8, 221 exe: []const u8,
@@ -278,21 +255,8 @@ pub fn ensureForAttach(
278 return true; 255 return true;
279 } 256 }
280 257
281 /// Walk a colon-separated `path_env` for an executable `name`; the first 258 /// An empty PATH segment is skipped, not read as cwd: never exec a stray
282 /// hit wins, execvp's own rule. Caller owns the returned path. Takes the 259 /// `./muxd`.
283 /// PATH string rather than reading the environment so tests stay
284 /// environment-free — the parseArgs discipline, applied here.
285 ///
286 /// Empty segments (`::`, leading/trailing `:`) mean the current directory
287 /// to POSIX; they are SKIPPED instead — an attach must never execute a
288 /// `./muxd` it happens to be standing next to. Only the IMPLICIT cwd is
289 /// refused: an explicit `.` or any other relative entry still resolves
290 /// against the cwd, as execvp would. A typed entry is a choice someone
291 /// made; a stray colon is invisible, and only the invisible one is a trap.
292 ///
293 /// `access(X_OK)` also succeeds on a searchable DIRECTORY named `name`, so
294 /// a hit is not proof of an executable file — matching ensureDaemon's own
295 /// check above. The exec that follows is what finally rejects it.
296 pub fn findInPath( 260 pub fn findInPath(
297 alloc: std.mem.Allocator, 261 alloc: std.mem.Allocator,
298 path_env: []const u8, 262 path_env: []const u8,
@@ -300,8 +264,15 @@ pub fn findInPath(
300 ) error{OutOfMemory}!?[]const u8 { 264 ) error{OutOfMemory}!?[]const u8 {
301 var it = std.mem.splitScalar(u8, path_env, ':'); 265 var it = std.mem.splitScalar(u8, path_env, ':');
302 while (it.next()) |dir| { 266 while (it.next()) |dir| {
267 // POSIX reads an empty segment as the current directory. Only the
268 // IMPLICIT cwd is refused: an explicit `.` still resolves against
269 // it, as execvp would. A typed entry is a choice someone made; a
270 // stray colon is invisible, and only the invisible one is a trap.
303 if (dir.len == 0) continue; 271 if (dir.len == 0) continue;
304 const candidate = try std.fs.path.join(alloc, &.{ dir, name }); 272 const candidate = try std.fs.path.join(alloc, &.{ dir, name });
273 // access(X_OK) also succeeds on a searchable DIRECTORY named
274 // `name`, so a hit is not proof of an executable file. The exec
275 // that follows is what finally rejects it.
305 std.posix.access(candidate, std.posix.X_OK) catch { 276 std.posix.access(candidate, std.posix.X_OK) catch {
306 alloc.free(candidate); 277 alloc.free(candidate);
307 continue; 278 continue;
src/wall.zig
Old New
@@ -9,17 +9,12 @@
9 //! The session splits at the LAST '#' because validSessionName refuses 9 //! The session splits at the LAST '#' because validSessionName refuses
10 //! '#', so any earlier one belongs to the target's own spelling. 10 //! '#', so any earlier one belongs to the target's own spelling.
11 //! 11 //!
12 //! argv normalization lives here too (`spellingFromArgv`): both binaries 12 //! The file is `$XDG_STATE_HOME/mux/wall`, one spelling per line, in wall
13 //! accept `--sock PATH` as two arguments or as one, and both arrive at 13 //! order. Every mutation rewrites it atomically (temp + rename); two
14 //! the single spelling above. 14 //! concurrent writers resolve as last-rename-wins, acceptable for a single
15 //! 15 //! user's state file. "Atomic" is writer-vs-writer only: `save` fsyncs
16 //! The file is `$XDG_STATE_HOME/mux/wall`, one spelling per line, order 16 //! nothing, so a crash can still leave the rename torn. No crash-durability
17 //! is wall order. Every mutation rewrites it atomically (temp + rename); 17 //! claim is made here.
18 //! two concurrent writers resolve as last-rename-wins, acceptable for a
19 //! single user's state file. "Atomic" is writer-vs-writer only: `save`
20 //! does not fsync the file or its directory, so a crash at the wrong
21 //! moment can still leave the rename torn on some filesystems — no
22 //! stronger, crash-durability claim is made here.
23 const std = @import("std"); 18 const std = @import("std");
24 const proto = @import("protocol"); 19 const proto = @import("protocol");
25 20
@@ -74,18 +69,7 @@ pub fn parseSpelling(line: []const u8) ParseError!Parsed {
74 69
75 pub const ArgvError = error{ MissingSockPath, FlagLikeTarget } || std.mem.Allocator.Error; 70 pub const ArgvError = error{ MissingSockPath, FlagLikeTarget } || std.mem.Allocator.Error;
76 71
77 /// One argv element (plus, for `--sock`, the one after it) becomes one 72 /// Both binaries accept both `--sock` dialects: one parser, one spelling.
78 /// spelling in the grammar above.
79 ///
80 /// Two dialects grew for the same wall: `muxweb --sock PATH` spells the
81 /// sock tile as a flag with a following value, while `mux wall` takes each
82 /// argument as a whole spelling and so needs `'--sock PATH'` quoted. Both
83 /// binaries call this, so both accept both, and neither owns a second
84 /// parser: a bare `--sock` joins with the next argument, anything else —
85 /// `--sock PATH` already in one piece, HOST, quic:// — passes through.
86 ///
87 /// The result is always an owned copy so ownership does not depend on
88 /// which spelling arrived; `consumed` is how far the caller's index moves.
89 pub fn spellingFromArgv( 73 pub fn spellingFromArgv(
90 alloc: std.mem.Allocator, 74 alloc: std.mem.Allocator,
91 args: []const [:0]const u8, 75 args: []const [:0]const u8,
@@ -188,17 +172,13 @@ pub fn saveLines(lines: []const []const u8, path: []const u8) !void {
188 172
189 /// Every line of the wall file, verbatim, with NO grammar applied. 173 /// Every line of the wall file, verbatim, with NO grammar applied.
190 /// 174 ///
191 /// `load` refuses a file holding a line that no longer parses, loudly and 175 /// `load` refuses a line that no longer parses: silently dropping a tile
192 /// on purpose — silently dropping a tile the user wrote down is worse than 176 /// the user wrote down is worse. But that left one hand-edited line
193 /// making them fix the line. But when EVERY path went through `load`, one 177 /// unrepairable by the command whose whole job is removing a line, so
194 /// hand-edited line made the file unrepairable by the tool that owns it, 178 /// removal reads with this instead.
195 /// including the command whose entire job is removing a line. So removal
196 /// reads with this instead: it can delete the broken line, and it preserves
197 /// every line it did not touch byte for byte.
198 /// 179 ///
199 /// Deliberately NOT used by `record` or `Wall.add`: those GROW the wall, 180 /// NOT for `record` or `Wall.add`: growing a file whose content is not
200 /// and growing a file whose existing content is not understood would build 181 /// understood builds on garbage and re-saves it as if it had been read.
201 /// on garbage and re-save it as if it had been read.
202 pub fn loadLines(alloc: std.mem.Allocator, path: []const u8) !std.ArrayList([]u8) { 182 pub fn loadLines(alloc: std.mem.Allocator, path: []const u8) !std.ArrayList([]u8) {
203 var lines: std.ArrayList([]u8) = .empty; 183 var lines: std.ArrayList([]u8) = .empty;
204 errdefer { 184 errdefer {
@@ -220,15 +200,9 @@ pub fn freeLines(alloc: std.mem.Allocator, lines: *std.ArrayList([]u8)) void {
220 lines.deinit(alloc); 200 lines.deinit(alloc);
221 } 201 }
222 202
223 /// Dedup is on the SPELLING, never on the session's identity: the same 203 /// Dedup is on the SPELLING, never on session identity: the same session
224 /// session reached as `HOST#S` and as `quic://…#S` is two tiles, 204 /// as `HOST#S` and as `quic://HOST#S` is two tiles, deliberately. Identity
225 /// deliberately — identity dedup would need an endpoint handshake the 205 /// dedup would need an endpoint handshake the wall does not want.
226 /// wall does not have and does not want (the home-screen spec).
227 ///
228 /// Read-modify-write against a file two processes may hold at once. The
229 /// resolution is `save`'s: last rename wins, acceptable for one user's
230 /// state file. A reader never sees a torn file (temp + rename); a writer
231 /// can lose a concurrent writer's line.
232 pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool { 206 pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool {
233 var w = try load(alloc, path); 207 var w = try load(alloc, path);
234 defer w.deinit(alloc); 208 defer w.deinit(alloc);
@@ -238,16 +212,13 @@ pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8)
238 return true; 212 return true;
239 } 213 }
240 214
241 /// Remove `spelling` from the wall file. Returns whether it was there — 215 /// Absent is a fact for the caller to report, not an error here.
242 /// absent is a fact for the caller to report, not an error here.
243 /// 216 ///
244 /// `orderedRemove`, so the lines that stay keep their order: the wall is 217 /// `orderedRemove`, so the lines that stay keep their order: the wall is a
245 /// a list the user reads (and jumps into with `1`-`9`) by position. 218 /// list the user reads, and jumps into with `1`-`9`, by position.
246 /// 219 ///
247 /// Reads with `loadLines`, not `load`: removal is the one operation that 220 /// Reads with `loadLines`, not `load`, so a hand-edited line can be
248 /// must work on a file the grammar cannot fully read, or a single 221 /// removed at all.
249 /// hand-edited line would be unrepairable with the tool that owns it. The
250 /// broken lines it does not match are written back untouched.
251 pub fn forget(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool { 222 pub fn forget(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool {
252 var lines = try loadLines(alloc, path); 223 var lines = try loadLines(alloc, path);
253 defer freeLines(alloc, &lines); 224 defer freeLines(alloc, &lines);
@@ -260,7 +231,6 @@ pub fn forget(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8)
260 return false; 231 return false;
261 } 232 }
262 233
263 /// `$XDG_STATE_HOME/mux/wall`, defaulting to `~/.local/state/mux/wall`.
264 /// The *From split is xdg.zig's pattern for the same reason: setenv is 234 /// The *From split is xdg.zig's pattern for the same reason: setenv is
265 /// unsafe in-process for Zig tests. 235 /// unsafe in-process for Zig tests.
266 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { 236 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 {
src/wasm_core.zig
Old New
@@ -28,18 +28,16 @@ const client_core = @import("client_core");
28 /// needs no libc, no syscalls, no host imports. 28 /// needs no libc, no syscalls, no host imports.
29 const alloc = std.heap.wasm_allocator; 29 const alloc = std.heap.wasm_allocator;
30 30
31 /// Trap explicitly on panic. This is also the hook a later version uses 31 /// No host import to log through, so a panic can only trap.
32 /// to surface panic text to JS (a host-imported log before the trap).
33 pub const panic = std.debug.FullPanic(struct { 32 pub const panic = std.debug.FullPanic(struct {
34 fn f(_: []const u8, _: ?usize) noreturn { 33 fn f(_: []const u8, _: ?usize) noreturn {
35 @trap(); 34 @trap();
36 } 35 }
37 }.f); 36 }.f);
38 37
39 /// Load-bearing (spike obstacle 4): ghostty-vt logs warnings on some 38 /// std's default logFn writes to stderr, which on wasm32-freestanding
40 /// unsupported sequences, and std's default logFn writes to stderr, which 39 /// drags in posix.writev/lseek and std.Thread: without this no-op the
41 /// on wasm32-freestanding drags in posix.writev/lseek and std.Thread. 40 /// build fails inside std.
42 /// Without this no-op the build fails inside std before any ghostty code.
43 pub const std_options: std.Options = .{ 41 pub const std_options: std.Options = .{
44 .logFn = struct { 42 .logFn = struct {
45 fn f( 43 fn f(
@@ -58,8 +56,8 @@ const Core = struct {
58 /// Borrows from input_buf. It is valid only until the host next stages 56 /// Borrows from input_buf. It is valid only until the host next stages
59 /// a frame, exactly like ClientCore's payload-borrowing result. 57 /// a frame, exactly like ClientCore's payload-borrowing result.
60 clipboard: client_core.ClipboardSet = .{ .target = 0, .base64 = &.{} }, 58 clipboard: client_core.ClipboardSet = .{ .target = 0, .base64 = &.{} },
61 /// Borrows from input_buf. The text is valid only until the host next 59 /// Borrows input_buf: valid only until the host next stages or writes
62 /// stages or writes input, or starts another selection request. 60 /// input, or starts another request.
63 selection: proto.SelectionReply = .{ 61 selection: proto.SelectionReply = .{
64 .id = 0, 62 .id = 0,
65 .status = .unavailable, 63 .status = .unavailable,
@@ -321,8 +319,7 @@ export fn mux_clipboard_len() u32 {
321 return @intCast(c.clipboard.base64.len); 319 return @intCast(c.clipboard.base64.len);
322 } 320 }
323 321
324 /// Begin a correlated selection request and write its exact protocol payload 322 /// Invalid u16 columns refuse without disturbing an existing pending id.
325 /// to output_buf. Invalid u16 columns do not disturb an existing pending id.
326 export fn mux_selection_request( 323 export fn mux_selection_request(
327 id: u32, 324 id: u32,
328 anchor_row: u32, 325 anchor_row: u32,
@@ -345,18 +342,15 @@ export fn mux_selection_request(
345 return @intCast(payload.len); 342 return @intCast(payload.len);
346 } 343 }
347 344
348 /// WebAssembly exposes this u32 to JavaScript as an i32; browser consumers 345 /// wasm exposes this u32 to JS as an i32; normalize with >>> 0 before
349 /// must normalize it with `mux_selection_id() >>> 0` before comparing IDs. 346 /// comparing.
350 export fn mux_selection_id() u32 { 347 export fn mux_selection_id() u32 {
351 const c = core orelse return 0; 348 const c = core orelse return 0;
352 return c.selection.id; 349 return c.selection.id;
353 } 350 }
354 351
355 /// Retained history rows the daemon sampled while extracting this reply. 352 /// Lower than the requester sampled means a page was evicted: absolute
356 /// The requester compares it with the value it sampled when the request 353 /// rows now name different lines; discard.
357 /// became authoritative: a LOWER reading means the page list evicted a
358 /// page, so every absolute row in the request now names a different line
359 /// and the text must be discarded rather than copied.
360 export fn mux_selection_history_rows() u32 { 354 export fn mux_selection_history_rows() u32 {
361 const c = core orelse return 0; 355 const c = core orelse return 0;
362 return c.selection.history_rows; 356 return c.selection.history_rows;
@@ -378,8 +372,8 @@ export fn mux_selection_len() u32 {
378 return @intCast(c.selection.text.len); 372 return @intCast(c.selection.text.len);
379 } 373 }
380 374
381 /// Force a full repaint on the next mux_read_viewport (scroll-mode exit, 375 /// For a canvas the host lost, a scroll-mode exit, or first paint after
382 /// a canvas the host lost, first paint after tab restore). 376 /// tab restore.
383 export fn mux_mark_all_dirty() void { 377 export fn mux_mark_all_dirty() void {
384 const c = core orelse return; 378 const c = core orelse return;
385 @memset(c.dirty, true); 379 @memset(c.dirty, true);
@@ -480,17 +474,9 @@ export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32 {
480 return @intCast(seq.len); 474 return @intCast(seq.len);
481 } 475 }
482 476
483 /// `len` staged bytes to the output buffer, UNCHANGED. Two callers, and 477 /// `len` staged bytes out UNCHANGED. An IME's finished composition is
484 /// the distinction between them is the point: 478 /// TYPING, not paste: bracketing would tell the application a human did
485 /// 479 /// not write it. Paste body chunks pass through here between the markers.
486 /// - An IME's compositionend hands over finished text, and finished
487 /// text is TYPING. Bracketing it would tell the application a human
488 /// did not write it — vim would skip paste mode's indentation, a
489 /// shell's bracketed-paste guard would refuse to run it — so composed
490 /// text goes out raw, exactly as the keystrokes it stands in for.
491 /// - The body chunks of a real paste, between the markers below.
492 ///
493 /// -2 if the host staged more than either buffer holds.
494 export fn mux_text_encode(len: u32) i32 { 480 export fn mux_text_encode(len: u32) i32 {
495 if (core) |c| clearBorrowedInputResults(c); 481 if (core) |c| clearBorrowedInputResults(c);
496 if (len > input_buf.len or len > output_buf.len) return -2; 482 if (len > input_buf.len or len > output_buf.len) return -2;
@@ -499,17 +485,10 @@ export fn mux_text_encode(len: u32) i32 {
499 return @intCast(len); 485 return @intCast(len);
500 } 486 }
501 487
502 /// The bracketed-paste markers, each on its own, because a paste too big 488 /// Each marker on its own, because a paste too big for one message is
503 /// for one message is still ONE paste: the host sends begin, then N 489 /// still ONE paste: begin, N unwrapped chunks through mux_text_encode,
504 /// unwrapped chunks through mux_text_encode, then end. 490 /// end. Wrapping each chunk would put a paste-END mid-text, and vim acts
505 /// 491 /// on it right there — paste mode off 32 KiB in, the rest re-indented.
506 /// keymap.pasteInto's contract is "the wrap and nothing else" around the
507 /// WHOLE paste, and this is the code finally matching that doc. Wrapping
508 /// each chunk instead — which is what the shell used to do — put a
509 /// paste-END in the middle of the pasted text, and an application that
510 /// acts on the marker acts on it right there: vim leaves paste mode
511 /// 32 KiB in and re-indents the rest.
512 ///
513 export fn mux_paste_begin() i32 { 492 export fn mux_paste_begin() i32 {
514 const c = core orelse { 493 const c = core orelse {
515 output_len = 0; 494 output_len = 0;
@@ -542,16 +521,8 @@ export fn mux_paste_end() i32 {
542 // Flat viewport readout 521 // Flat viewport readout
543 // --------------------------------------------------------------------- 522 // ---------------------------------------------------------------------
544 523
545 /// cols*rows cells, 4 u32 each, row-major: 524 /// cols*rows cells, 4 u32 each, row-major; paintRow packs them. Valid
546 /// [0] codepoint (0 = empty cell) 525 /// until the next call that can grow memory.
547 /// [1] fg: kind << 24 | value (kind 0 none, 1 palette, 2 rgb;
548 /// value = palette index or 0xRRGGBB)
549 /// [2] bg: same packing
550 /// [3] flags: ghostty's u16 style flags (bit 0 bold, 1 italic, 2 faint,
551 /// 3 blink, 4 inverse, 5 invisible, 6 strikethrough, 7 overline,
552 /// bits 8-10 underline style) | wide << 16 | spacer << 17
553 /// Valid until the next call that can grow memory — JS re-reads
554 /// memory.buffer every time (see the module header).
555 export fn mux_viewport_ptr() [*]const u32 { 526 export fn mux_viewport_ptr() [*]const u32 {
556 const c = core orelse return &empty_viewport; 527 const c = core orelse return &empty_viewport;
557 return c.viewport.ptr; 528 return c.viewport.ptr;
@@ -608,6 +579,10 @@ fn paintRow(c: *Core, eng: *Engine, y: u16) void {
608 .wide => 1 << 16, 579 .wide => 1 << 16,
609 .spacer_tail, .spacer_head => 1 << 17, 580 .spacer_tail, .spacer_head => 1 << 17,
610 }; 581 };
582 // The flag word JS decodes: ghostty's u16 style flags (bit 0 bold,
583 // 1 italic, 2 faint, 3 blink, 4 inverse, 5 invisible,
584 // 6 strikethrough, 7 overline, bits 8-10 underline style), then
585 // wide << 16 and spacer << 17 from the switch above.
611 c.viewport[base + 3] = @as(u32, @as(u16, @bitCast(style.flags))) | wide; 586 c.viewport[base + 3] = @as(u32, @as(u16, @bitCast(style.flags))) | wide;
612 } 587 }
613 } 588 }
src/webhub.zig
Old New
@@ -28,12 +28,10 @@ pub const default_port: u16 = 7681;
28 /// browser has no such bound (u64 lengths) — snapshots are safe. 28 /// browser has no such bound (u64 lengths) — snapshots are safe.
29 pub const ws_buffer_len = 64 * 1024; 29 pub const ws_buffer_len = 64 * 1024;
30 30
31 /// The WebSocket Origin check, non-negotiable (spec): any webpage open 31 /// Any webpage may dial ws://127.0.0.1:PORT — localhost binding does not
32 /// in the browser may attempt ws://127.0.0.1:PORT — localhost binding 32 /// stop a cross-origin WebSocket, and this socket carries shell input to
33 /// does not stop cross-origin WebSocket dials, and this socket carries 33 /// every device. std's upgradeRequested does not check Origin; this is
34 /// shell input to every device. Exactly our own two spellings pass; 34 /// entirely ours.
35 /// no Origin header refuses. std's upgradeRequested does NOT check this;
36 /// it is entirely ours.
37 pub fn originAllowed(origin: ?[]const u8, port: u16) bool { 35 pub fn originAllowed(origin: ?[]const u8, port: u16) bool {
38 const o = origin orelse return false; 36 const o = origin orelse return false;
39 var buf: [40]u8 = undefined; 37 var buf: [40]u8 = undefined;
@@ -45,10 +43,9 @@ pub fn originAllowed(origin: ?[]const u8, port: u16) bool {
45 return false; 43 return false;
46 } 44 }
47 45
48 /// `/ws/<id>` → the tile id, or null when the path is not ours or the id 46 /// `/ws/<id>` → the tile id, with no opinion on range: ids go sparse the
49 /// does not parse. Deliberately no range opinion: ids are hub-assigned and 47 /// moment a tile is removed, so only the Hub's map, under its mutex, can
50 /// go sparse the moment a tile is removed, so "is this id live" is a 48 /// answer "is this id live".
51 /// question only the Hub's map can answer, under its mutex.
52 pub fn wsTileId(path: []const u8) ?u32 { 49 pub fn wsTileId(path: []const u8) ?u32 {
53 const prefix = "/ws/"; 50 const prefix = "/ws/";
54 if (!std.mem.startsWith(u8, path, prefix)) return null; 51 if (!std.mem.startsWith(u8, path, prefix)) return null;
@@ -80,9 +77,8 @@ pub const AddError = wall.ParseError || ResolveError || error{PersistFailed};
80 /// with `Hub.init`, which resolves the whole wall before serving. 77 /// with `Hub.init`, which resolves the whole wall before serving.
81 pub const ResolveError = error{ MissingKey, SockPathTooLong, OutOfMemory }; 78 pub const ResolveError = error{ MissingKey, SockPathTooLong, OutOfMemory };
82 79
83 /// Spelling → client.Target. The same resolution argv gets at startup, 80 /// Resolves as argv does at startup, so a runtime tile means what the
84 /// so a tile added at runtime means exactly what one typed on the command 81 /// command line means.
85 /// line means. Allocates into `arena` (the tile's own).
86 fn resolveTile( 82 fn resolveTile(
87 arena: std.mem.Allocator, 83 arena: std.mem.Allocator,
88 spelling: []const u8, 84 spelling: []const u8,
@@ -346,12 +342,8 @@ pub const Hub = struct {
346 try self.persist(); 342 try self.persist();
347 } 343 }
348 344
349 /// The pump's copy of the target, in the pump's own arena, plus the 345 /// UnknownId vs OutOfMemory: the HTTP layer answers 404 for one,
350 /// fd registration that lets removeTile reach it. UnknownId when the 346 /// 500 for the other.
351 /// id is gone — a browser can always dial a tile another device just
352 /// removed — kept distinct from OutOfMemory so the HTTP layer can
353 /// answer 404 for one and 500 for the other instead of folding a
354 /// memory failure into "no such tile".
355 pub fn checkoutTarget( 347 pub fn checkoutTarget(
356 self: *Hub, 348 self: *Hub,
357 id: u32, 349 id: u32,
@@ -378,10 +370,10 @@ pub const Hub = struct {
378 return copy; 370 return copy;
379 } 371 }
380 372
381 /// Unregister, but only the fd this caller registered: a second pump 373 /// Unregister only the fd this caller registered: a second pump
382 /// releasing must not clear the tracked pump's registration. Must run 374 /// releasing must not clear the tracked pump's. Must run BEFORE the
383 /// BEFORE the caller closes the fd, or a concurrent removeTile could 375 /// caller closes the fd, or a concurrent removeTile could shutdown a
384 /// shutdown a number the kernel has already handed to somebody else. 376 /// number the kernel has already recycled.
385 pub fn releaseTile(self: *Hub, id: u32, ws_fd: std.posix.fd_t) void { 377 pub fn releaseTile(self: *Hub, id: u32, ws_fd: std.posix.fd_t) void {
386 self.mutex.lock(); 378 self.mutex.lock();
387 defer self.mutex.unlock(); 379 defer self.mutex.unlock();
@@ -416,8 +408,7 @@ pub const Hub = struct {
416 } 408 }
417 }; 409 };
418 410
419 /// The embedded page, injected by the exe root (webhub_main @embedFiles 411 /// The embedded page: webhub_main @embedFiles them, tests inject fakes.
420 /// them; tests inject fakes).
421 pub const Assets = struct { 412 pub const Assets = struct {
422 index_html: []const u8, 413 index_html: []const u8,
423 mux_js: []const u8, 414 mux_js: []const u8,
@@ -602,19 +593,16 @@ pub fn headFrame(buffered: []const u8, capacity: usize) HeadFrame {
602 pub const ping_idle_ms: i64 = 30_000; 593 pub const ping_idle_ms: i64 = 30_000;
603 pub const dead_intervals: i64 = 3; 594 pub const dead_intervals: i64 = 3;
604 595
605 /// The browser leg's liveness, carried BY POINTER across dialLoop — which 596 /// The browser leg's liveness, carried BY POINTER across dialLoop —
606 /// is the whole point of it being a struct. 597 /// which is the whole point of it being a struct.
607 /// 598 ///
608 /// A reconnect is exactly when a dead browser is most likely (the outage 599 /// A reconnect is when a dead browser is most likely and when nothing
609 /// and the closed laptop have the same cause more often than not) and 600 /// else is watching. Two shortcuts are both wrong: leaving the timer to
610 /// exactly when nothing else is watching. Two tempting shortcuts are both 601 /// the pump blinds the check for the length of the outage; resetting the
611 /// wrong: leaving the timer to the pump makes the check blind for as long 602 /// clock on the way out of dialLoop blinds it AND lets a long outage hide
612 /// as the outage lasts, while resetting the clock on the way out of 603 /// a browser that died mid-way. So one ping runs in both loops against
613 /// dialLoop makes it blind AND lets a long outage hide a browser that 604 /// one clock. A browser merely waiting answers the ping in its WebSocket
614 /// died in the middle of it. So the same ping runs in both loops against 605 /// stack without waking the page, so silence here really is silence.
615 /// one clock. A browser that is merely waiting answers the ping itself —
616 /// the WebSocket stack does that without waking the page — so silence
617 /// here really is silence.
618 const Liveness = struct { 606 const Liveness = struct {
619 last_inbound_ms: i64, 607 last_inbound_ms: i64,
620 pings_sent: i64 = 0, 608 pings_sent: i64 = 0,
@@ -645,22 +633,8 @@ const Liveness = struct {
645 /// when a transport was handed in — the dial loop drains with none. 633 /// when a transport was handed in — the dial loop drains with none.
646 const Drained = enum { ok, browser_dead, transport_dead }; 634 const Drained = enum { ok, browser_dead, transport_dead };
647 635
648 /// The browser leg, drained to the last buffered byte. Both loops use 636 /// Both loops drain the browser leg through this copy; the optional
649 /// this one copy: the five decisions are the same five in the same order 637 /// transport is their only difference.
650 /// (fill, incomplete, too_big, pong, ready), and the only thing that
651 /// differs is whether a data frame has a transport to go to — which is
652 /// exactly what the optional says.
653 ///
654 /// `fill` is the caller's poll readiness, and it gates the ONE blocking
655 /// step. Everything after it reads the reader's own buffer, so the drain
656 /// itself runs on every pass whether or not poll fired. That is not
657 /// thrift, it is the correctness of the thing: bytes already in
658 /// userspace are invisible to poll — they are what made it fire the
659 /// previous time — so a drain that only ran on readiness would strand
660 /// whatever a mid-drain reconnect left behind until the browser happened
661 /// to send more. A close frame stranded that way holds this tile's
662 /// daemon client slot open until the reaper takes it. The cost of the
663 /// guarantee is one headFrame call over an empty buffer per idle pass.
664 fn drainBrowser( 638 fn drainBrowser(
665 ws: *std.http.Server.WebSocket, 639 ws: *std.http.Server.WebSocket,
666 live: *Liveness, 640 live: *Liveness,
@@ -670,6 +644,11 @@ fn drainBrowser(
670 // poll reports what the KERNEL holds; one fill turns that readable 644 // poll reports what the KERNEL holds; one fill turns that readable
671 // event into buffered bytes, and it cannot block — poll just said 645 // event into buffered bytes, and it cannot block — poll just said
672 // there are bytes (or an EOF, which ends the tile right here). 646 // there are bytes (or an EOF, which ends the tile right here).
647 // Readiness gates only this step: bytes already in userspace are
648 // invisible to poll (they are what made it fire last time), so a
649 // drain that ran only on readiness would strand whatever a mid-drain
650 // reconnect left behind — a close frame stranded that way holds this
651 // tile's daemon client slot until the reaper takes it.
673 if (fill) ws.input.fillMore() catch return .browser_dead; 652 if (fill) ws.input.fillMore() catch return .browser_dead;
674 while (true) { 653 while (true) {
675 // Every read is gated on headFrame: readSmallMessage may not be 654 // Every read is gated on headFrame: readSmallMessage may not be
@@ -710,11 +689,8 @@ fn drainBrowser(
710 } 689 }
711 } 690 }
712 691
713 /// Narrate the outage, re-dial, narrate the recovery. False means the 692 /// False means the tile is over. Close is idempotent, so a failed
714 /// tile is over: the browser hung up (or could not be told) while we were 693 /// re-dial leaves the caller's deferred close with nothing to do.
715 /// dialing, and the caller returns. `transport` is closed and replaced in
716 /// place — close is idempotent, so a failed re-dial leaves the caller's
717 /// `defer transport.close()` with nothing to do.
718 fn redial( 694 fn redial(
719 alloc: std.mem.Allocator, 695 alloc: std.mem.Allocator,
720 transport: *client.Transport, 696 transport: *client.Transport,
@@ -730,23 +706,8 @@ fn redial(
730 return true; 706 return true;
731 } 707 }
732 708
733 /// One thread per tile, and blocking is the design: the tile's Transport 709 /// One thread per tile: Transport.readFrame's blocking read is
734 /// is private to this thread, so Transport.readFrame's blocking read 710 /// correct here. The hub reconnects; the browser re-attaches.
735 /// (fatal to a multiplexing hub) is simply correct here. A slow browser
736 /// stalls only its own tile (v1 stance: the daemon side is protected by
737 /// its own 8 MiB pending cap; the hub's upstream reads just stall).
738 ///
739 /// The hub owns reconnection; the browser owns re-attach. On transport
740 /// death this narrates `reconnecting`, re-dials on the CLI's own backoff
741 /// schedule (client.nextBackoffMs — no retry cap, deliberately), then
742 /// narrates `up`; the browser's replica quotes have_seq/have_epoch in a
743 /// fresh attach and the snapshot-vs-delta resolution does the rest.
744 ///
745 /// The dead-leg bound below is best-effort rather than a bound: it is
746 /// measured between passes of this loop, and a blocking upstream
747 /// readFrame parks the pass it is in for as long as the transport takes.
748 /// An ssh-fallback tile whose peer goes quiet tears on the transport's
749 /// own terms, not on 90 seconds.
750 pub fn pumpTile( 711 pub fn pumpTile(
751 alloc: std.mem.Allocator, 712 alloc: std.mem.Allocator,
752 ws: *std.http.Server.WebSocket, 713 ws: *std.http.Server.WebSocket,
@@ -826,17 +787,15 @@ pub fn pumpTile(
826 // Is anyone still there? A closed tab usually arrives as a close 787 // Is anyone still there? A closed tab usually arrives as a close
827 // frame or EOF, but a laptop that slept, a killed browser, or a 788 // frame or EOF, but a laptop that slept, a killed browser, or a
828 // dropped ssh -L leaves the socket half-open and silent forever. 789 // dropped ssh -L leaves the socket half-open and silent forever.
790 // Best-effort, not a bound: it is measured between passes, and a
791 // blocking upstream readFrame parks the pass it is in for as long
792 // as the transport takes.
829 if (!live.tick(ws)) return; 793 if (!live.tick(ws)) return;
830 } 794 }
831 } 795 }
832 796
833 /// Dial with the CLI's backoff schedule until the transport opens or the 797 /// Null when the browser hangs up. A message arriving mid-backoff is
834 /// browser hangs up (null). While waiting out a backoff the WS is 798 /// dropped; the browser re-attaches on `up` anyway.
835 /// watched: a message that arrives with no transport to carry it is
836 /// dropped (the browser re-attaches on `up` anyway), a dead WS ends the
837 /// tile — and `live` runs here on the same clock the pump uses, because
838 /// an outage is when a browser is most likely to die and when nothing
839 /// else is looking.
840 fn dialLoop( 799 fn dialLoop(
841 alloc: std.mem.Allocator, 800 alloc: std.mem.Allocator,
842 target: client.Target, 801 target: client.Target,
@@ -875,20 +834,11 @@ fn dialLoop(
875 } 834 }
876 } 835 }
877 836
878 /// One accepted connection, start to finish. Static 837 /// Labels are argv, not hostile input, but a path with a quote in it must
879 /// requests loop for keep-alive; a WebSocket upgrade consumes the 838 /// not break the page. Deliberately NOT muxa's jsonEscape: this one sends
880 /// connection into a tile pump and never returns to HTTP. 839 /// every control byte to `\u00XX` (one rule, no table to get wrong) while
881 /// `/tiles`: the runtime half the embedded page cannot know — one object 840 /// muxa spells the short forms. Both parse identically, the bytes differ,
882 /// per tile in index order, `{"label":…,"session":…}`. Escaping covers the 841 /// and each is pinned by its own test.
883 /// two bytes JSON cannot carry raw in a string plus control chars; labels
884 /// are argv (hosts, paths), not hostile input, but a path with a quote
885 /// in it must not break the page.
886 ///
887 /// Deliberately NOT muxa's jsonEscape, though the two look alike: this one
888 /// sends every control byte to `\u00XX` (one rule, no table to get wrong)
889 /// while muxa spells the short forms `\n`, `\r`, `\t`. Both are valid JSON
890 /// and parse identically, but the bytes differ, and each is pinned by its
891 /// own test. Sharing one would rewrite one side's output for no gain.
892 fn appendJsonString(alloc: std.mem.Allocator, out: *std.ArrayList(u8), s: []const u8) !void { 842 fn appendJsonString(alloc: std.mem.Allocator, out: *std.ArrayList(u8), s: []const u8) !void {
893 try out.append(alloc, '"'); 843 try out.append(alloc, '"');
894 for (s) |c| switch (c) { 844 for (s) |c| switch (c) {
@@ -915,6 +865,8 @@ pub fn parseIdList(alloc: std.mem.Allocator, body: []const u8) error{ Bad, OutOf
915 return out.toOwnedSlice(alloc); 865 return out.toOwnedSlice(alloc);
916 } 866 }
917 867
868 /// Static requests loop for keep-alive; a WS upgrade takes the connection
869 /// and never returns to HTTP.
918 pub fn serveConn( 870 pub fn serveConn(
919 alloc: std.mem.Allocator, 871 alloc: std.mem.Allocator,
920 stream: std.net.Stream, 872 stream: std.net.Stream,
src/webhub_main.zig
Old New
@@ -40,21 +40,17 @@ const usage =
40 \\ 40 \\
41 ; 41 ;
42 42
43 /// Validate one wall spelling and take an owned copy of it. 43 /// Refused at usage altitude; downstream it is a rejected attach in
44 /// 44 /// one tile, unexplained.
45 /// A bad spelling is refused HERE, at usage-error altitude, rather than
46 /// downstream where it would arrive as a rejected attach in one tile with
47 /// nothing on the hub's console to explain it. The message names the tile:
48 /// with several targets on the line, `usage` alone would not say which.
49 ///
50 /// wall.parseSpelling is the ONE grammar — the same one the state file and
51 /// the hub's POST /tiles are read with, so what argv accepts is exactly
52 /// what the page can add.
53 fn addSpelling( 45 fn addSpelling(
54 alloc: std.mem.Allocator, 46 alloc: std.mem.Allocator,
55 list: *std.ArrayList([]const u8), 47 list: *std.ArrayList([]const u8),
56 spelling: []const u8, 48 spelling: []const u8,
57 ) ParseError!void { 49 ) ParseError!void {
50 // The ONE grammar: argv, the state file and POST /tiles are all read
51 // with this, so what argv accepts is exactly what the page can add.
52 // The message names the tile — with several targets on the line,
53 // `usage` alone would not say which.
58 _ = wall.parseSpelling(spelling) catch |err| { 54 _ = wall.parseSpelling(spelling) catch |err| {
59 std.debug.print("muxweb: tile {s}: {s}\n", .{ spelling, switch (err) { 55 std.debug.print("muxweb: tile {s}: {s}\n", .{ spelling, switch (err) {
60 error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", 56 error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')",
src/xdg.zig
Old New
@@ -7,7 +7,6 @@
7 //! inspection. 7 //! inspection.
8 const std = @import("std"); 8 const std = @import("std");
9 9
10 /// `$XDG_CONFIG_HOME/mux/key`, defaulting to `~/.config/mux/key`.
11 /// The one place the default key location is spelled; muxd keygen writes 10 /// The one place the default key location is spelled; muxd keygen writes
12 /// it and both binaries' key resolution reads it. 11 /// it and both binaries' key resolution reads it.
13 pub fn keyPath(alloc: std.mem.Allocator) ![]const u8 { 12 pub fn keyPath(alloc: std.mem.Allocator) ![]const u8 {
@@ -30,10 +29,8 @@ pub const KeyResolution = union(enum) {
30 missing: []const u8, 29 missing: []const u8,
31 }; 30 };
32 31
33 /// The key-resolution rule both binaries follow: an explicit spelling 32 /// ONE owner: a drift here would mean two binaries disagreeing about which
34 /// wins, otherwise the XDG default must already exist. ONE owner, because 33 /// key a `quic://` target authenticates with.
35 /// a drift here would mean two binaries disagreeing about which key a
36 /// `quic://` target authenticates with.
37 pub fn resolveKeyPath(alloc: std.mem.Allocator, given: ?[]const u8) !KeyResolution { 34 pub fn resolveKeyPath(alloc: std.mem.Allocator, given: ?[]const u8) !KeyResolution {
38 if (given) |g| return .{ .given = g }; 35 if (given) |g| return .{ .given = g };
39 const p = try keyPath(alloc); 36 const p = try keyPath(alloc);
@@ -41,12 +38,8 @@ pub fn resolveKeyPath(alloc: std.mem.Allocator, given: ?[]const u8) !KeyResoluti
41 return .{ .default = p }; 38 return .{ .default = p };
42 } 39 }
43 40
44 /// Which spelling of the key path a command line meant: `--key` beats 41 /// `--key` beats `$MUX_KEY_FILE`: the flag is the more specific intent.
45 /// `$MUX_KEY_FILE`, because the flag is the more specific statement of 42 /// An empty spelling of either is unset, not a key at the empty path.
46 /// intent, and an empty spelling of either means unset rather than a key
47 /// at the empty path. Both binaries' parsers call this — it lives beside
48 /// resolveKeyPath because it is the step before it, and a drift between
49 /// two copies would make them authenticate with different keys.
50 pub fn pickKey(flag: ?[]const u8, env: ?[]const u8) ?[]const u8 { 43 pub fn pickKey(flag: ?[]const u8, env: ?[]const u8) ?[]const u8 {
51 const k = flag orelse env orelse return null; 44 const k = flag orelse env orelse return null;
52 return if (k.len == 0) null else k; 45 return if (k.len == 0) null else k;
@@ -63,7 +56,6 @@ pub fn keyPathFrom(
63 return std.fmt.allocPrint(alloc, "{s}/.config/mux/key", .{h}); 56 return std.fmt.allocPrint(alloc, "{s}/.config/mux/key", .{h});
64 } 57 }
65 58
66 /// `$XDG_STATE_HOME/mux/muxd.log`, defaulting to `~/.local/state/mux/muxd.log`.
67 /// Truncated at each spawn by the spawner: it holds the current daemon's 59 /// Truncated at each spawn by the spawner: it holds the current daemon's
68 /// stdout+stderr, not history. 60 /// stdout+stderr, not history.
69 pub fn logPath(alloc: std.mem.Allocator) ![]const u8 { 61 pub fn logPath(alloc: std.mem.Allocator) ![]const u8 {
@@ -81,11 +73,8 @@ pub fn logPathFrom(
81 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/muxd.log", .{h}); 73 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/muxd.log", .{h});
82 } 74 }
83 75
84 /// `$XDG_CACHE_HOME/mux/hosts/<host>`, defaulting to 76 /// Where `mux HOST` remembers the last announce. A host containing a path
85 /// `~/.cache/mux/hosts/<host>`. Where `mux HOST` remembers the last 77 /// separator is refused; the caller attaches uncached rather than failing.
86 /// announce. A host containing a path separator is refused — it would
87 /// name a different file than it means — and the caller attaches
88 /// uncached rather than failing.
89 pub fn hostCachePath(alloc: std.mem.Allocator, host: []const u8) ![]const u8 { 78 pub fn hostCachePath(alloc: std.mem.Allocator, host: []const u8) ![]const u8 {
90 return hostCachePathFrom(alloc, host, std.posix.getenv("XDG_CACHE_HOME"), std.posix.getenv("HOME")); 79 return hostCachePathFrom(alloc, host, std.posix.getenv("XDG_CACHE_HOME"), std.posix.getenv("HOME"));
91 } 80 }
@@ -132,23 +121,15 @@ pub fn makePrivateDir(dir: []const u8) !void {
132 try d.chmod(0o700); 121 try d.chmod(0o700);
133 } 122 }
134 123
135 /// The same 0700 policy, for a directory in a parent this process does NOT 124 /// `makePrivateDir`'s 0700 policy where the parent is not ours:
136 /// own — `$XDG_RUNTIME_DIR` or, when that is unset, a shared `/tmp`. The 125 /// `$XDG_RUNTIME_DIR`, or a shared `/tmp` when that is unset. So it refuses
137 /// difference from `makePrivateDir` is the whole point: this refuses an 126 /// an existing entry instead of adopting it: one pre-created by another
138 /// entry that is already there instead of adopting it. 127 /// user as a symlink would take the chmod to the link's TARGET, leaving the
128 /// caller writing inside a directory it does not own.
139 /// 129 ///
140 /// `makePrivateDir` tolerates a pre-existing entry and reaches it through 130 /// The mode goes to `mkdir` so the directory is never briefly 0755; the
141 /// whatever the path resolves to, which is correct under `~` and is a 131 /// chmod after it undoes the umask, which on 0500 would lock this daemon
142 /// symlink attack anywhere else: an entry pre-created by another user as a 132 /// out.
143 /// symlink would have `makePath` succeed, the chmod land on the link's
144 /// TARGET, and the caller's files then be written inside a directory it
145 /// does not own. So: one `mkdir`, which never follows a symlink and never
146 /// adopts an existing entry, and a `no_follow` open for the mode.
147 ///
148 /// The mode is passed to `mkdir` rather than chmod'd on afterwards so the
149 /// directory is never briefly 0755; the chmod that follows is for the
150 /// umask, which can only take bits away and on an odd one (0500) would
151 /// leave a directory this daemon cannot itself use.
152 pub fn makeNewPrivateDir(dir: []const u8) !void { 133 pub fn makeNewPrivateDir(dir: []const u8) !void {
153 std.posix.mkdir(dir, 0o700) catch |err| switch (err) { 134 std.posix.mkdir(dir, 0o700) catch |err| switch (err) {
154 // Not ours. Named separately from the other errors because it is 135 // Not ours. Named separately from the other errors because it is
tools/docscheck.zig
Old New
@@ -50,9 +50,8 @@ fn isWordByte(c: u8) bool {
50 return std.ascii.isAlphanumeric(c) or c == '_'; 50 return std.ascii.isAlphanumeric(c) or c == '_';
51 } 51 }
52 52
53 /// A comment line for this tool's purposes: the first non-space bytes are 53 /// Whole-line only: a trailing comment in src/ is overwhelmingly a wire
54 /// `//`. Covers `//`, `///` and `//!` alike. Trailing comments are excluded 54 /// spelling inside a literal.
55 /// because src/'s are overwhelmingly a wire spelling inside a literal.
56 fn commentBody(line: []const u8) ?[]const u8 { 55 fn commentBody(line: []const u8) ?[]const u8 {
57 const t = std.mem.trimLeft(u8, line, " \t"); 56 const t = std.mem.trimLeft(u8, line, " \t");
58 if (!std.mem.startsWith(u8, t, "//")) return null; 57 if (!std.mem.startsWith(u8, t, "//")) return null;