23eb7062
refactor: simplify-pass — one owner for fallback state, spawn prep, precmd, frame delimiting
a73x 2026-08-13 21:17
Commit message
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 |