a73x

35b76657

feat: the daemon can write down what only it knows

a73x   2026-08-26 15:01

Commit message
feat: the daemon can write down what only it knows

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

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 }