fad55914
docs: the whole tree stops telling stories
a73x 2026-08-23 09:50
Commit message
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; |