ca93662f
Restore the claims the prose ratchets cut from src/server
a73x 2026-08-31 11:28
Commit message
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 |