35b76657
feat: the daemon can write down what only it knows
a73x 2026-08-26 15:01
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -246,7 +246,7 @@ const mod_table = [_]ModSpec{ | |||
| 246 | // it names directly (the key it loads, the idle default it falls back | 246 | // it names directly (the key it loads, the idle default it falls back |
| 247 | // to). xdg is for endpoint_req's lazy bind — the default key path, | 247 | // to). xdg is for endpoint_req's lazy bind — the default key path, |
| 248 | // resolved by the daemon itself when nobody handed it a --key. | 248 | // resolved by the daemon itself when nobody handed it a --key. |
| 249 | .{ .name = "server", .path = "src/server.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "pty", "protocol", "delta", "cmd", "shellint", "sockpath", "quic", "quic_server", "xdg" }, .test_imports = &.{ "replica", "testtmp" }, .quic_tests = true }, | 249 | .{ .name = "server", .path = "src/server.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "pty", "protocol", "delta", "cmd", "shellint", "sockpath", "quic", "quic_server", "xdg", "upgrade" }, .test_imports = &.{ "replica", "testtmp" }, .quic_tests = true }, |
| 250 | // The agent-facing client. It speaks frames and owns no terminal, which | 250 | // The agent-facing client. It speaks frames and owns no terminal, which |
| 251 | // is the whole point — it attaches at 0x0 and never claims the grid. | 251 | // is the whole point — it attaches at 0x0 and never claims the grid. |
| 252 | // The transport modules are the CLI client's, minus everything that | 252 | // The transport modules are the CLI client's, minus everything that |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -22,6 +22,7 @@ const sockpath = @import("sockpath"); | |||
| 22 | const quic = @import("quic"); | 22 | const quic = @import("quic"); |
| 23 | const quic_server = @import("quic_server"); | 23 | const quic_server = @import("quic_server"); |
| 24 | const xdg = @import("xdg"); | 24 | const xdg = @import("xdg"); |
| 25 | const upgrade = @import("upgrade"); | ||
| 25 | const TmpDir = @import("testtmp").TmpDir; | 26 | const TmpDir = @import("testtmp").TmpDir; |
| 26 | 27 | ||
| 27 | const max_clients = 8; | 28 | const max_clients = 8; |
| @@ -504,6 +505,11 @@ pub const Server = struct { | |||
| 504 | /// shell and orphan all but the last from teardown.) Every slice points | 505 | /// shell and orphan all but the last from teardown.) Every slice points |
| 505 | /// into shellint_arena. | 506 | /// into shellint_arena. |
| 506 | spawn_plan: SpawnPlan, | 507 | spawn_plan: SpawnPlan, |
| 508 | // Spawn inputs retained for the manifest: SpawnPlan is the computed | ||
| 509 | // result (argv/env after injection), not the inputs that produced it. | ||
| 510 | spawn_shell: [:0]const u8, | ||
| 511 | spawn_shell_integration: bool, | ||
| 512 | spawn_extra_env: []const Pty.EnvPair, | ||
| 507 | listener: std.net.Server, | 513 | listener: std.net.Server, |
| 508 | sock_path: []const u8, | 514 | sock_path: []const u8, |
| 509 | /// What the socket file was when we bound it, so teardown can tell our | 515 | /// What the socket file was when we bound it, so teardown can tell our |
| @@ -649,6 +655,9 @@ pub const Server = struct { | |||
| 649 | var srv: Server = .{ | 655 | var srv: Server = .{ |
| 650 | .alloc = alloc, | 656 | .alloc = alloc, |
| 651 | .spawn_plan = plan, | 657 | .spawn_plan = plan, |
| 658 | .spawn_shell = opts.shell, | ||
| 659 | .spawn_shell_integration = opts.shell_integration, | ||
| 660 | .spawn_extra_env = opts.extra_env, | ||
| 652 | .listener = listener, | 661 | .listener = listener, |
| 653 | .sock_path = opts.sock_path, | 662 | .sock_path = opts.sock_path, |
| 654 | .path_id = path_id, | 663 | .path_id = path_id, |
| @@ -3244,6 +3253,113 @@ pub const Server = struct { | |||
| 3244 | try proto.writeFrame(fd, .stats_reply, try self.statsText(&buf)); | 3253 | try proto.writeFrame(fd, .stats_reply, try self.statsText(&buf)); |
| 3245 | } | 3254 | } |
| 3246 | 3255 | ||
| 3256 | // The manifest an exec-ing daemon leaves for its replacement. The | ||
| 3257 | // writer's version and path are the rollback target's identity. | ||
| 3258 | fn writeManifestTo( | ||
| 3259 | self: *Server, | ||
| 3260 | fd: std.posix.fd_t, | ||
| 3261 | writer_version: []const u8, | ||
| 3262 | writer_path: []const u8, | ||
| 3263 | ) !void { | ||
| 3264 | const a = self.alloc; | ||
| 3265 | |||
| 3266 | // QUIC runtime state: arm, fd, bound address, idle, key. The key | ||
| 3267 | // crosses as bytes — the memfd is anonymous and the file may have | ||
| 3268 | // moved. None means no QUIC at all. | ||
| 3269 | var quic_state: upgrade.QuicState = .{}; | ||
| 3270 | switch (self.quic) { | ||
| 3271 | .none => {}, | ||
| 3272 | .borrowed, .owned => |l| { | ||
| 3273 | quic_state.arm = if (self.quic == .owned) .owned else .borrowed; | ||
| 3274 | quic_state.fd = l.fd; | ||
| 3275 | quic_state.idle_ms = l.idle_ms; | ||
| 3276 | const addr = l.boundAddr(); | ||
| 3277 | const addr_bytes = std.mem.asBytes(&addr.any); | ||
| 3278 | quic_state.addr_len = @intCast(addr_bytes.len); | ||
| 3279 | @memcpy(quic_state.addr[0..addr_bytes.len], addr_bytes); | ||
| 3280 | if (quic_server.Listener.currentKey()) |k| @memcpy(&quic_state.key, &k.bytes); | ||
| 3281 | }, | ||
| 3282 | } | ||
| 3283 | |||
| 3284 | // extra_env: upgrade.EnvPair is { []const u8, ?[]const u8 } while | ||
| 3285 | // Pty.EnvPair is { [:0]const u8, ?[:0]const u8 }; the manifest's | ||
| 3286 | // shape is what crosses, so the mapping lives here. | ||
| 3287 | const extra_env = try a.alloc(upgrade.EnvPair, self.spawn_extra_env.len); | ||
| 3288 | defer a.free(extra_env); | ||
| 3289 | for (self.spawn_extra_env, extra_env) |src, *dst| { | ||
| 3290 | dst.* = .{ .key = src.key, .value = if (src.value) |v| v else null }; | ||
| 3291 | } | ||
| 3292 | |||
| 3293 | const daemon: upgrade.Daemon = .{ | ||
| 3294 | .writer_version = writer_version, | ||
| 3295 | .writer_path = writer_path, | ||
| 3296 | .sock_path = self.sock_path, | ||
| 3297 | .listener_fd = self.listener.stream.handle, | ||
| 3298 | .shellint_dir = self.shellint_dir, | ||
| 3299 | .agent_dir = self.agent_dir, | ||
| 3300 | .shell = self.spawn_shell, | ||
| 3301 | .shell_integration = self.spawn_shell_integration, | ||
| 3302 | .extra_env = extra_env, | ||
| 3303 | .quic = quic_state, | ||
| 3304 | .counters = .{ | ||
| 3305 | .snapshots = self.stats.snapshots, | ||
| 3306 | .snapshot_bytes = self.stats.snapshot_bytes, | ||
| 3307 | .deltas = self.stats.deltas, | ||
| 3308 | .delta_bytes = self.stats.delta_bytes, | ||
| 3309 | .snapshot_equiv_bytes = self.stats.snapshot_equiv_bytes, | ||
| 3310 | .attaches = self.stats.attaches, | ||
| 3311 | .agent_refused_no_offer = self.agent_refused_no_offer, | ||
| 3312 | .agent_refused_full = self.agent_refused_full, | ||
| 3313 | }, | ||
| 3314 | }; | ||
| 3315 | |||
| 3316 | // One SessionRec per live session. dumpState is viewport-only by | ||
| 3317 | // construction; the title is carried apart because dumpState has | ||
| 3318 | // no title field. | ||
| 3319 | var recs: std.ArrayList(upgrade.SessionRec) = .empty; | ||
| 3320 | defer recs.deinit(a); | ||
| 3321 | for (&self.sessions) |*slot| { | ||
| 3322 | const s = slot.* orelse continue; | ||
| 3323 | const vt = try s.eng.dumpState(a); | ||
| 3324 | const title_bytes = s.eng.title(); | ||
| 3325 | const title: ?[]const u8 = if (title_bytes.len > 0) title_bytes else null; | ||
| 3326 | try recs.append(a, .{ | ||
| 3327 | .name = s.name(), | ||
| 3328 | .pty_fd = s.pty.master, | ||
| 3329 | .child_pid = s.pty.child, | ||
| 3330 | .cols = @intCast(s.eng.term.cols), | ||
| 3331 | .rows = @intCast(s.eng.term.rows), | ||
| 3332 | .vt = vt, | ||
| 3333 | .title = title, | ||
| 3334 | .cmd = .{ | ||
| 3335 | .phase = @intFromEnum(s.cmd.phase), | ||
| 3336 | .marks_seen = s.cmd.marks_seen, | ||
| 3337 | .start_row = s.cmd.start_row, | ||
| 3338 | .end_row = s.cmd.end_row, | ||
| 3339 | .exit_code = s.cmd.exit_code, | ||
| 3340 | }, | ||
| 3341 | .last_return = s.last_return, | ||
| 3342 | .agent_fd = s.agent_listener, | ||
| 3343 | .agent_path = if (s.agent_path) |p| p else null, | ||
| 3344 | }); | ||
| 3345 | } | ||
| 3346 | |||
| 3347 | // Write to a buffer first, then to the fd: the manifest writer's | ||
| 3348 | // anytype contract is an ArrayList writer (as the upgrade module's | ||
| 3349 | // own tests use), and serializing to memory avoids coupling the | ||
| 3350 | // manifest format to a particular fd writer's interface. | ||
| 3351 | var manifest_buf: std.ArrayList(u8) = .empty; | ||
| 3352 | defer manifest_buf.deinit(a); | ||
| 3353 | try upgrade.writeManifest(manifest_buf.writer(a), a, daemon, recs.items); | ||
| 3354 | var file = std.fs.File{ .handle = fd }; | ||
| 3355 | try file.seekTo(0); | ||
| 3356 | try file.writeAll(manifest_buf.items); | ||
| 3357 | |||
| 3358 | // dumpState allocated each iteration; freed now that the manifest | ||
| 3359 | // carries a copy. | ||
| 3360 | for (recs.items) |r| a.free(r.vt); | ||
| 3361 | } | ||
| 3362 | |||
| 3247 | pub const stats_main_fmt = | 3363 | pub const stats_main_fmt = |
| 3248 | "snapshots={d} snapshot_bytes={d} deltas={d} delta_bytes={d}" ++ | 3364 | "snapshots={d} snapshot_bytes={d} deltas={d} delta_bytes={d}" ++ |
| 3249 | " snapshot_equiv_bytes={d} clients={d} attaches={d} sessions={d}" ++ | 3365 | " snapshot_equiv_bytes={d} clients={d} attaches={d} sessions={d}" ++ |
| @@ -11077,3 +11193,58 @@ test "Server: a resize reaches the pty as an in-band size report when the app as | |||
| 11077 | } | 11193 | } |
| 11078 | try std.testing.expect(std.mem.indexOf(u8, got.items, "[48;30;100;0;0t") != null); | 11194 | try std.testing.expect(std.mem.indexOf(u8, got.items, "[48;30;100;0;0t") != null); |
| 11079 | } | 11195 | } |
| 11196 | |||
| 11197 | test "writeManifestTo: what crosses is what a session cannot rebuild" { | ||
| 11198 | const alloc = std.testing.allocator; | ||
| 11199 | |||
| 11200 | var tmp = try TmpDir.make(); | ||
| 11201 | defer tmp.cleanup(); | ||
| 11202 | const dir_path = tmp.path(); | ||
| 11203 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/manif.sock", .{dir_path}); | ||
| 11204 | defer alloc.free(sock_path); | ||
| 11205 | |||
| 11206 | var srv = try Server.init(alloc, .{ | ||
| 11207 | .sock_path = sock_path, | ||
| 11208 | .shell = "/bin/sh", | ||
| 11209 | .shell_integration = true, | ||
| 11210 | .extra_env = &.{.{ .key = "PS1", .value = ">>" }}, | ||
| 11211 | }); | ||
| 11212 | defer srv.deinit(); | ||
| 11213 | |||
| 11214 | const s = &srv.sessions[0].?; | ||
| 11215 | // Feed the engine a title so the manifest must carry it. | ||
| 11216 | s.eng.feed("\x1b]0;t1\x07"); | ||
| 11217 | // Feed some content so dumpState is non-empty. | ||
| 11218 | s.eng.feed("hello\r\n"); | ||
| 11219 | |||
| 11220 | const memfd = try std.posix.memfd_create("mux-upgrade-test", 0); | ||
| 11221 | defer std.posix.close(memfd); | ||
| 11222 | |||
| 11223 | try srv.writeManifestTo(memfd, "0.0.1-99", "/fake/muxd"); | ||
| 11224 | |||
| 11225 | // Read back and parse. | ||
| 11226 | var file = std.fs.File{ .handle = memfd }; | ||
| 11227 | try file.seekTo(0); | ||
| 11228 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); | ||
| 11229 | defer alloc.free(buf); | ||
| 11230 | |||
| 11231 | var parsed = try upgrade.parseManifest(alloc, buf); | ||
| 11232 | defer parsed.deinit(); | ||
| 11233 | |||
| 11234 | // Daemon section: sock_path and writer identity cross. | ||
| 11235 | try std.testing.expectEqualStrings(sock_path, parsed.daemon.sock_path); | ||
| 11236 | try std.testing.expectEqualStrings("0.0.1-99", parsed.daemon.writer_version); | ||
| 11237 | try std.testing.expectEqualStrings("/fake/muxd", parsed.daemon.writer_path); | ||
| 11238 | try std.testing.expectEqualStrings("/bin/sh", parsed.daemon.shell); | ||
| 11239 | try std.testing.expect(parsed.daemon.shell_integration); | ||
| 11240 | try std.testing.expectEqual(@as(usize, 1), parsed.daemon.extra_env.len); | ||
| 11241 | try std.testing.expectEqualStrings("PS1", parsed.daemon.extra_env[0].key); | ||
| 11242 | |||
| 11243 | // Session section: name, cols/rows, title, pty fd. | ||
| 11244 | try std.testing.expectEqual(@as(usize, 1), parsed.sessions.len); | ||
| 11245 | try std.testing.expectEqualStrings(proto.default_session, parsed.sessions[0].name); | ||
| 11246 | try std.testing.expectEqual(@as(u16, 80), parsed.sessions[0].cols); | ||
| 11247 | try std.testing.expectEqual(@as(u16, 24), parsed.sessions[0].rows); | ||
| 11248 | try std.testing.expectEqualStrings("t1", parsed.sessions[0].title.?); | ||
| 11249 | try std.testing.expectEqual(s.pty.master, parsed.sessions[0].pty_fd); | ||
| 11250 | } | ||