a73x

ca93662f

Restore the claims the prose ratchets cut from src/server

a73x   2026-08-31 11:28

Commit message
Restore the claims the prose ratchets cut from src/server

The compression pass reached its byte counts by dropping the checkable
half of each comment, and this puts it back where the loss cost a reader
something:

- shellint's shim directory kept its symlink threat and lost the defence
  (`prepare` creating the directory exclusively), leaving the random
  suffix reading as the mitigation, which it is not.
- `answer_ms = 5000` lost its derivation: ten times the preflight's
  round-trip bound (`mux_main.agent_probe_ms`), and the 2ms fall-through
  that gives the measured 8s wedge its meaning.
- cmd.zig lost the `end <= start` convention by name, and `status_reply`
  as the consumer that reads a span mid-run.
- `AgentChan.session` lost "even when its client attaches elsewhere
  first", which is the case the field exists for.

Rename `cmd.Tracker` to `cmd.LiveCommand`. It holds one command and
forgets it at the next `command_start`; the thing that accumulates is
`Session.last_return`, so "tracker" named the opposite of the job. The
comments no longer spell the OSC 133 marks `A`/`C`/`D` — every use sat
beside the `prompt_start`/`command_start`/`command_end` tag it encoded,
so the letters were a decode step over what was already on the line.

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

src/server/cmd.zig
Old New
@@ -1,41 +1,50 @@
1 //! The session's command state machine: MarkEvents in, transitions out. Pure — 1 //! `LiveCommand` follows one session's command through the OSC 133 marks its
2 //! no I/O, no clock, no seq. The trust rule: a D only counts if it closes a 2 //! shell emits: it parses each MarkEvent and determines the transition that
3 //! seen C, and a stray D resets to `at_prompt` rather than being believed. A 3 //! mark causes, if any. It is pure — the server stamps the seqs and puts the
4 //! nested C is believed wholesale, and a stray A at idle is a no-op. 4 //! transitions on the wire as `cmd_state`, `await_reply` and `status_reply`.
5 //!
6 //! The trust rule: a `command_end` counts only if it closes a `command_start`
7 //! already seen; an unmatched one resets to `at_prompt` rather than being
8 //! believed. A `command_start` while one is open is believed wholesale — latest
9 //! wins, with no attempt to detect nesting — and a `prompt_start` at an idle
10 //! prompt does nothing.
5 const std = @import("std"); 11 const std = @import("std");
6 const proto = @import("term").protocol; 12 const proto = @import("term").protocol;
7 const Engine = @import("term").engine.Engine; 13 const Engine = @import("term").engine.Engine;
8 14
9 pub const Tracker = struct { 15 pub const LiveCommand = struct {
10 phase: proto.CmdPhase = .at_prompt, 16 phase: proto.CmdPhase = .at_prompt,
11 /// Sticky: once any C has been seen, this session speaks marks and the 17 /// Sticky: once any `command_start` has been seen, this session speaks marks
12 /// pgid fallback stops being consulted while a command is open. 18 /// and the pgid fallback stops being consulted while a command is open.
13 marks_seen: bool = false, 19 marks_seen: bool = false,
14 start_row: u32 = 0, 20 start_row: u32 = 0,
15 end_row: u32 = 0, 21 end_row: u32 = 0,
16 exit_code: ?u8 = null, 22 exit_code: ?u8 = null,
17 23
18 /// `.reset` means the tracker forcibly settled to `at_prompt`; it is a 24 /// `.reset` means it forcibly settled to `at_prompt`; that is a
19 /// state change to observe, never a transition to broadcast. 25 /// state change to observe, never a transition to broadcast.
20 pub const Transition = enum { running, returned, reset }; 26 pub const Transition = enum { running, returned, reset };
21 27
22 pub fn apply(self: *Tracker, ev: Engine.MarkEvent) ?Transition { 28 pub fn apply(self: *LiveCommand, ev: Engine.MarkEvent) ?Transition {
23 switch (ev.kind) { 29 switch (ev.kind) {
24 .command_start => { 30 .command_start => {
25 self.marks_seen = true; 31 self.marks_seen = true;
26 self.phase = .running; 32 self.phase = .running;
27 self.start_row = ev.row; 33 self.start_row = ev.row;
28 // The new command has no end row, and the PREVIOUS command's 34 // The new command has no end row, and the PREVIOUS command's
29 // would read as a span running backwards. Collapsing it to the 35 // would read as a span running backwards (end < start) to
30 // start row says the span is empty until a D closes it. 36 // anyone who fetched it mid-run — a mid-command `status_reply`
37 // is exactly that fetch. Collapsing it to the start row says
38 // what is true under the end <= start convention: the span is
39 // empty until a `command_end` closes it.
31 self.end_row = ev.row; 40 self.end_row = ev.row;
32 self.exit_code = null; 41 self.exit_code = null;
33 return .running; 42 return .running;
34 }, 43 },
35 .command_end => { 44 .command_end => {
36 if (self.phase != .running) { 45 if (self.phase != .running) {
37 // A D with no open C: a nested program echoing marks it 46 // Ending a command that never started: a nested program
38 // has no business emitting. Reset, believe nothing. 47 // echoing marks it has no business emitting. Believe nothing.
39 self.phase = .at_prompt; 48 self.phase = .at_prompt;
40 return .reset; 49 return .reset;
41 } 50 }
@@ -45,9 +54,9 @@ pub const Tracker = struct {
45 return .returned; 54 return .returned;
46 }, 55 },
47 .prompt_start => { 56 .prompt_start => {
48 // 'A' after a return is the prompt redrawing: back to rest. 57 // After a return this is the prompt redrawing: back to rest.
49 // 'A' mid-run (Ctrl-C redraw) also lands here — the shell 58 // Mid-run it is a Ctrl-C redraw — the shell saying the command
50 // is telling us the command is over even without a D. 59 // is over without ever ending it.
51 if (self.phase == .running) { 60 if (self.phase == .running) {
52 self.phase = .returned; 61 self.phase = .returned;
53 self.end_row = ev.row; 62 self.end_row = ev.row;
@@ -61,43 +70,43 @@ pub const Tracker = struct {
61 } 70 }
62 71
63 /// The window in which the pgid fallback must NOT race the marks. 72 /// The window in which the pgid fallback must NOT race the marks.
64 pub fn marksOpen(self: *const Tracker) bool { 73 pub fn marksOpen(self: *const LiveCommand) bool {
65 return self.marks_seen and self.phase == .running; 74 return self.marks_seen and self.phase == .running;
66 } 75 }
67 }; 76 };
68 77
69 test "C then D is running then returned, with rows and code" { 78 test "a command that starts then ends goes running then returned, with rows and code" {
70 var t = Tracker{}; 79 var t = LiveCommand{};
71 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 10, .exit_code = null })); 80 try std.testing.expectEqual(@as(?LiveCommand.Transition, .running), t.apply(.{ .kind = .command_start, .row = 10, .exit_code = null }));
72 try std.testing.expectEqual(proto.CmdPhase.running, t.phase); 81 try std.testing.expectEqual(proto.CmdPhase.running, t.phase);
73 try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .command_end, .row = 14, .exit_code = 1 })); 82 try std.testing.expectEqual(@as(?LiveCommand.Transition, .returned), t.apply(.{ .kind = .command_end, .row = 14, .exit_code = 1 }));
74 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase); 83 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
75 try std.testing.expectEqual(@as(u32, 10), t.start_row); 84 try std.testing.expectEqual(@as(u32, 10), t.start_row);
76 try std.testing.expectEqual(@as(u32, 14), t.end_row); 85 try std.testing.expectEqual(@as(u32, 14), t.end_row);
77 try std.testing.expectEqual(@as(?u8, 1), t.exit_code); 86 try std.testing.expectEqual(@as(?u8, 1), t.exit_code);
78 } 87 }
79 88
80 test "a stray D resets and is not believed" { 89 test "an unmatched command_end resets and is not believed" {
81 var t = Tracker{}; 90 var t = LiveCommand{};
82 try std.testing.expectEqual(@as(?Tracker.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 3, .exit_code = 0 })); 91 try std.testing.expectEqual(@as(?LiveCommand.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 3, .exit_code = 0 }));
83 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase); 92 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
84 try std.testing.expectEqual(@as(?u8, null), t.exit_code); 93 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
85 } 94 }
86 95
87 test "A closes an open command without a code (Ctrl-C at a prompt redraw)" { 96 test "a prompt_start closes an open command without a code (Ctrl-C at a prompt redraw)" {
88 var t = Tracker{}; 97 var t = LiveCommand{};
89 _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null }); 98 _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null });
90 try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null })); 99 try std.testing.expectEqual(@as(?LiveCommand.Transition, .returned), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
91 try std.testing.expectEqual(@as(?u8, null), t.exit_code); 100 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
92 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase); 101 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
93 try std.testing.expectEqual(@as(u32, 6), t.end_row); 102 try std.testing.expectEqual(@as(u32, 6), t.end_row);
94 // The next A settles back to rest with no transition. 103 // The next `prompt_start` settles back to rest with no transition.
95 try std.testing.expectEqual(@as(?Tracker.Transition, null), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null })); 104 try std.testing.expectEqual(@as(?LiveCommand.Transition, null), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
96 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase); 105 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
97 } 106 }
98 107
99 test "marksOpen guards the pgid race window" { 108 test "marksOpen guards the pgid race window" {
100 var t = Tracker{}; 109 var t = LiveCommand{};
101 try std.testing.expect(!t.marksOpen()); 110 try std.testing.expect(!t.marksOpen());
102 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null }); 111 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
103 try std.testing.expect(t.marksOpen()); 112 try std.testing.expect(t.marksOpen());
@@ -108,36 +117,38 @@ test "marksOpen guards the pgid race window" {
108 try std.testing.expect(t.marks_seen); 117 try std.testing.expect(t.marks_seen);
109 } 118 }
110 119
111 test "back-to-back commands: second C reopens cleanly" { 120 test "back-to-back commands: the second command_start reopens cleanly" {
112 var t = Tracker{}; 121 var t = LiveCommand{};
113 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null }); 122 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
114 _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 0 }); 123 _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 0 });
115 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 4, .exit_code = null })); 124 try std.testing.expectEqual(@as(?LiveCommand.Transition, .running), t.apply(.{ .kind = .command_start, .row = 4, .exit_code = null }));
116 try std.testing.expectEqual(@as(u32, 4), t.start_row); 125 try std.testing.expectEqual(@as(u32, 4), t.start_row);
117 try std.testing.expectEqual(@as(?u8, null), t.exit_code); 126 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
118 // Nothing of the finished command survives into the running one — the END 127 // Nothing of the finished command survives into the running one — the END
119 // ROW especially, which left alone describes a span ending before it starts. 128 // ROW especially, which left at the finished command's value describes a
120 // Equal rows are the empty span consumers already read as "no output yet". 129 // span ending before it starts, and that is what a mid-command
130 // `status_reply` would hand an agent. Equal rows are the empty span every
131 // consumer already reads as "no output yet".
121 try std.testing.expectEqual(@as(u32, 4), t.end_row); 132 try std.testing.expectEqual(@as(u32, 4), t.end_row);
122 try std.testing.expect(t.end_row <= t.start_row); 133 try std.testing.expect(t.end_row <= t.start_row);
123 } 134 }
124 135
125 test "a nested C while running is believed wholesale: latest wins" { 136 test "a nested command_start while running is believed wholesale: latest wins" {
126 var t = Tracker{}; 137 var t = LiveCommand{};
127 _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null }); 138 _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null });
128 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 9, .exit_code = null })); 139 try std.testing.expectEqual(@as(?LiveCommand.Transition, .running), t.apply(.{ .kind = .command_start, .row = 9, .exit_code = null }));
129 try std.testing.expectEqual(@as(u32, 9), t.start_row); 140 try std.testing.expectEqual(@as(u32, 9), t.start_row);
130 try std.testing.expectEqual(@as(?u8, null), t.exit_code); 141 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
131 } 142 }
132 143
133 test "a stray D after a completed command resets phase but never absorbs its payload" { 144 test "an unmatched command_end after a completed one resets phase but never absorbs its payload" {
134 var t = Tracker{}; 145 var t = LiveCommand{};
135 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null }); 146 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
136 _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 1 }); 147 _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 1 });
137 try std.testing.expectEqual(@as(?Tracker.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 9, .exit_code = 7 })); 148 try std.testing.expectEqual(@as(?LiveCommand.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 9, .exit_code = 7 }));
138 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase); 149 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
139 // The stray's row and exit code never land: the finished command's 150 // The stray's row and exit code never land: the finished command's
140 // fields are left exactly as the real D set them. 151 // fields are left exactly as the real `command_end` set them.
141 try std.testing.expectEqual(@as(?u8, 1), t.exit_code); 152 try std.testing.expectEqual(@as(?u8, 1), t.exit_code);
142 try std.testing.expectEqual(@as(u32, 0), t.start_row); 153 try std.testing.expectEqual(@as(u32, 0), t.start_row);
143 try std.testing.expectEqual(@as(u32, 2), t.end_row); 154 try std.testing.expectEqual(@as(u32, 2), t.end_row);
src/server/server.zig
Old New
@@ -303,22 +303,24 @@ pub const Session = struct {
303 /// TEXT, so it is dropped as soon as its seq stops being servable. 303 /// TEXT, so it is dropped as soon as its seq stops being servable.
304 pending_clipboard: ?PendingEvent = null, 304 pending_clipboard: ?PendingEvent = null,
305 pending_bell: ?PendingEvent = null, 305 pending_bell: ?PendingEvent = null,
306 /// The session's command state machine (OSC 133). Seq-stamped copies of 306 /// The session's live command (OSC 133 marks). Seq-stamped copies of its
307 /// its transitions are what cmd_state/await_reply/status_reply carry. 307 /// transitions are what cmd_state/await_reply/status_reply carry.
308 cmd: cmdmod.Tracker = .{}, 308 cmd: cmdmod.LiveCommand = .{},
309 /// The last completed command, frozen as it returned, and the one owner 309 /// The last completed command, frozen as it returned, and the one owner
310 /// of the return watermark: "a return happened at or before this seq" and 310 /// of the return watermark: "a return happened at or before this seq" and
311 /// "here is what it was" are one fact, not two fields to keep in step. 311 /// "here is what it was" are one fact, not two fields to keep in step.
312 /// An await asks about a PAST event, and the live tracker cannot describe 312 /// An await asks about a PAST event, which `LiveCommand` by definition
313 /// one — a shell's `D;code` and the next prompt's `A` arrive in a single 313 /// cannot describe: a shell's `command_end` and the next `prompt_start`
314 /// write, so live state loses the verdict a fraction of a pump after it. 314 /// arrive in a single write, so the live view loses the verdict a fraction
315 /// of a pump after it.
315 last_return: ?proto.CmdState = null, 316 last_return: ?proto.CmdState = null,
316 /// `milliTimestamp` of the last byte the pty produced; the settle floor. 317 /// `milliTimestamp` of the last byte the pty produced; the settle floor.
317 /// 0 is a session that has never spoken, which no elapsed silence should 318 /// 0 is a session that has never spoken, which no elapsed silence should
318 /// be read as a finished command. Per-session: this shell's silence. 319 /// be read as a finished command. Per-session: this shell's silence.
319 last_pty_ms: i64 = 0, 320 last_pty_ms: i64 = 0,
320 /// The `monoMs` at which an accepted `end_req` stops being polite. Null 321 /// The `monoMs` at which the SIGTERM an accepted `end_req` sent stops being
321 /// except between that accept and the SIGKILL `reap` sends once it passes. 322 /// waited on and `reap` sends SIGKILL. Null except between that accept and
323 /// the kill, so an accepted end is bounded and no shell can refuse to die.
322 end_by_ms: ?i64 = null, 324 end_by_ms: ?i64 = null,
323 /// The session's name, valid only up to name_len — the rest of the 325 /// The session's name, valid only up to name_len — the rest of the
324 /// buffer is undefined, so read it through name() and nowhere else. 326 /// buffer is undefined, so read it through name() and nowhere else.
src/server/server_agent.zig
Old New
@@ -40,16 +40,21 @@ pub const max_agent_chans = proto.agent_chans_max;
40 40
41 /// `client` and `session` decide who may speak for a channel: the client because 41 /// `client` and `session` decide who may speak for a channel: the client because
42 /// ids are daemon-wide and a guessed one must not reach a stranger's ssh-agent, 42 /// ids are daemon-wide and a guessed one must not reach a stranger's ssh-agent,
43 /// the session because a channel dies with the shell that dialled it. No buffer 43 /// the session because a channel dies with the shell that dialled it even when
44 /// here — the daemon never holds agent bytes. 44 /// its client has attached elsewhere first — which is the case `killSessionChans`
45 /// exists for, and the reason `client` alone is not enough to identify a channel.
46 /// No buffer here: the daemon never holds agent bytes.
45 pub const AgentChan = struct { 47 pub const AgentChan = struct {
46 fd: std.posix.fd_t, 48 fd: std.posix.fd_t,
47 id: u32, 49 id: u32,
48 client: usize, 50 client: usize,
49 session: usize, 51 session: usize,
50 /// The answer clock: from the first request handed to the client until its 52 /// The answer clock: from the first request handed to the client until its
51 /// first reply. Started by the REQUEST, because until ssh asks the client 53 /// first reply. Started by the REQUEST, because until ssh asks, the client
52 /// owes nothing; stopped for good by one reply, which is the proof. 54 /// owes nothing. One reply stops it for good: an `agent_offer` only claims
55 /// the client can reach an agent, and a reply is the proof of that claim,
56 /// which is why no later request restarts the clock. The bound it is
57 /// measured against is `AgentRelay.answer_ms`.
53 answer: union(enum) { unasked, asked: i64, proven } = .unasked, 58 answer: union(enum) { unasked, asked: i64, proven } = .unasked,
54 }; 59 };
55 60
@@ -76,9 +81,9 @@ pub const AgentRelay = struct {
76 /// the table full?") has no answer at all. 81 /// the table full?") has no answer at all.
77 refused_no_offer: u32 = 0, 82 refused_no_offer: u32 = 0,
78 refused_full: u32 = 0, 83 refused_full: u32 = 0,
79 /// Whether the current full-table episode has been reported. One line 84 /// Whether the current full-table episode has been reported. One line per
80 /// per episode, not per dial: ssh retries, and a log that scrolled would 85 /// episode, not per dial: ssh retries on refusal, so logging each dial would
81 /// be its own outage. 86 /// fill the daemon log fast enough to bury the line that explains it.
82 full_said: bool = false, 87 full_said: bool = false,
83 /// The next channel id to hand out. Starts at 1 so an id is never 88 /// The next channel id to hand out. Starts at 1 so an id is never
84 /// confused with an absent one at a glance, and wraps — `nextId` 89 /// confused with an absent one at a glance, and wraps — `nextId`
@@ -87,9 +92,19 @@ pub const AgentRelay = struct {
87 /// How long a client may sit on a channel's FIRST forwarded request before 92 /// How long a client may sit on a channel's FIRST forwarded request before
88 /// the daemon hangs up for it. An `agent_offer` is a declaration, not a 93 /// the daemon hangs up for it. An `agent_offer` is a declaration, not a
89 /// capability: a peer that cannot answer does not degrade forwarding, it 94 /// capability: a peer that cannot answer does not degrade forwarding, it
90 /// WEDGES it — ssh blocks past 8s on a socket that accepts and never 95 /// WEDGES it. Measured: ssh blocks past 8s on a socket that accepts and
91 /// replies. Only the first request is clocked, because a later SIGN may 96 /// never replies, where one that closes falls through to its other methods
92 /// legitimately wait on a human's touch. 97 /// in 2ms (decisions.md).
98 ///
99 /// Only the first request is clocked. One reply proves the peer speaks for
100 /// an agent, and a later SIGN may legitimately wait on a human touching a
101 /// hardware key.
102 ///
103 /// 5 s is ten times the client-side preflight's round-trip bound
104 /// (`mux_main.agent_probe_ms`), so this can never be what separates a slow
105 /// agent from a refusing one — that distinction is the preflight's job, and
106 /// this is only here to catch a peer that never answers at all. A field
107 /// rather than a const so a test does not have to wait it out.
93 answer_ms: i64 = 5000, 108 answer_ms: i64 = 5000,
94 109
95 /// What `statsText` prints and `writeManifestTo` carries. A struct so 110 /// What `statsText` prints and `writeManifestTo` carries. A struct so
@@ -149,9 +164,9 @@ pub const AgentRelay = struct {
149 std.debug.print("mux d: no agent socket for session {s} ({t})\n", .{ name, err }); 164 std.debug.print("mux d: no agent socket for session {s} ({t})\n", .{ name, err });
150 return null; 165 return null;
151 }; 166 };
152 // Backlog 8, not the default 128: the only thing that ever dials 167 // Backlog 8, not the default 128: the only dialler is the ssh clients
153 // this is one session's ssh clients, and a queue deeper than the 168 // of one session's shell, so the queue can only be as deep as the
154 // hands typing into that shell is queueing for nobody. 169 // commands one person has started at once.
155 const listener = addr.listen(.{ .kernel_backlog = 8 }) catch |err| { 170 const listener = addr.listen(.{ .kernel_backlog = 8 }) catch |err| {
156 std.debug.print("mux d: no agent socket for session {s} ({t})\n", .{ name, err }); 171 std.debug.print("mux d: no agent socket for session {s} ({t})\n", .{ name, err });
157 return null; 172 return null;
src/server/server_sessions.zig
Old New
@@ -158,9 +158,11 @@ pub const SessionTable = struct {
158 } 158 }
159 159
160 /// Tear down every session whose shell has exited: its clients told and 160 /// Tear down every session whose shell has exited: its clients told and
161 /// dropped, its slot nulled, its name freed. Answers nothing — a shell's 161 /// dropped, its slot nulled, its name freed. Answers nothing, because a
162 /// exit code is a fact about that shell, and an emptied table is a daemon 162 /// shell's exit code is a fact about that shell and never about the daemon:
163 /// with nothing on it rather than one that is leaving. 163 /// an emptied table is a daemon with nothing on it, not a daemon that is
164 /// leaving. Only `mux d stop` ends one (decisions.md), which is what lets a
165 /// later birth take the default session name back.
164 pub fn reap(self: *SessionTable, srv: *Server) void { 166 pub fn reap(self: *SessionTable, srv: *Server) void {
165 for (&self.table, 0..) |*slot, si| { 167 for (&self.table, 0..) |*slot, si| {
166 const s = if (slot.*) |*sp| sp else continue; 168 const s = if (slot.*) |*sp| sp else continue;
src/server/shellint.zig
Old New
@@ -18,8 +18,11 @@ const precmd_fn =
18 ; 18 ;
19 19
20 /// Pointing ZDOTDIR at the shim silently costs the user their ~/.zshenv: zsh 20 /// Pointing ZDOTDIR at the shim silently costs the user their ~/.zshenv: zsh
21 /// looks for it under $ZDOTDIR and the shim directory has none, so a config 21 /// looks for it under $ZDOTDIR and the shim directory has none, so a config kept
22 /// kept there stops being read. The .zshrc is handed back below. 22 /// there — PATH edits, and anything else zsh reads for non-interactive shells —
23 /// stops being read for the session. The .zshrc is handed back below, which is
24 /// the common case; the fix for the rest is a .zshenv shim that restores
25 /// ZDOTDIR, the way ghostty's does.
23 pub const zsh_zshrc = 26 pub const zsh_zshrc =
24 \\# mux shell integration (zsh): OSC 133 marks. Sourced via a ZDOTDIR 27 \\# mux shell integration (zsh): OSC 133 marks. Sourced via a ZDOTDIR
25 \\# shim; restores the user's ZDOTDIR (or unsets it) then runs their rc. 28 \\# shim; restores the user's ZDOTDIR (or unsets it) then runs their rc.
@@ -40,8 +43,10 @@ pub const zsh_zshrc =
40 ; 43 ;
41 44
42 /// The DEBUG trap here silently REPLACES any the session already had — bash 45 /// The DEBUG trap here silently REPLACES any the session already had — bash
43 /// allows exactly one, and bash-preexec and atuin each install one. mux wins 46 /// allows exactly one, and bash-preexec, atuin and iTerm2's integration each
44 /// and the other goes quiet, with no diagnostic anywhere. 47 /// install one. mux wins and the other goes quiet, with no diagnostic anywhere.
48 /// Coexisting means detecting bash-preexec and registering with it instead;
49 /// this version does not.
45 pub const bash_init = 50 pub const bash_init =
46 \\# mux shell integration (bash): OSC 133 marks. Passed via --init-file; 51 \\# mux shell integration (bash): OSC 133 marks. Passed via --init-file;
47 \\# sources the user's normal rc first so their config still runs. 52 \\# sources the user's normal rc first so their config still runs.
@@ -130,9 +135,12 @@ pub const Injection = struct {
130 /// for env-only injections (zsh, fish) and for .other. 135 /// for env-only injections (zsh, fish) and for .other.
131 extra_argv: []const [:0]const u8, 136 extra_argv: []const [:0]const u8,
132 env: []const EnvPair, 137 env: []const EnvPair,
133 /// The shim directory, set EXACTLY when this call created one. The caller 138 /// The shim directory, set EXACTLY when this call created one — an unknown
134 /// deletes it at teardown, so a path reported but never created is a cleanup 139 /// shell writes nothing and reports null. The caller deletes it at teardown,
135 /// claiming work it did not do, and one created but not reported is litter. 140 /// so a path reported but never created is a cleanup claiming work it did
141 /// not do, and one created but not reported is litter in the runtime
142 /// directory. Reported from the one place that knows, rather than re-derived
143 /// by the caller from a second `detect` of the same shell.
136 dir: ?[]const u8 = null, 144 dir: ?[]const u8 = null,
137 }; 145 };
138 146
@@ -148,12 +156,18 @@ pub fn install(
148 parent_dir: []const u8, 156 parent_dir: []const u8,
149 shell_path: []const u8, 157 shell_path: []const u8,
150 ) Injection { 158 ) Injection {
151 // The pid keeps two daemons in one runtime directory legible; the random 159 // The pid keeps two daemons sharing one runtime directory legible in a
152 // half is not decoration. `parent_dir` is a shared `/tmp` without 160 // listing; the random half is not decoration. `parent_dir` is the socket's
153 // `$XDG_RUNTIME_DIR` and a pid is guessable, so another user could 161 // directory, a shared `/tmp` when `$XDG_RUNTIME_DIR` is unset, and a pid is
154 // pre-create the name as a symlink and the shim files the shell SOURCES 162 // guessable: another user could pre-create the exact name as a symlink to a
155 // would land through it. It also ends the collision with a dead 163 // directory of theirs, and the shim files the session shell then SOURCES
156 // predecessor's leftover name. 164 // would land through it.
165 //
166 // What closes that is `prepare` creating the directory EXCLUSIVELY — an
167 // existing name, symlink or not, fails the call. The random half only makes
168 // the attempt expensive to aim, and is not the defence on its own. It also
169 // ends the mundane collision, where a predecessor SIGKILLed before teardown
170 // left its name behind for a later daemon drawing the same pid.
157 const dir = std.fmt.allocPrint( 171 const dir = std.fmt.allocPrint(
158 arena, 172 arena,
159 "{s}/mux-shellint-{d}-{x:0>12}", 173 "{s}/mux-shellint-{d}-{x:0>12}",
@@ -321,8 +335,8 @@ test "prepare zsh writes the shim and sets ZDOTDIR" {
321 const rc_path = try std.fs.path.join(arena.allocator(), &.{ inj.env[0].value, ".zshrc" }); 335 const rc_path = try std.fs.path.join(arena.allocator(), &.{ inj.env[0].value, ".zshrc" });
322 const rc = try std.fs.cwd().readFileAlloc(std.testing.allocator, rc_path, 8192); 336 const rc = try std.fs.cwd().readFileAlloc(std.testing.allocator, rc_path, 8192);
323 defer std.testing.allocator.free(rc); 337 defer std.testing.allocator.free(rc);
324 // The two halves that make it work: the D mark carries the exit code, 338 // The two halves that make it work: the command-end mark carries the exit
325 // and the hooks are actually registered. 339 // code, and the hooks are registered.
326 try std.testing.expect(std.mem.indexOf(u8, rc, "133;D;%s") != null); 340 try std.testing.expect(std.mem.indexOf(u8, rc, "133;D;%s") != null);
327 try std.testing.expect(std.mem.indexOf(u8, rc, "add-zsh-hook") != null); 341 try std.testing.expect(std.mem.indexOf(u8, rc, "add-zsh-hook") != null);
328 // ...and the shim hands control back to the user's own rc, which is the 342 // ...and the shim hands control back to the user's own rc, which is the