a73x

23eb7062

refactor: simplify-pass — one owner for fallback state, spawn prep, precmd, frame delimiting

a73x   2026-08-13 21:17

Commit message
refactor: simplify-pass — one owner for fallback state, spawn prep, precmd, frame delimiting

Quality only: no behavior change and no wire-format change. Verified by
byte-comparing a live `status`, `run` and `await` (plus the verb error
strings and exit codes) against binaries built from the parent commit —
identical — and by dumping the three shell-integration scripts before and
after the splice, which hash the same.

server.zig
  - checkAwaits reads the foreground pgid ONCE per pump instead of once
    per waiting client. It is a property of the pty, so every client read
    the same number back; null now covers both "marks hold the floor, so
    nobody asked" and "the ioctl failed", the two cases that already
    skipped it. saw_busy stays per-client.
  - The three fallback arms each hand-patched the CmdState cmdState()
    built. One fallbackState() owner holds every override, and the
    deliberate differences between the arms are now visible as arguments
    rather than looking like three copies drifting.
  - init's spawn preparation moves to prepareSpawn(), so init reads as
    engine, pty, listener again.
  - Server no longer re-derives what shellint already knew: the injection
    reports the directory it created and the daemon deletes exactly that.
  - pushInbound delimits through proto.delimitFrame; it dropped in without
    disturbing the interleaved drop-logic, so there is no second owner
    left to note.

shellint.zig
  - _mux_precmd was byte-identical in the zsh and bash scripts; it is now
    one const spliced into both. Two copies of the hook that decides
    whether an exit code is knowable is two places to drift, silently, in
    exactly one shell.
  - install() owns the `mux-shellint-<pid>` naming and the degraded-mode
    message, and Injection.dir reports a created directory. Teardown
    removes only what was created, same as before, but the fact now has
    one owner instead of being re-derived from a second detect().
  - The private-directory policy moves to xdg.makePrivateDir, shared with
    makePrivateParent; the fchmod-needs-iterate reason lives in one place.

muxa.zig
  - await and run were the same pipeline written twice with the middle
    diverging; awaitVerb() is that pipeline, and a non-null cmdline IS the
    difference. Error strings keep their verb prefix via failAs().
  - printStatus and printAwaitReply hand-rolled the same five CmdState
    fields; writeCmdFields() writes them and each verb keeps its own
    envelope. printStatus's test now pins the whole object byte for byte
    rather than three substrings.
  - awaitFrame drops its alloc parameter (there is one allocator in this
    process, and asking made the pairing look like a choice); Conn.close
    captures the payload on both arms.

protocol.zig
  - delimitFrame(): one pure owner for the header walk, the max_payload
    gate and the consumed count, called from both ends of the wire.
  - CmdState.seq documents the two different numbers that travel in it —
    a return watermark in status/marks replies, the delta-tracker seq in
    fallback await replies — and the rule that follows: take since_seq
    from a status or marks reply, never from a fallback one. Whether the
    fallback arms should stamp it at all is recorded as an open design
    note; changing it would move wire bytes.

engine.zig, pty.zig, webhub.zig
  - Flatten the nested exit_code if-expression.
  - Spell out that a later env pair beats an earlier one, which is what
    lets extra_env override the injection's own; the rule was pinned only
    by test usage.
  - Record why webhub's label escape deliberately is NOT muxa's: both are
    valid JSON, the bytes differ, and each is pinned by its own test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -196,14 +196,17 @@ pub fn build(b: *std.Build) void {
196 cmd_mod.addImport("protocol", protocol_mod); 196 cmd_mod.addImport("protocol", protocol_mod);
197 197
198 // Shell integration: the OSC 133 mark scripts and what a spawn must add 198 // Shell integration: the OSC 133 mark scripts and what a spawn must add
199 // to hand them to a shell. A leaf on purpose — it writes files and reads 199 // to hand them to a shell. Near-leaf on purpose — it writes files and
200 // the environment, and knows nothing of ptys, servers or the protocol, 200 // reads the environment, and knows nothing of ptys, servers or the
201 // so its tests need no daemon and no socket. 201 // protocol, so its tests need no daemon and no socket. The one import
202 // is xdg, for the private-directory policy the shim directory shares
203 // with the key file's parent; xdg is itself a leaf, so no cycle.
202 const shellint_mod = b.createModule(.{ 204 const shellint_mod = b.createModule(.{
203 .root_source_file = b.path("src/shellint.zig"), 205 .root_source_file = b.path("src/shellint.zig"),
204 .target = target, 206 .target = target,
205 .optimize = optimize, 207 .optimize = optimize,
206 }); 208 });
209 shellint_mod.addImport("xdg", xdg_mod);
207 210
208 // The replay core: snapshot/delta application and the resume 211 // The replay core: snapshot/delta application and the resume
209 // coordinates, shared by the CLI client, the wasm core, and the 212 // coordinates, shared by the CLI client, the wasm core, and the
src/engine.zig
Old New
@@ -46,13 +46,13 @@ pub const MuxHandler = struct {
46 else => return, // L/N/P/B/I: prompt furniture, not boundaries 46 else => return, // L/N/P/B/I: prompt furniture, not boundaries
47 }; 47 };
48 const eng = self.engineOf(); 48 const eng = self.engineOf();
49 const exit_code: ?u8 = if (kind == .command_end) 49 // Only `D` carries a code, and even then only when the shell put one
50 if (value.readOption(.exit_code)) |code| 50 // in the mark; everything else has none to read.
51 @intCast(@as(u32, @bitCast(code)) & 0xff) 51 const raw = if (kind == .command_end) value.readOption(.exit_code) else null;
52 else 52 // Masked to the low byte, which is what waitpid would have reported:
53 null 53 // a shell is free to spell `D;300`, and truncating is the same answer
54 else 54 // the kernel gives rather than a refusal to parse.
55 null; 55 const exit_code: ?u8 = if (raw) |code| @intCast(@as(u32, @bitCast(code)) & 0xff) else null;
56 // Load-bearing catch: under OOM we drop the mark rather than fail 56 // Load-bearing catch: under OOM we drop the mark rather than fail
57 // the feed. A dropped mark costs precision, not correctness — 57 // the feed. A dropped mark costs precision, not correctness —
58 // await falls back to pgid/settle when no boundary arrives. 58 // await falls back to pgid/settle when no boundary arrives.
src/muxa.zig
Old New
@@ -324,7 +324,7 @@ const Conn = struct {
324 fn close(self: *Conn) void { 324 fn close(self: *Conn) void {
325 switch (self.link) { 325 switch (self.link) {
326 .fd => |fd| std.posix.close(fd), 326 .fd => |fd| std.posix.close(fd),
327 .quic => self.link.quic.cl.deinit(), 327 .quic => |q| q.cl.deinit(),
328 } 328 }
329 } 329 }
330 330
@@ -414,15 +414,15 @@ const Conn = struct {
414 /// the reply we are waiting for is never coming, and the reason is an 414 /// the reply we are waiting for is never coming, and the reason is an
415 /// answer — the session ran its last command — not a transport 415 /// answer — the session ran its last command — not a transport
416 /// failure. Callers get error.SessionExited plus `session_exit`. 416 /// failure. Callers get error.SessionExited plus `session_exit`.
417 fn awaitFrame( 417 ///
418 self: *Conn, 418 /// The returned frame is allocated from this Conn's own allocator, so
419 alloc: std.mem.Allocator, 419 /// `frame.deinit` takes that one. Every caller was already passing it —
420 want: proto.MsgType, 420 /// there is one allocator in this process — and asking for it made the
421 deadline_ms: i64, 421 /// pairing look like a choice.
422 ) !proto.Frame { 422 fn awaitFrame(self: *Conn, want: proto.MsgType, deadline_ms: i64) !proto.Frame {
423 return switch (self.link) { 423 return switch (self.link) {
424 .fd => self.awaitFrameFd(alloc, want, deadline_ms), 424 .fd => self.awaitFrameFd(self.alloc, want, deadline_ms),
425 .quic => self.awaitFrameQuic(alloc, want, deadline_ms), 425 .quic => self.awaitFrameQuic(self.alloc, want, deadline_ms),
426 }; 426 };
427 } 427 }
428 428
@@ -534,32 +534,23 @@ const Conn = struct {
534 } 534 }
535 }; 535 };
536 536
537 /// One frame delimited out of `buf`, and how many bytes of it that took. 537 /// One OWNED frame delimited out of `buf`, and how many bytes of it that
538 /// Null while the tail is still partial — a header that has not all 538 /// took. The arithmetic is `proto.delimitFrame`'s — the same walk the
539 /// arrived, or a payload still in flight — which is the ordinary state of 539 /// daemon does over the same wire from the other end — and what this adds
540 /// a byte stream and never an error. 540 /// is the copy: the caller consumes the bytes out of the client's inbound
541 /// 541 /// buffer immediately, so a payload still pointing into it would be a
542 /// Takes a plain slice rather than the client, so the delimiting can be 542 /// slice into memory about to be shifted.
543 /// exercised against a canned buffer with no connection anywhere. The
544 /// shape is the daemon's `pushInbound` walk, which delimits the same
545 /// frames off the same wire from the other end.
546 fn frameFrom( 543 fn frameFrom(
547 alloc: std.mem.Allocator, 544 alloc: std.mem.Allocator,
548 buf: []const u8, 545 buf: []const u8,
549 ) !?struct { frame: proto.Frame, consumed: usize } { 546 ) !?struct { frame: proto.Frame, consumed: usize } {
550 if (buf.len < proto.frame_header_len) return null; 547 const d = try proto.delimitFrame(buf) orelse return null;
551 const len = std.mem.readInt(u32, buf[1..5], .little); 548 const payload = try alloc.alloc(u8, d.payload.len);
552 // A length no frame can legitimately carry: the stream is not what we
553 // think it is, and reading on would allocate against a number the peer
554 // chose. Same bound and same verdict as the daemon's walk.
555 if (len > proto.max_payload) return error.FrameTooLarge;
556 if (buf.len < proto.frame_header_len + len) return null;
557 const payload = try alloc.alloc(u8, len);
558 errdefer alloc.free(payload); 549 errdefer alloc.free(payload);
559 @memcpy(payload, buf[proto.frame_header_len..][0..len]); 550 @memcpy(payload, d.payload);
560 return .{ 551 return .{
561 .frame = .{ .type = @enumFromInt(buf[0]), .payload = payload }, 552 .frame = .{ .type = d.type, .payload = payload },
562 .consumed = proto.frame_header_len + len, 553 .consumed = d.consumed,
563 }; 554 };
564 } 555 }
565 556
@@ -800,7 +791,7 @@ test "awaitFrame ends a wait on exit_status, keeping the code" {
800 try proto.writeFrame(pipe[1], .exit_status, &[_]u8{5}); 791 try proto.writeFrame(pipe[1], .exit_status, &[_]u8{5});
801 try std.testing.expectError( 792 try std.testing.expectError(
802 error.SessionExited, 793 error.SessionExited,
803 conn.awaitFrame(alloc, .status_reply, std.time.milliTimestamp() + 2000), 794 conn.awaitFrame(.status_reply, std.time.milliTimestamp() + 2000),
804 ); 795 );
805 try std.testing.expectEqual(@as(?u8, 5), conn.session_exit); 796 try std.testing.expectEqual(@as(?u8, 5), conn.session_exit);
806 797
@@ -829,6 +820,17 @@ fn fail(msg: []const u8, detail: []const u8) u8 {
829 return 1; 820 return 1;
830 } 821 }
831 822
823 /// `fail` for a message whose verb prefix is only known at runtime, which
824 /// is every failure in the shared await/run pipeline. Produces exactly the
825 /// `"<verb>: <what>"` the two verbs printed when they were written out
826 /// separately; a message too long to prefix falls back to the unprefixed
827 /// one rather than losing the failure.
828 fn failAs(who: []const u8, msg: []const u8, detail: []const u8) u8 {
829 var buf: [256]u8 = undefined;
830 const joined = std.fmt.bufPrint(&buf, "{s}: {s}", .{ who, msg }) catch msg;
831 return fail(joined, detail);
832 }
833
832 fn writeError(writer: anytype, msg: []const u8, detail: []const u8) !void { 834 fn writeError(writer: anytype, msg: []const u8, detail: []const u8) !void {
833 try writer.writeAll("{\"error\":"); 835 try writer.writeAll("{\"error\":");
834 try jsonEscape(writer, msg); 836 try jsonEscape(writer, msg);
@@ -958,8 +960,14 @@ fn dispatch(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 {
958 .status => verbStatus(alloc, conn, deadline), 960 .status => verbStatus(alloc, conn, deadline),
959 .capture => verbCapture(alloc, conn, o.vt, deadline), 961 .capture => verbCapture(alloc, conn, o.vt, deadline),
960 .send => verbSend(alloc, conn, o.arg, deadline), 962 .send => verbSend(alloc, conn, o.arg, deadline),
961 .run => verbRun(alloc, conn, o, deadline), 963 // The one thing `run` needs that `await` does not, checked here so
962 .@"await" => verbAwait(alloc, conn, o, deadline), 964 // the shared pipeline below can read `cmdline == null` as "this is
965 // an await" rather than as "a run that was spelled wrong".
966 .run => if (o.arg) |cmdline|
967 awaitVerb(alloc, conn, o, deadline, cmdline)
968 else
969 fail("run: needs CMDLINE", ""),
970 .@"await" => awaitVerb(alloc, conn, o, deadline, null),
963 }; 971 };
964 } 972 }
965 973
@@ -1015,7 +1023,7 @@ fn openQuicConn(
1015 1023
1016 fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u8 { 1024 fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u8 {
1017 conn.sendFrame(.status_req, "", deadline) catch |e| return fail("status: send failed", @errorName(e)); 1025 conn.sendFrame(.status_req, "", deadline) catch |e| return fail("status: send failed", @errorName(e));
1018 const frame = conn.awaitFrame(alloc, .status_reply, deadline) catch |e| switch (e) { 1026 const frame = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) {
1019 error.SessionExited => return failSessionEnded(conn.session_exit), 1027 error.SessionExited => return failSessionEnded(conn.session_exit),
1020 else => return fail("status: no reply", @errorName(e)), 1028 else => return fail("status: no reply", @errorName(e)),
1021 }; 1029 };
@@ -1041,21 +1049,34 @@ fn writeExitCode(writer: anytype, code: ?u8) !void {
1041 } 1049 }
1042 } 1050 }
1043 1051
1052 /// The five CmdState fields `status` and `await`/`run` both publish, in the
1053 /// one order both have always used. Written as a bare fragment — no braces,
1054 /// no leading or trailing comma — because the two verbs nest it
1055 /// differently: `status` puts it inside a `"cmd"` object and follows it
1056 /// with the seq, while `await` inlines it at the top level and follows it
1057 /// with the duration. Each verb keeps its own envelope; what they stopped
1058 /// keeping is a second spelling of the fields inside it.
1059 fn writeCmdFields(writer: anytype, st: proto.CmdState) !void {
1060 try writer.writeAll("\"phase\":");
1061 try jsonEscape(writer, @tagName(st.phase));
1062 try writer.writeAll(",\"mechanism\":");
1063 try jsonEscape(writer, @tagName(st.mechanism));
1064 try writer.writeAll(",\"exit_code\":");
1065 try writeExitCode(writer, st.exit_code);
1066 try writer.print(",\"start_row\":{d},\"end_row\":{d}", .{ st.start_row, st.end_row });
1067 }
1068
1044 fn printStatus(writer: anytype, st: proto.StatusReply) !void { 1069 fn printStatus(writer: anytype, st: proto.StatusReply) !void {
1045 try writer.print( 1070 try writer.print(
1046 "{{\"cols\":{d},\"rows\":{d},\"cursor\":{{\"x\":{d},\"y\":{d}}}," ++ 1071 "{{\"cols\":{d},\"rows\":{d},\"cursor\":{{\"x\":{d},\"y\":{d}}}," ++
1047 "\"history_rows\":{d},\"alt_screen\":{},\"icanon\":{},\"echo\":{},\"cmd\":{{\"phase\":", 1072 "\"history_rows\":{d},\"alt_screen\":{},\"icanon\":{},\"echo\":{},\"cmd\":{{",
1048 .{ st.cols, st.rows, st.cursor_x, st.cursor_y, st.history_rows, st.alt_screen, st.mode.icanon, st.mode.echo }, 1073 .{ st.cols, st.rows, st.cursor_x, st.cursor_y, st.history_rows, st.alt_screen, st.mode.icanon, st.mode.echo },
1049 ); 1074 );
1050 try jsonEscape(writer, @tagName(st.cmd.phase)); 1075 try writeCmdFields(writer, st.cmd);
1051 try writer.writeAll(",\"mechanism\":"); 1076 // The watermark, and only `status` carries it: this is the number an
1052 try jsonEscape(writer, @tagName(st.cmd.mechanism)); 1077 // agent feeds back as `since_seq`, which is why `await` does not print
1053 try writer.writeAll(",\"exit_code\":"); 1078 // one (see proto.CmdState.seq).
1054 try writeExitCode(writer, st.cmd.exit_code); 1079 try writer.print(",\"seq\":{d}}}}}\n", .{st.cmd.seq});
1055 try writer.print(
1056 ",\"start_row\":{d},\"end_row\":{d},\"seq\":{d}}}}}\n",
1057 .{ st.cmd.start_row, st.cmd.end_row, st.cmd.seq },
1058 );
1059 } 1080 }
1060 1081
1061 test "printStatus spells a pending exit code as JSON null" { 1082 test "printStatus spells a pending exit code as JSON null" {
@@ -1071,16 +1092,23 @@ test "printStatus spells a pending exit code as JSON null" {
1071 .mode = .{ .icanon = true, .echo = true }, 1092 .mode = .{ .icanon = true, .echo = true },
1072 .cmd = .{ .phase = .running, .mechanism = .marks, .exit_code = null, .start_row = 3, .end_row = 4, .seq = 9 }, 1093 .cmd = .{ .phase = .running, .mechanism = .marks, .exit_code = null, .start_row = 3, .end_row = 4, .seq = 9 },
1073 }); 1094 });
1074 const got = fbs.getWritten(); 1095 // The whole object, byte for byte, not a handful of substrings: this is
1075 try std.testing.expect(std.mem.indexOf(u8, got, "\"exit_code\":null") != null); 1096 // muxa's published contract with an agent's JSON parser, and the fields
1076 try std.testing.expect(std.mem.indexOf(u8, got, "\"phase\":\"running\"") != null); 1097 // it shares with `await` are written by a helper both verbs call — a
1077 try std.testing.expect(std.mem.indexOf(u8, got, "\"cursor\":{\"x\":1,\"y\":2}") != null); 1098 // pin on the parts cannot see a comma or a nesting level move.
1099 try std.testing.expectEqualStrings(
1100 "{\"cols\":80,\"rows\":24,\"cursor\":{\"x\":1,\"y\":2},\"history_rows\":7," ++
1101 "\"alt_screen\":false,\"icanon\":true,\"echo\":true," ++
1102 "\"cmd\":{\"phase\":\"running\",\"mechanism\":\"marks\",\"exit_code\":null," ++
1103 "\"start_row\":3,\"end_row\":4,\"seq\":9}}\n",
1104 fbs.getWritten(),
1105 );
1078 } 1106 }
1079 1107
1080 fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, deadline: i64) !u8 { 1108 fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, deadline: i64) !u8 {
1081 const payload = [_]u8{if (vt) 1 else 0}; 1109 const payload = [_]u8{if (vt) 1 else 0};
1082 conn.sendFrame(.debug_dump, &payload, deadline) catch |e| return fail("capture: send failed", @errorName(e)); 1110 conn.sendFrame(.debug_dump, &payload, deadline) catch |e| return fail("capture: send failed", @errorName(e));
1083 const frame = conn.awaitFrame(alloc, .dump_reply, deadline) catch |e| switch (e) { 1111 const frame = conn.awaitFrame(.dump_reply, deadline) catch |e| switch (e) {
1084 error.SessionExited => return failSessionEnded(conn.session_exit), 1112 error.SessionExited => return failSessionEnded(conn.session_exit),
1085 else => return fail("capture: no reply", @errorName(e)), 1113 else => return fail("capture: no reply", @errorName(e)),
1086 }; 1114 };
@@ -1125,7 +1153,7 @@ fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, deadline: i
1125 // for that flush to succeed. Nothing is done with the reply — its 1153 // for that flush to succeed. Nothing is done with the reply — its
1126 // arrival is the whole content. 1154 // arrival is the whole content.
1127 conn.sendFrame(.status_req, "", deadline) catch |e| return fail("send: ack request failed", @errorName(e)); 1155 conn.sendFrame(.status_req, "", deadline) catch |e| return fail("send: ack request failed", @errorName(e));
1128 const ack = conn.awaitFrame(alloc, .status_reply, deadline) catch |e| switch (e) { 1156 const ack = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) {
1129 // The bytes we sent ended the session (`exit\n`). Reported as the 1157 // The bytes we sent ended the session (`exit\n`). Reported as the
1130 // session's death rather than as "sent", because this verb's answer 1158 // session's death rather than as "sent", because this verb's answer
1131 // is about the send and there is no longer a session to have sent 1159 // is about the send and there is no longer a session to have sent
@@ -1182,7 +1210,7 @@ fn doAwait(
1182 .settle_ms = o.settle_ms, 1210 .settle_ms = o.settle_ms,
1183 .timeout_ms = o.timeout_ms, 1211 .timeout_ms = o.timeout_ms,
1184 }), deadline); 1212 }), deadline);
1185 const frame = try conn.awaitFrame(alloc, .await_reply, deadline); 1213 const frame = try conn.awaitFrame(.await_reply, deadline);
1186 defer frame.deinit(alloc); 1214 defer frame.deinit(alloc);
1187 return try proto.decodeAwaitReply(frame.payload); 1215 return try proto.decodeAwaitReply(frame.payload);
1188 } 1216 }
@@ -1291,7 +1319,7 @@ fn waitFailDetail(buf: []u8, conn: *const Conn, e: anyerror) []const u8 {
1291 /// newer than this may answer me". 1319 /// newer than this may answer me".
1292 fn currentSeq(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u64 { 1320 fn currentSeq(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u64 {
1293 try conn.sendFrame(.status_req, "", deadline); 1321 try conn.sendFrame(.status_req, "", deadline);
1294 const frame = try conn.awaitFrame(alloc, .status_reply, deadline); 1322 const frame = try conn.awaitFrame(.status_reply, deadline);
1295 defer frame.deinit(alloc); 1323 defer frame.deinit(alloc);
1296 const s = try proto.decodeStatusReply(frame.payload); 1324 const s = try proto.decodeStatusReply(frame.payload);
1297 return s.cmd.seq; 1325 return s.cmd.seq;
@@ -1368,7 +1396,7 @@ fn fetchSpan(
1368 if (end_row <= start_row) return null; 1396 if (end_row <= start_row) return null;
1369 const count: u16 = @intCast(@min(end_row - start_row, std.math.maxInt(u16))); 1397 const count: u16 = @intCast(@min(end_row - start_row, std.math.maxInt(u16)));
1370 try conn.sendFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start_row, count), deadline); 1398 try conn.sendFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start_row, count), deadline);
1371 const frame = try conn.awaitFrame(alloc, .scrollback_chunk, deadline); 1399 const frame = try conn.awaitFrame(.scrollback_chunk, deadline);
1372 defer frame.deinit(alloc); 1400 defer frame.deinit(alloc);
1373 // The chunk leads with the request it answers; the rows follow. 1401 // The chunk leads with the request it answers; the rows follow.
1374 if (frame.payload.len <= 6) return null; 1402 if (frame.payload.len <= 6) return null;
@@ -1387,16 +1415,9 @@ fn printAwaitReply(
1387 ) !void { 1415 ) !void {
1388 try writer.writeAll("{\"reason\":"); 1416 try writer.writeAll("{\"reason\":");
1389 try jsonEscape(writer, @tagName(r.reason)); 1417 try jsonEscape(writer, @tagName(r.reason));
1390 try writer.writeAll(",\"phase\":"); 1418 try writer.writeAll(",");
1391 try jsonEscape(writer, @tagName(r.state.phase)); 1419 try writeCmdFields(writer, r.state);
1392 try writer.writeAll(",\"mechanism\":"); 1420 try writer.print(",\"duration_ms\":{d}", .{duration_ms});
1393 try jsonEscape(writer, @tagName(r.state.mechanism));
1394 try writer.writeAll(",\"exit_code\":");
1395 try writeExitCode(writer, r.state.exit_code);
1396 try writer.print(
1397 ",\"start_row\":{d},\"end_row\":{d},\"duration_ms\":{d}",
1398 .{ r.state.start_row, r.state.end_row, duration_ms },
1399 );
1400 if (output) |text| { 1421 if (output) |text| {
1401 try writer.writeAll(",\"output\":"); 1422 try writer.writeAll(",\"output\":");
1402 try jsonEscape(writer, text); 1423 try jsonEscape(writer, text);
@@ -1468,28 +1489,26 @@ fn reportSessionEnded(alloc: std.mem.Allocator, code: ?u8, duration_ms: i64) !u8
1468 return 0; 1489 return 0;
1469 } 1490 }
1470 1491
1471 fn verbAwait(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 { 1492 /// `await` and `run` are one pipeline: attach claiming no grid, read the
1472 const started = std.time.milliTimestamp(); 1493 /// watermark, wait for the session to come to rest, report. `run` is that
1473 attachZero(conn, deadline) catch |e| return fail("await: attach failed", @errorName(e)); 1494 /// pipeline with a command line put in — the line is sent between the
1474 const since = currentSeq(alloc, conn, deadline) catch |e| switch (e) { 1495 /// watermark and the wait, and the marks span is fetched at the end — so
1475 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), 1496 /// `cmdline` being non-null IS the difference between the two verbs, and
1476 else => return fail("await: status failed", @errorName(e)), 1497 /// they are written once rather than twice with the middle diverging.
1477 }; 1498 fn awaitVerb(
1478 const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) { 1499 alloc: std.mem.Allocator,
1479 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), 1500 conn: *Conn,
1480 else => { 1501 o: Opts,
1481 var detail: [128]u8 = undefined; 1502 deadline: i64,
1482 return fail("await: no reply", waitFailDetail(&detail, conn, e)); 1503 cmdline: ?[]const u8,
1483 }, 1504 ) !u8 {
1484 }; 1505 // Every error string this function can print names the verb the user
1485 return reportAwait(alloc, r, null, elapsed(started)); 1506 // typed, because "attach failed" from the wrong verb sends an agent
1486 } 1507 // looking in the wrong place.
1487 1508 const who = if (cmdline == null) "await" else "run";
1488 fn verbRun(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 {
1489 const cmdline = o.arg orelse return fail("run: needs CMDLINE", "");
1490 const started = std.time.milliTimestamp(); 1509 const started = std.time.milliTimestamp();
1491 1510
1492 attachZero(conn, deadline) catch |e| return fail("run: attach failed", @errorName(e)); 1511 attachZero(conn, deadline) catch |e| return failAs(who, "attach failed", @errorName(e));
1493 1512
1494 // BEFORE the input, not after: the watermark has to be the one this 1513 // BEFORE the input, not after: the watermark has to be the one this
1495 // command must beat. Read afterwards, a command fast enough to return 1514 // command must beat. Read afterwards, a command fast enough to return
@@ -1498,33 +1517,37 @@ fn verbRun(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 {
1498 // had happened. 1517 // had happened.
1499 const since = currentSeq(alloc, conn, deadline) catch |e| switch (e) { 1518 const since = currentSeq(alloc, conn, deadline) catch |e| switch (e) {
1500 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), 1519 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)),
1501 else => return fail("run: status failed", @errorName(e)), 1520 else => return failAs(who, "status failed", @errorName(e)),
1502 }; 1521 };
1503 1522
1504 // The cmdline goes to the pty verbatim — escapes are `send`'s business 1523 if (cmdline) |cmd| {
1505 // — plus the newline that submits it. No ack round-trip is needed the 1524 // The cmdline goes to the pty verbatim — escapes are `send`'s
1506 // way `send` needs one: the await_req that follows is itself the read 1525 // business — plus the newline that submits it. No ack round-trip is
1507 // that proves the daemon got past this frame, and this process stays 1526 // needed the way `send` needs one: the await_req that follows is
1508 // connected until the reply lands. 1527 // itself the read that proves the daemon got past this frame, and
1509 const line = std.fmt.allocPrint(alloc, "{s}\n", .{cmdline}) catch |e| 1528 // this process stays connected until the reply lands.
1510 return fail("run: cannot build the command line", @errorName(e)); 1529 const line = std.fmt.allocPrint(alloc, "{s}\n", .{cmd}) catch |e|
1511 defer alloc.free(line); 1530 return failAs(who, "cannot build the command line", @errorName(e));
1512 conn.sendFrame(.input, line, deadline) catch |e| return fail("run: input failed", @errorName(e)); 1531 defer alloc.free(line);
1532 conn.sendFrame(.input, line, deadline) catch |e|
1533 return failAs(who, "input failed", @errorName(e));
1534 }
1513 1535
1514 const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) { 1536 const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) {
1515 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), 1537 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)),
1516 else => { 1538 else => {
1517 var detail: [128]u8 = undefined; 1539 var detail: [128]u8 = undefined;
1518 return fail("run: no reply", waitFailDetail(&detail, conn, e)); 1540 return failAs(who, "no reply", waitFailDetail(&detail, conn, e));
1519 }, 1541 },
1520 }; 1542 };
1521 1543
1522 // Only the marks regime knows where the command's rows are; pgid and 1544 // Only the marks regime knows where the command's rows are; pgid and
1523 // settle answer WHEN, never WHERE, and a span from them would be a 1545 // settle answer WHEN, never WHERE, and a span from them would be a
1524 // guess dressed as a transcript. 1546 // guess dressed as a transcript. `await` never fetches one at all: it
1547 // did not start the command, so the span it would name is not its own.
1525 var output: ?[]u8 = null; 1548 var output: ?[]u8 = null;
1526 defer if (output) |text| alloc.free(text); 1549 defer if (output) |text| alloc.free(text);
1527 if (r.state.mechanism == .marks and r.reason == .returned) { 1550 if (cmdline != null and r.state.mechanism == .marks and r.reason == .returned) {
1528 output = fetchSpan( 1551 output = fetchSpan(
1529 alloc, 1552 alloc,
1530 conn, 1553 conn,
src/protocol.zig
Old New
@@ -56,6 +56,44 @@ pub const Frame = struct {
56 } 56 }
57 }; 57 };
58 58
59 /// One frame's boundaries inside a buffer somebody else filled. `payload`
60 /// BORROWS from that buffer and is valid only until it is written to or
61 /// shifted, which is why this type is separate from `Frame`: the callers
62 /// that need to keep a payload copy it out themselves, and the ones that
63 /// only need to read it never allocate at all.
64 pub const Delimited = struct {
65 type: MsgType,
66 payload: []const u8,
67 /// Header plus payload — what the caller must drop off the front of
68 /// its buffer before looking for the next frame.
69 consumed: usize,
70 };
71
72 /// Delimit the frame at the front of `buf`, without copying.
73 ///
74 /// Null means the tail is still partial — a header that has not all
75 /// arrived, or a payload still in flight — which is the ordinary state of
76 /// a byte stream and never an error. `error.FrameTooLarge` means a length
77 /// no frame can legitimately carry: the stream is not what we think it is,
78 /// and reading on would size an allocation from a number the peer chose.
79 ///
80 /// Pure, and takes a plain slice rather than any connection, so both ends
81 /// of the wire delimit with the same arithmetic and it can be exercised
82 /// against a canned buffer. The type byte is read through a non-exhaustive
83 /// enum on purpose: an unknown message type is the peer's business to have
84 /// sent and the caller's to ignore, not a reason to refuse the stream.
85 pub fn delimitFrame(buf: []const u8) !?Delimited {
86 if (buf.len < frame_header_len) return null;
87 const len = std.mem.readInt(u32, buf[1..5], .little);
88 if (len > max_payload) return error.FrameTooLarge;
89 if (buf.len < frame_header_len + len) return null;
90 return .{
91 .type = @enumFromInt(buf[0]),
92 .payload = buf[frame_header_len..][0..len],
93 .consumed = frame_header_len + len,
94 };
95 }
96
59 pub fn writeFrame(fd: std.posix.fd_t, t: MsgType, payload: []const u8) !void { 97 pub fn writeFrame(fd: std.posix.fd_t, t: MsgType, payload: []const u8) !void {
60 var hdr: [5]u8 = undefined; 98 var hdr: [5]u8 = undefined;
61 hdr[0] = @intFromEnum(t); 99 hdr[0] = @intFromEnum(t);
@@ -176,14 +214,35 @@ pub const CmdPhase = enum(u8) { at_prompt = 0, running = 1, returned = 2 };
176 /// One snapshot of the session's command state machine. Rows are absolute 214 /// One snapshot of the session's command state machine. Rows are absolute
177 /// screen-space rows (0 = oldest retained history row) — meaningless while 215 /// screen-space rows (0 = oldest retained history row) — meaningless while
178 /// the alt screen is active, and shifted once the scrollback ring prunes, 216 /// the alt screen is active, and shifted once the scrollback ring prunes,
179 /// so spans should be fetched promptly. `seq` is the delta-tracker seq 217 /// so spans should be fetched promptly.
180 /// stamped after the post-feed update (await ordering, nothing else).
181 pub const CmdState = struct { 218 pub const CmdState = struct {
182 phase: CmdPhase, 219 phase: CmdPhase,
183 mechanism: Mechanism, 220 mechanism: Mechanism,
184 exit_code: ?u8, 221 exit_code: ?u8,
185 start_row: u32, 222 start_row: u32,
186 end_row: u32, 223 end_row: u32,
224 /// Two different numbers travel in this field, and which one it is
225 /// depends on the frame carrying it:
226 ///
227 /// * In `status_reply` and in the `cmd_state` pushes the marks stream
228 /// produces, it is the RETURN WATERMARK — the seq stamped when a
229 /// command last returned, 0 if none has this session. This is the
230 /// series `AwaitReq.since_seq` is compared against.
231 /// * In an `await_reply` resolved by anything other than marks (the
232 /// pgid, settle and timeout fallbacks), it is instead the delta
233 /// tracker's CURRENT seq — a grid-content ordering, so the answer
234 /// can be placed against the deltas the client holds.
235 ///
236 /// So: an agent must take its next `since_seq` from a status or marks
237 /// reply, never from a fallback one. Feeding a tracker seq back as a
238 /// watermark compares across two series, and a client that did it would
239 /// wait out its next await for a return that had already happened.
240 ///
241 /// OPEN DESIGN NOTE, recorded rather than acted on: it is not settled
242 /// that the fallback arms should stamp anything into this field, since
243 /// they have no return to watermark. Making them send the watermark
244 /// instead would collapse the two meanings into one — and would change
245 /// the bytes on the wire, so it is a protocol decision, not a cleanup.
187 seq: u64, 246 seq: u64,
188 }; 247 };
189 248
src/pty.zig
Old New
@@ -72,8 +72,17 @@ pub const Pty = struct {
72 _ = c.setenv("TERM", "xterm-256color", 1); 72 _ = c.setenv("TERM", "xterm-256color", 1);
73 // After TERM so a caller could override it, and before the 73 // After TERM so a caller could override it, and before the
74 // signal work so the environment is settled whatever follows. 74 // signal work so the environment is settled whatever follows.
75 // Overwrite (1): the daemon's own value for a name it was 75 //
76 // handed is not the one it means the child to see. 76 // Overwrite (1), and that is a contract rather than a detail:
77 // the daemon's own value for a name it was handed is not the
78 // one it means the child to see, and — because this is a loop
79 // over an ordered slice — a LATER pair beats an earlier one for
80 // the same key. That is what lets Server.Options.extra_env
81 // override a variable the shell-integration injection set,
82 // which is exactly how the integration tests point HOME at a
83 // temp directory. The rule was pinned only by that usage; it is
84 // spelled out here so a reorder of the slice cannot quietly
85 // invert it.
77 for (opts.env) |kv| _ = c.setenv(kv.key.ptr, kv.value.ptr, 1); 86 for (opts.env) |kv| _ = c.setenv(kv.key.ptr, kv.value.ptr, 1);
78 87
79 // Ctrl-C must work in the session, and without this it does not. 88 // Ctrl-C must work in the session, and without this it does not.
src/server.zig
Old New
@@ -360,74 +360,13 @@ pub const Server = struct {
360 // execs, and a failure here is still cheap — no process yet. 360 // execs, and a failure here is still cheap — no process yet.
361 var shellint_arena = std.heap.ArenaAllocator.init(alloc); 361 var shellint_arena = std.heap.ArenaAllocator.init(alloc);
362 errdefer shellint_arena.deinit(); 362 errdefer shellint_arena.deinit();
363 var shellint_dir: ?[]const u8 = null; 363 const plan = try prepareSpawn(shellint_arena.allocator(), opts);
364 var injection: shellint.Injection = .{ .extra_argv = &.{}, .env = &.{} };
365 if (opts.shell_integration) {
366 const a = shellint_arena.allocator();
367 // Beside the socket: that directory is already private, already
368 // runtime-appropriate and already per-user, which is three
369 // properties the shims need and none of them are ours to
370 // re-derive. The pid keeps two daemons sharing one socket
371 // directory out of each other's shims.
372 const parent = std.fs.path.dirname(opts.sock_path) orelse ".";
373 const dir = try std.fmt.allocPrint(
374 a,
375 "{s}/mux-shellint-{d}",
376 .{ parent, std.os.linux.getpid() },
377 );
378 // Degraded, never fatal. A session without marks is a working
379 // session — it runs on the pgid and settle fallbacks, which is
380 // what every unknown shell does — so refusing to start the
381 // daemon over an optional enhancement would invert the whole
382 // module's premise. Said out loud, because a silent fallback
383 // here would look exactly like a shell that ignores its rc.
384 if (shellint.prepare(a, dir, opts.shell)) |inj| {
385 injection = inj;
386 // Only a shell we actually wrote scripts for leaves a
387 // directory behind. Recording the path unconditionally
388 // would make teardown delete-tree a path nothing ever
389 // created — exactly the kind of "the cleanup claims work it
390 // did not do" this project has been burned by.
391 if (shellint.detect(opts.shell) != .other) shellint_dir = dir;
392 } else |err| {
393 std.debug.print(
394 "muxd: shell integration unavailable ({s}: {t}); " ++
395 "the session runs without command marks\n",
396 .{ dir, err },
397 );
398 }
399 }
400 // shellint speaks its own EnvPair so it can stay a leaf, and so can
401 // pty; the daemon is the one place that knows about both, so the
402 // mapping lives here. The arena outlives the spawn, as spawnArgv
403 // requires of anything it reads in the child.
404 const env = try shellint_arena.allocator().alloc(
405 Pty.EnvPair,
406 injection.env.len + opts.extra_env.len,
407 );
408 for (injection.env, env[0..injection.env.len]) |src, *dst| {
409 dst.* = .{ .key = src.key, .value = src.value };
410 }
411 // Last, so setenv's overwrite makes the caller's spelling the one
412 // the child sees.
413 @memcpy(env[injection.env.len..], opts.extra_env);
414 // argv is the shell plus whatever the injection adds, null-terminated
415 // for execve. With no extra argv and no env this is byte-identical to
416 // the old `Pty.spawn` call, which is what keeps a /bin/sh session
417 // exactly the session it was before shell integration existed.
418 const argv = try shellint_arena.allocator().allocSentinel(
419 ?[*:0]const u8,
420 1 + injection.extra_argv.len,
421 null,
422 );
423 argv[0] = opts.shell.ptr;
424 for (injection.extra_argv, argv[1..]) |src, *dst| dst.* = src.ptr;
425 364
426 var pty = try Pty.spawnArgv(.{ 365 var pty = try Pty.spawnArgv(.{
427 .cols = opts.cols, 366 .cols = opts.cols,
428 .rows = opts.rows, 367 .rows = opts.rows,
429 .argv = argv.ptr, 368 .argv = plan.argv,
430 .env = env, 369 .env = plan.env,
431 }); 370 });
432 errdefer pty.deinit(); 371 errdefer pty.deinit();
433 372
@@ -449,10 +388,60 @@ pub const Server = struct {
449 .path_id = path_id, 388 .path_id = path_id,
450 .epoch = epoch, 389 .epoch = epoch,
451 .shellint_arena = shellint_arena, 390 .shellint_arena = shellint_arena,
452 .shellint_dir = shellint_dir, 391 .shellint_dir = plan.shellint_dir,
453 }; 392 };
454 } 393 }
455 394
395 /// What `Pty.spawnArgv` has to be handed, once shell integration has
396 /// had its say. Every slice points into the arena `prepareSpawn` was
397 /// given, which must outlive the spawn — spawnArgv reads all of it in
398 /// the child, after the fork.
399 const SpawnPlan = struct {
400 argv: [*:null]const ?[*:0]const u8,
401 env: []const Pty.EnvPair,
402 /// Straight from the injection: the shim directory to delete at
403 /// teardown, or null when nothing was written.
404 shellint_dir: ?[]const u8,
405 };
406
407 /// Turn the session options into that plan. Split out of `init` because
408 /// it is the one part of starting a daemon that is neither the engine,
409 /// the pty nor the listener, and inlining it buried those three.
410 fn prepareSpawn(a: std.mem.Allocator, opts: Options) !SpawnPlan {
411 // Beside the socket: that directory is already private, already
412 // runtime-appropriate and already per-user, which is three
413 // properties the shims need and none of them are ours to re-derive.
414 // What the directory under it is CALLED, whether one was created,
415 // and what to say when the attempt fails are all shellint's —
416 // `install` reports the directory it made, so nothing here has to
417 // re-derive from the shell what that call already knew.
418 const injection: shellint.Injection = if (opts.shell_integration)
419 shellint.install(a, std.fs.path.dirname(opts.sock_path) orelse ".", opts.shell)
420 else
421 shellint.no_injection;
422
423 // shellint speaks its own EnvPair so it can stay a leaf, and so can
424 // pty; the daemon is the one place that knows about both, so the
425 // mapping lives here.
426 const env = try a.alloc(Pty.EnvPair, injection.env.len + opts.extra_env.len);
427 for (injection.env, env[0..injection.env.len]) |src, *dst| {
428 dst.* = .{ .key = src.key, .value = src.value };
429 }
430 // Last, so setenv's overwrite makes the caller's spelling the one
431 // the child sees (see the loop in Pty.spawnArgv).
432 @memcpy(env[injection.env.len..], opts.extra_env);
433
434 // argv is the shell plus whatever the injection adds, null-terminated
435 // for execve. With no extra argv and no env this is byte-identical to
436 // the old `Pty.spawn` call, which is what keeps a /bin/sh session
437 // exactly the session it was before shell integration existed.
438 const argv = try a.allocSentinel(?[*:0]const u8, 1 + injection.extra_argv.len, null);
439 argv[0] = opts.shell.ptr;
440 for (injection.extra_argv, argv[1..]) |src, *dst| dst.* = src.ptr;
441
442 return .{ .argv = argv.ptr, .env = env, .shellint_dir = injection.dir };
443 }
444
456 pub fn deinit(self: *Server) void { 445 pub fn deinit(self: *Server) void {
457 for (&self.clients) |*slot| { 446 for (&self.clients) |*slot| {
458 if (slot.*) |*c| { 447 if (slot.*) |*c| {
@@ -1201,30 +1190,27 @@ pub const Server = struct {
1201 1190
1202 while (true) { 1191 while (true) {
1203 if (self.clients[i] == null) return; // a handler dropped it 1192 if (self.clients[i] == null) return; // a handler dropped it
1204 const buf = self.clients[i].?.inbound.items; 1193 // Same walk as the client's, out of proto: a length the peer
1205 if (buf.len < 5) return; // not yet a header 1194 // chose is gated there, and a partial tail is null rather than
1206 const len = std.mem.readInt(u32, buf[1..5], .little); 1195 // an error. What differs is the verdict on a bad length — this
1207 if (len > proto.max_payload) { 1196 // side has a connection it can drop, and does.
1197 const d = proto.delimitFrame(self.clients[i].?.inbound.items) catch {
1208 self.dropClient(i); 1198 self.dropClient(i);
1209 return; 1199 return;
1210 } 1200 } orelse return; // header or payload still coming
1211 if (buf.len < 5 + len) return; // header known, payload still coming
1212 1201
1213 // Copied out before handling: a handler may queue sends, and 1202 // Copied out before handling: a handler may queue sends, and
1214 // anything that touches this slot could reallocate `inbound` 1203 // anything that touches this slot could reallocate `inbound`
1215 // underneath a slice into it. 1204 // underneath a slice into it.
1216 const payload = self.alloc.alloc(u8, len) catch { 1205 const payload = self.alloc.alloc(u8, d.payload.len) catch {
1217 self.dropClient(i); 1206 self.dropClient(i);
1218 return; 1207 return;
1219 }; 1208 };
1220 defer self.alloc.free(payload); 1209 defer self.alloc.free(payload);
1221 @memcpy(payload, buf[5 .. 5 + len]); 1210 @memcpy(payload, d.payload);
1222 const frame: proto.Frame = .{ 1211 const frame: proto.Frame = .{ .type = d.type, .payload = payload };
1223 .type = @enumFromInt(buf[0]),
1224 .payload = payload,
1225 };
1226 1212
1227 const consumed = 5 + len; 1213 const consumed = d.consumed;
1228 const slot = &self.clients[i].?; 1214 const slot = &self.clients[i].?;
1229 const rest = slot.inbound.items.len - consumed; 1215 const rest = slot.inbound.items.len - consumed;
1230 std.mem.copyForwards(u8, slot.inbound.items[0..rest], slot.inbound.items[consumed..]); 1216 std.mem.copyForwards(u8, slot.inbound.items[0..rest], slot.inbound.items[consumed..]);
@@ -1367,6 +1353,18 @@ pub const Server = struct {
1367 /// folding into poll is needed at that resolution. 1353 /// folding into poll is needed at that resolution.
1368 fn checkAwaits(self: *Server) void { 1354 fn checkAwaits(self: *Server) void {
1369 const now = std.time.milliTimestamp(); 1355 const now = std.time.milliTimestamp();
1356 // One ioctl for the whole pump, not one per waiting client: the
1357 // foreground process group is a property of the pty, so every client
1358 // in this loop would read the same number back. Null covers both
1359 // "marks hold the floor, so nobody asked" — the probe is skipped
1360 // entirely then, exactly as before — and "the ioctl failed", which
1361 // has always been silently ignored. What stays per-client is
1362 // `saw_busy`: the transition each await is watching for is its own.
1363 const fg_pgid: ?std.posix.pid_t = if (self.cmd.marksOpen())
1364 null
1365 else
1366 self.pty.fgPgid() catch null;
1367
1370 for (0..max_clients) |i| { 1368 for (0..max_clients) |i| {
1371 if (self.clients[i] == null) continue; 1369 if (self.clients[i] == null) continue;
1372 const slot = &self.clients[i].?; 1370 const slot = &self.clients[i].?;
@@ -1393,21 +1391,22 @@ pub const Server = struct {
1393 } 1391 }
1394 } 1392 }
1395 1393
1396 // 2. pgid: only when marks do not hold the floor. The shell is 1394 // 2. pgid: only when marks do not hold the floor (folded into
1397 // the session leader, so its pid is the resting pgid. 1395 // fg_pgid above). The shell is the session leader, so its pid
1398 if (!self.cmd.marksOpen()) { 1396 // is the resting pgid.
1399 if (self.pty.fgPgid()) |pg| { 1397 if (fg_pgid) |pg| {
1400 if (pg != self.pty.child) { 1398 if (pg != self.pty.child) {
1401 a.saw_busy = true; 1399 a.saw_busy = true;
1402 } else if (a.saw_busy) { 1400 } else if (a.saw_busy) {
1403 var st = self.cmdState(.pgid); 1401 // The pgid went out and came back: something ran and is
1404 st.phase = .returned; 1402 // over. WHAT its code was, this mechanism cannot say.
1405 st.exit_code = null; 1403 const st = self.fallbackState(.pgid, .{
1406 st.seq = self.tracker.seq; 1404 .phase = .returned,
1407 self.answerAwait(i, st, .returned); 1405 .clear_exit_code = true,
1408 continue; 1406 });
1409 } 1407 self.answerAwait(i, st, .returned);
1410 } else |_| {} 1408 continue;
1409 }
1411 } 1410 }
1412 1411
1413 // 3. Settle: output silence, if the caller asked for a floor. 1412 // 3. Settle: output silence, if the caller asked for a floor.
@@ -1415,9 +1414,9 @@ pub const Server = struct {
1415 now - self.last_pty_ms >= a.settle_ms and 1414 now - self.last_pty_ms >= a.settle_ms and
1416 now - a.started_ms >= a.settle_ms) 1415 now - a.started_ms >= a.settle_ms)
1417 { 1416 {
1418 var st = self.cmdState(.settle); 1417 // Phase is left as the session's own: silence says the
1419 st.exit_code = null; 1418 // output stopped, never that a command returned.
1420 st.seq = self.tracker.seq; 1419 const st = self.fallbackState(.settle, .{ .clear_exit_code = true });
1421 self.answerAwait(i, st, .settled); 1420 self.answerAwait(i, st, .settled);
1422 continue; 1421 continue;
1423 } 1422 }
@@ -1426,8 +1425,14 @@ pub const Server = struct {
1426 // not a zero-length deadline but the absence of one — such an 1425 // not a zero-length deadline but the absence of one — such an
1427 // await ends only when marks, the pgid or settle end it. 1426 // await ends only when marks, the pgid or settle end it.
1428 if (a.timeout_ms > 0 and now - a.started_ms >= a.timeout_ms) { 1427 if (a.timeout_ms > 0 and now - a.started_ms >= a.timeout_ms) {
1429 var st = self.cmdState(if (self.cmd.marks_seen) .marks else .pgid); 1428 // Nothing is overridden but the seq: a timeout reports the
1430 st.seq = self.tracker.seq; 1429 // session exactly as it stands, mid-command and all, and on
1430 // a marks session that includes an exit code still standing
1431 // from the command before this one.
1432 const st = self.fallbackState(
1433 if (self.cmd.marks_seen) .marks else .pgid,
1434 .{},
1435 );
1431 self.answerAwait(i, st, .timeout); 1436 self.answerAwait(i, st, .timeout);
1432 continue; 1437 continue;
1433 } 1438 }
@@ -1690,6 +1695,42 @@ pub const Server = struct {
1690 }; 1695 };
1691 } 1696 }
1692 1697
1698 /// The state an await resolved by something OTHER than the marks stream
1699 /// answers with: `cmdState` for the live picture, then the overrides
1700 /// that mechanism is entitled to make. One owner for all of them,
1701 /// because the three fallback arms in `checkAwaits` differ only in
1702 /// which overrides they take, and hand-patching the struct at each site
1703 /// made a set of deliberate differences look like three drifting copies.
1704 ///
1705 /// The seq override is unconditional and is the subtle one: it replaces
1706 /// cmdState's RETURN watermark with the delta tracker's current seq.
1707 /// That is deliberate — it orders the reply against the grid content
1708 /// the client has, which is what a fallback answer is actually about —
1709 /// and it is why proto.CmdState.seq documents two meanings. A client
1710 /// that took its next `since_seq` from here would be using a number
1711 /// from the wrong series; see that doc comment for the rule and for the
1712 /// open question of whether these arms should move the watermark at all.
1713 ///
1714 /// `phase` and `clear_exit_code` default to leaving what cmdState built:
1715 /// a mechanism overrides only what it can actually claim to know.
1716 fn fallbackState(
1717 self: *Server,
1718 mechanism: proto.Mechanism,
1719 overrides: struct {
1720 phase: ?proto.CmdPhase = null,
1721 clear_exit_code: bool = false,
1722 },
1723 ) proto.CmdState {
1724 var st = self.cmdState(mechanism);
1725 if (overrides.phase) |p| st.phase = p;
1726 // Only marks can know a code (proto.Mechanism says so); a fallback
1727 // that passed one through would be attributing the PREVIOUS
1728 // command's verdict to this one.
1729 if (overrides.clear_exit_code) st.exit_code = null;
1730 st.seq = self.tracker.seq;
1731 return st;
1732 }
1733
1693 fn buildStatusReply(self: *Server) proto.StatusReply { 1734 fn buildStatusReply(self: *Server) proto.StatusReply {
1694 const cur = self.eng.cursorPos(); 1735 const cur = self.eng.cursorPos();
1695 return .{ 1736 return .{
src/shellint.zig
Old New
@@ -3,6 +3,27 @@
3 //! rc-file edits, ever. Detection is by shell basename; unknown shells get 3 //! rc-file edits, ever. Detection is by shell basename; unknown shells get
4 //! nothing and the session runs on the pgid/settle fallbacks. 4 //! nothing and the session runs on the pgid/settle fallbacks.
5 const std = @import("std"); 5 const std = @import("std");
6 const xdg = @import("xdg");
7
8 /// The precmd hook, character for character the same in zsh and bash: both
9 /// shells spell `$?`, `local` and `printf` alike, and the mark it emits is
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 =
19 \\_mux_precmd() {
20 \\ local code=$?
21 \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code"
22 \\ _mux_ran=""
23 \\ printf '\e]133;A\a'
24 \\}
25 \\
26 ;
6 27
7 /// Pointing ZDOTDIR at the shim silently costs the user their ~/.zshenv: 28 /// Pointing ZDOTDIR at the shim silently costs the user their ~/.zshenv:
8 /// zsh looks for .zshenv under $ZDOTDIR, and the shim directory has none, 29 /// zsh looks for .zshenv under $ZDOTDIR, and the shim directory has none,
@@ -22,12 +43,8 @@ pub const zsh_zshrc =
22 \\[[ -f "${ZDOTDIR:-$HOME}/.zshrc" ]] && source "${ZDOTDIR:-$HOME}/.zshrc" 43 \\[[ -f "${ZDOTDIR:-$HOME}/.zshrc" ]] && source "${ZDOTDIR:-$HOME}/.zshrc"
23 \\autoload -Uz add-zsh-hook 44 \\autoload -Uz add-zsh-hook
24 \\_mux_preexec() { _mux_ran=1; printf '\e]133;C\a'; } 45 \\_mux_preexec() { _mux_ran=1; printf '\e]133;C\a'; }
25 \\_mux_precmd() { 46 \\
26 \\ local code=$? 47 ++ precmd_fn ++
27 \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code"
28 \\ _mux_ran=""
29 \\ printf '\e]133;A\a'
30 \\}
31 \\add-zsh-hook preexec _mux_preexec 48 \\add-zsh-hook preexec _mux_preexec
32 \\add-zsh-hook precmd _mux_precmd 49 \\add-zsh-hook precmd _mux_precmd
33 \\ 50 \\
@@ -68,12 +85,8 @@ pub const bash_init =
68 \\ _mux_ran=1 85 \\ _mux_ran=1
69 \\ printf '\e]133;C\a' 86 \\ printf '\e]133;C\a'
70 \\} 87 \\}
71 \\_mux_precmd() { 88 \\
72 \\ local code=$? 89 ++ precmd_fn ++
73 \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code"
74 \\ _mux_ran=""
75 \\ printf '\e]133;A\a'
76 \\}
77 \\# Prepended, never appended: _mux_precmd has to see the command's own 90 \\# Prepended, never appended: _mux_precmd has to see the command's own
78 \\# $?, and any member running ahead of it would have overwritten it. 91 \\# $?, and any member running ahead of it would have overwritten it.
79 \\# 92 \\#
@@ -123,17 +136,75 @@ pub fn detect(shell_path: []const u8) Kind {
123 pub const EnvPair = struct { key: [:0]const u8, value: [:0]const u8 }; 136 pub const EnvPair = struct { key: [:0]const u8, value: [:0]const u8 };
124 137
125 /// Everything the spawn needs: the argv to exec and env pairs to set in 138 /// Everything the spawn needs: the argv to exec and env pairs to set in
126 /// the child. `dir` must outlive the spawn (paths point into it). 139 /// the child. The shim directory must outlive the spawn (paths point into
140 /// it).
127 pub const Injection = struct { 141 pub const Injection = struct {
128 /// Extra argv AFTER the shell path (bash --init-file <shim>); empty 142 /// Extra argv AFTER the shell path (bash --init-file <shim>); empty
129 /// for env-only injections (zsh, fish) and for .other. 143 /// for env-only injections (zsh, fish) and for .other.
130 extra_argv: []const [:0]const u8, 144 extra_argv: []const [:0]const u8,
131 env: []const EnvPair, 145 env: []const EnvPair,
146 /// The shim directory, set EXACTLY when this call created one — an
147 /// unknown shell writes nothing and reports null. The caller deletes
148 /// it at teardown, so a path reported here that was never created
149 /// would be a cleanup claiming work it did not do, and a path created
150 /// but not reported would be litter left in the runtime directory.
151 /// Reported from the one place that knows, rather than re-derived by
152 /// the caller from a second `detect` of the same shell.
153 dir: ?[]const u8 = null,
132 }; 154 };
133 155
156 /// The empty injection: what a shell with no scripts gets, and what a
157 /// failed `install` degrades to. Nothing to exec, nothing to export,
158 /// nothing on disk to remove.
159 pub const no_injection: Injection = .{ .extra_argv = &.{}, .env = &.{}, .dir = null };
160
161 /// Prepare the shim under `parent_dir` and hand back what the spawn must
162 /// add, degrading to `no_injection` rather than failing.
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>` naming lives here rather than at the call
172 /// 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 out of each other's shims.
175 pub fn install(
176 arena: std.mem.Allocator,
177 parent_dir: []const u8,
178 shell_path: []const u8,
179 ) Injection {
180 const dir = std.fmt.allocPrint(
181 arena,
182 "{s}/mux-shellint-{d}",
183 .{ parent_dir, std.os.linux.getpid() },
184 ) catch {
185 std.debug.print(
186 "muxd: shell integration unavailable (out of memory naming the shim " ++
187 "directory under {s}); the session runs without command marks\n",
188 .{parent_dir},
189 );
190 return no_injection;
191 };
192 return prepare(arena, dir, shell_path) catch |err| {
193 std.debug.print(
194 "muxd: shell integration unavailable ({s}: {t}); " ++
195 "the session runs without command marks\n",
196 .{ dir, err },
197 );
198 return no_injection;
199 };
200 }
201
134 /// Prepare shim files under `dir` (created private, 0700) for `shell_path` 202 /// Prepare shim files under `dir` (created private, 0700) for `shell_path`
135 /// and return what spawn must add. All returned slices are allocated from 203 /// and return what spawn must add. All returned slices are allocated from
136 /// `arena` — hand it an arena that lives as long as the daemon. 204 /// `arena` — hand it an arena that lives as long as the daemon.
205 ///
206 /// `install` is what the daemon calls; this stays public for the tests,
207 /// which need to name their own directory.
137 pub fn prepare( 208 pub fn prepare(
138 arena: std.mem.Allocator, 209 arena: std.mem.Allocator,
139 dir: []const u8, 210 dir: []const u8,
@@ -141,7 +212,7 @@ pub fn prepare(
141 ) !Injection { 212 ) !Injection {
142 switch (detect(shell_path)) { 213 switch (detect(shell_path)) {
143 .zsh => { 214 .zsh => {
144 try makeDirPrivate(dir); 215 try xdg.makePrivateDir(dir);
145 const rc_path = try std.fs.path.join(arena, &.{ dir, ".zshrc" }); 216 const rc_path = try std.fs.path.join(arena, &.{ dir, ".zshrc" });
146 try writeFilePrivate(rc_path, zsh_zshrc); 217 try writeFilePrivate(rc_path, zsh_zshrc);
147 var env: std.ArrayList(EnvPair) = .empty; 218 var env: std.ArrayList(EnvPair) = .empty;
@@ -155,21 +226,21 @@ pub fn prepare(
155 .value = try arena.dupeZ(u8, orig), 226 .value = try arena.dupeZ(u8, orig),
156 }); 227 });
157 } 228 }
158 return .{ .extra_argv = &.{}, .env = try env.toOwnedSlice(arena) }; 229 return .{ .extra_argv = &.{}, .env = try env.toOwnedSlice(arena), .dir = dir };
159 }, 230 },
160 .bash => { 231 .bash => {
161 try makeDirPrivate(dir); 232 try xdg.makePrivateDir(dir);
162 const init_path = try std.fs.path.join(arena, &.{ dir, "bash-init.sh" }); 233 const init_path = try std.fs.path.join(arena, &.{ dir, "bash-init.sh" });
163 try writeFilePrivate(init_path, bash_init); 234 try writeFilePrivate(init_path, bash_init);
164 const init_z = try arena.dupeZ(u8, init_path); 235 const init_z = try arena.dupeZ(u8, init_path);
165 const argv = try arena.alloc([:0]const u8, 2); 236 const argv = try arena.alloc([:0]const u8, 2);
166 argv[0] = "--init-file"; 237 argv[0] = "--init-file";
167 argv[1] = init_z; 238 argv[1] = init_z;
168 return .{ .extra_argv = argv, .env = &.{} }; 239 return .{ .extra_argv = argv, .env = &.{}, .dir = dir };
169 }, 240 },
170 .fish => { 241 .fish => {
171 const vendor = try std.fs.path.join(arena, &.{ dir, "fish", "vendor_conf.d" }); 242 const vendor = try std.fs.path.join(arena, &.{ dir, "fish", "vendor_conf.d" });
172 try makeDirPrivate(vendor); 243 try xdg.makePrivateDir(vendor);
173 const conf_path = try std.fs.path.join(arena, &.{ vendor, "mux.fish" }); 244 const conf_path = try std.fs.path.join(arena, &.{ vendor, "mux.fish" });
174 try writeFilePrivate(conf_path, fish_conf); 245 try writeFilePrivate(conf_path, fish_conf);
175 const orig = std.posix.getenv("XDG_DATA_DIRS") orelse "/usr/local/share:/usr/share"; 246 const orig = std.posix.getenv("XDG_DATA_DIRS") orelse "/usr/local/share:/usr/share";
@@ -177,26 +248,20 @@ pub fn prepare(
177 return .{ 248 return .{
178 .extra_argv = &.{}, 249 .extra_argv = &.{},
179 .env = try arena.dupe(EnvPair, &.{.{ .key = "XDG_DATA_DIRS", .value = merged }}), 250 .env = try arena.dupe(EnvPair, &.{.{ .key = "XDG_DATA_DIRS", .value = merged }}),
251 // The vendor directory nests UNDER `dir`, and `dir` is what
252 // teardown removes: deleting the leaf would leave the two
253 // directories above it behind.
254 .dir = dir,
180 }; 255 };
181 }, 256 },
182 .other => return .{ .extra_argv = &.{}, .env = &.{} }, 257 .other => return no_injection,
183 } 258 }
184 } 259 }
185 260
186 /// makePath plus the 0700 tightening the key file's parent gets, and for 261 /// The shim's contents are 0600 either way; the 0700 on the directory is
187 /// the same reason: the shim's contents are 0600 either way, but a 0755 262 /// what keeps it from publishing that this daemon exists and what it named
188 /// directory publishes that this daemon exists and what it named its 263 /// its files — the same reason, and now the same code, as the key file's
189 /// files. Only the last component is tightened — the runtime directory on 264 /// parent (see xdg.makePrivateDir).
190 /// the way there is not ours to re-permission.
191 fn makeDirPrivate(dir: []const u8) !void {
192 try std.fs.cwd().makePath(dir);
193 // `.iterate = true` is not optional: Dir.chmod fchmods the directory's
194 // own fd, and without it that fd is opened O_PATH, which fchmod refuses.
195 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
196 defer d.close();
197 try d.chmod(0o700);
198 }
199
200 fn writeFilePrivate(path: []const u8, contents: []const u8) !void { 265 fn writeFilePrivate(path: []const u8, contents: []const u8) !void {
201 const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600 }); 266 const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600 });
202 defer f.close(); 267 defer f.close();
@@ -254,6 +319,9 @@ test "prepare zsh writes the shim and sets ZDOTDIR" {
254 try std.testing.expect(inj.env.len >= 1); 319 try std.testing.expect(inj.env.len >= 1);
255 try std.testing.expectEqualStrings("ZDOTDIR", inj.env[0].key); 320 try std.testing.expectEqualStrings("ZDOTDIR", inj.env[0].key);
256 try std.testing.expectEqualStrings(shim, inj.env[0].value); 321 try std.testing.expectEqualStrings(shim, inj.env[0].value);
322 // A directory was created, so it is reported — this is the value the
323 // daemon deletes at teardown, and nothing else tells it what to delete.
324 try std.testing.expectEqualStrings(shim, inj.dir.?);
257 325
258 // ZDOTDIR names the directory; the file zsh will source is the .zshrc 326 // ZDOTDIR names the directory; the file zsh will source is the .zshrc
259 // inside it, which is the artifact worth asserting on. 327 // inside it, which is the artifact worth asserting on.
@@ -283,6 +351,7 @@ test "prepare bash returns --init-file argv" {
283 try std.testing.expectEqual(@as(usize, 2), inj.extra_argv.len); 351 try std.testing.expectEqual(@as(usize, 2), inj.extra_argv.len);
284 try std.testing.expectEqualStrings("--init-file", inj.extra_argv[0]); 352 try std.testing.expectEqualStrings("--init-file", inj.extra_argv[0]);
285 try std.testing.expectEqual(@as(usize, 0), inj.env.len); 353 try std.testing.expectEqual(@as(usize, 0), inj.env.len);
354 try std.testing.expectEqualStrings(shim, inj.dir.?);
286 355
287 const script = try std.fs.cwd().readFileAlloc(std.testing.allocator, inj.extra_argv[1], 8192); 356 const script = try std.fs.cwd().readFileAlloc(std.testing.allocator, inj.extra_argv[1], 8192);
288 defer std.testing.allocator.free(script); 357 defer std.testing.allocator.free(script);
@@ -308,6 +377,9 @@ test "prepare fish writes vendor_conf.d and prepends to XDG_DATA_DIRS" {
308 try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len); 377 try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
309 try std.testing.expectEqual(@as(usize, 1), inj.env.len); 378 try std.testing.expectEqual(@as(usize, 1), inj.env.len);
310 try std.testing.expectEqualStrings("XDG_DATA_DIRS", inj.env[0].key); 379 try std.testing.expectEqualStrings("XDG_DATA_DIRS", inj.env[0].key);
380 // The shim root, not the vendor directory nested inside it: teardown
381 // removes what it is given, and the leaf would strand two levels.
382 try std.testing.expectEqualStrings(shim, inj.dir.?);
311 // Prepended, not replaced: fish still has to find its own completions 383 // Prepended, not replaced: fish still has to find its own completions
312 // and functions, so clobbering the list would break the shell to 384 // and functions, so clobbering the list would break the shell to
313 // integrate with it. 385 // integrate with it.
@@ -341,6 +413,41 @@ test "prepare other injects nothing" {
341 // Not merely empty: an unknown shell must leave no trace on disk, so a 413 // Not merely empty: an unknown shell must leave no trace on disk, so a
342 // /bin/sh session is byte-identical to one from before this module. 414 // /bin/sh session is byte-identical to one from before this module.
343 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(shim, .{})); 415 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(shim, .{}));
416 // And it says so, which is the half teardown reads: a reported path
417 // here would have the daemon delete-tree a directory nothing created.
418 try std.testing.expectEqual(@as(?[]const u8, null), inj.dir);
419 }
420
421 test "install names the shim directory after the daemon and degrades in place" {
422 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
423 defer arena.deinit();
424 var t = try TmpPath.make();
425 defer t.deinit();
426
427 // The naming rule, asserted where it now lives: the caller hands over a
428 // parent and gets back a per-pid directory under it, which is what keeps
429 // two daemons sharing one runtime directory out of each other's shims.
430 const inj = install(arena.allocator(), t.dir, "/bin/bash");
431 var want: [512]u8 = undefined;
432 const expect = try std.fmt.bufPrint(
433 &want,
434 "{s}/mux-shellint-{d}",
435 .{ t.dir, std.os.linux.getpid() },
436 );
437 try std.testing.expectEqualStrings(expect, inj.dir.?);
438 try std.fs.cwd().access(expect, .{});
439
440 // An unwritable parent is the degraded path, and it is NOT an error: a
441 // session without marks still runs, so `install` returns the empty
442 // injection and the daemon starts. (The diagnostic goes to stderr; what
443 // is asserted here is that nothing propagates and nothing is claimed.)
444 const blocked = try std.fs.path.join(arena.allocator(), &.{ t.dir, "file-not-a-dir" });
445 const f = try std.fs.cwd().createFile(blocked, .{});
446 f.close();
447 const degraded = install(arena.allocator(), blocked, "/bin/bash");
448 try std.testing.expectEqual(@as(usize, 0), degraded.extra_argv.len);
449 try std.testing.expectEqual(@as(usize, 0), degraded.env.len);
450 try std.testing.expectEqual(@as(?[]const u8, null), degraded.dir);
344 } 451 }
345 452
346 test "prepare zsh: the shim directory is 0700 and the rc file 0600" { 453 test "prepare zsh: the shim directory is 0700 and the rc file 0600" {
src/webhub.zig
Old New
@@ -508,6 +508,12 @@ fn dialLoop(
508 /// bytes JSON cannot carry raw in a string plus control chars; labels 508 /// bytes JSON cannot carry raw in a string plus control chars; labels
509 /// are argv (hosts, paths), not hostile input, but a path with a quote 509 /// are argv (hosts, paths), not hostile input, but a path with a quote
510 /// in it must not break the page. 510 /// in it must not break the page.
511 ///
512 /// Deliberately NOT muxa's jsonEscape, though the two look alike: this one
513 /// sends every control byte to `\u00XX` (one rule, no table to get wrong)
514 /// while muxa spells the short forms `\n`, `\r`, `\t`. Both are valid JSON
515 /// and parse identically, but the bytes differ, and each is pinned by its
516 /// own test. Sharing one would rewrite one side's output for no gain.
511 pub fn tilesJson(alloc: std.mem.Allocator, labels: []const []const u8) ![]u8 { 517 pub fn tilesJson(alloc: std.mem.Allocator, labels: []const []const u8) ![]u8 {
512 var out: std.ArrayList(u8) = .empty; 518 var out: std.ArrayList(u8) = .empty;
513 errdefer out.deinit(alloc); 519 errdefer out.deinit(alloc);
src/xdg.zig
Old New
@@ -105,20 +105,19 @@ pub fn hostCachePathFrom(
105 return std.fmt.allocPrint(alloc, "{s}/.cache/mux/hosts/{s}", .{ h, host }); 105 return std.fmt.allocPrint(alloc, "{s}/.cache/mux/hosts/{s}", .{ h, host });
106 } 106 }
107 107
108 /// Create `path`'s parent directories and tighten the immediate parent to 108 /// Create `dir` and everything above it, then tighten `dir` itself to
109 /// 0700. Both files this project writes under a home directory — the key 109 /// 0700. Every directory this project creates to hold something private —
110 /// and the handoff cache — hold a credential and want exactly this, so the 110 /// the key file's parent, the handoff cache's, and the shell-integration
111 /// policy and the reason it is subtle live here rather than in two copies. 111 /// shim directory — wants exactly this, so the policy and the two subtle
112 /// A `path` with no directory component is a no-op. 112 /// parts of it live here rather than in a copy per caller.
113 pub fn makePrivateParent(path: []const u8) !void { 113 pub fn makePrivateDir(dir: []const u8) !void {
114 const dir = std.fs.path.dirname(path) orelse return;
115 try std.fs.cwd().makePath(dir); 114 try std.fs.cwd().makePath(dir);
116 // makePath leaves 0755, which does not expose the file's contents — 115 // makePath leaves 0755, which does not expose a contained file's
117 // that is 0600 — but does expose that it exists and what it is called. 116 // contents — that is 0600 — but does expose that it exists and what it
118 // ssh's answer for the analogous directory is 0700 and there is no 117 // is called. ssh's answer for the analogous directory is 0700 and
119 // reason to be looser. Only the LAST component is tightened: the 118 // there is no reason to be looser. Only THIS component is tightened:
120 // parents on the way (`~`, `~/.config`) are the user's own business 119 // the parents on the way (`~`, `~/.config`, the runtime directory) are
121 // and are not ours to re-permission. 120 // the user's own business and are not ours to re-permission.
122 // `.iterate = true` is not optional here: Dir.chmod fchmods the 121 // `.iterate = true` is not optional here: Dir.chmod fchmods the
123 // directory's own fd, and without it the fd is opened O_PATH, which 122 // directory's own fd, and without it the fd is opened O_PATH, which
124 // fchmod refuses. 123 // fchmod refuses.
@@ -127,6 +126,13 @@ pub fn makePrivateParent(path: []const u8) !void {
127 try d.chmod(0o700); 126 try d.chmod(0o700);
128 } 127 }
129 128
129 /// The same, for callers holding the path of the FILE that is going to
130 /// live there. A `path` with no directory component is a no-op.
131 pub fn makePrivateParent(path: []const u8) !void {
132 const dir = std.fs.path.dirname(path) orelse return;
133 try makePrivateDir(dir);
134 }
135
130 /// 32 random bytes at `path`, mode 0600, parent directories created and 136 /// 32 random bytes at `path`, mode 0600, parent directories created and
131 /// the immediate parent tightened to 0700. 137 /// the immediate parent tightened to 0700.
132 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both 138 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both