a73x

66faa2ff

feat: the upgrade manifest — sections a stranger can skip

a73x   2026-08-26 15:01

Commit message
feat: the upgrade manifest — sections a stranger can skip

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

build.zig
Old New
@@ -197,7 +197,7 @@ const mod_table = [_]ModSpec{
197 // exec-ing daemon leaves for its replacement. Protocol only — the 197 // exec-ing daemon leaves for its replacement. Protocol only — the
198 // manifest is a stranger's format (length-prefixed sections, unknown 198 // manifest is a stranger's format (length-prefixed sections, unknown
199 // tags skipped), so it cannot depend on server types. 199 // tags skipped), so it cannot depend on server types.
200 .{ .name = "upgrade", .path = "src/upgrade.zig", .layer = 1 }, 200 .{ .name = "upgrade", .path = "src/upgrade.zig", .layer = 1, .imports = &.{"protocol"} },
201 // Shell integration: the OSC 133 mark scripts and what a spawn must add 201 // Shell integration: the OSC 133 mark scripts and what a spawn must add
202 // to hand them to a shell. Near-leaf on purpose — it writes files and 202 // to hand them to a shell. Near-leaf on purpose — it writes files and
203 // reads the environment, and knows nothing of ptys, servers or the 203 // reads the environment, and knows nothing of ptys, servers or the
src/upgrade.zig
Old New
@@ -6,6 +6,7 @@
6 //! neither may be held to this build's struct layout. 6 //! neither may be held to this build's struct layout.
7 7
8 const std = @import("std"); 8 const std = @import("std");
9 const proto = @import("protocol");
9 10
10 /// Skew rule: an upgrade candidate must outrank the running daemon. 11 /// Skew rule: an upgrade candidate must outrank the running daemon.
11 /// SemanticVersion because our releases are `0.0.1-13`-shaped: the `-13` 12 /// SemanticVersion because our releases are `0.0.1-13`-shaped: the `-13`
@@ -35,3 +36,467 @@ test "strictlyNewer: a release outranks its own prereleases" {
35 test "strictlyNewer: garbage is an error, not a verdict" { 36 test "strictlyNewer: garbage is an error, not a verdict" {
36 try std.testing.expectError(error.BadVersion, strictlyNewer("not-a-version", "0.0.1-13")); 37 try std.testing.expectError(error.BadVersion, strictlyNewer("not-a-version", "0.0.1-13"));
37 } 38 }
39
40 /// Bumped when a section's LAYOUT changes, independent of the release
41 /// version: the release number says what the binary can do, this says
42 /// what these bytes mean, and rollback needs the two decoupled.
43 pub const manifest_version: u16 = 1;
44 const magic = "MUXU";
45
46 pub const SectionTag = enum(u8) { daemon = 1, session = 2, _ };
47
48 pub const EnvPair = struct { key: []const u8, value: ?[]const u8 };
49
50 pub const QuicArm = enum(u8) { none = 0, borrowed = 1, owned = 2 };
51
52 /// Runtime QUIC state, not launch flags: lazyBindQuic can own an
53 /// ephemeral-port listener no flag names, so the manifest records what IS
54 /// bound, arm and all. The key crosses as bytes, never a path — the memfd
55 /// is anonymous memory and the file the key came from may have moved.
56 pub const QuicState = struct {
57 arm: QuicArm = .none,
58 fd: i32 = -1,
59 addr: [addr_cap]u8 = @splat(0),
60 addr_len: u32 = 0,
61 idle_ms: u64 = 0,
62 key: [key_len]u8 = @splat(0),
63
64 pub const addr_cap = 128; // sockaddr_storage
65 pub const key_len = 32;
66 };
67
68 /// Cumulative counters cross so an upgrade is not mistaken for a restart
69 /// by anything sampling `muxd stats`.
70 pub const Counters = struct {
71 snapshots: u64 = 0,
72 snapshot_bytes: u64 = 0,
73 deltas: u64 = 0,
74 delta_bytes: u64 = 0,
75 snapshot_equiv_bytes: u64 = 0,
76 attaches: u64 = 0,
77 agent_refused_no_offer: u64 = 0,
78 agent_refused_full: u64 = 0,
79 };
80
81 pub const Daemon = struct {
82 writer_version: []const u8,
83 /// The rollback target: adoption failure execs this path back.
84 writer_path: []const u8,
85 sock_path: []const u8,
86 listener_fd: i32,
87 /// Both dirs are pid-named and held open by live shells; they cross as
88 /// paths because the new process (same pid) can keep them but could
89 /// never re-mint them.
90 shellint_dir: ?[]const u8,
91 agent_dir: ?[]const u8,
92 shell: []const u8,
93 shell_integration: bool,
94 extra_env: []const EnvPair,
95 quic: QuicState,
96 counters: Counters,
97 };
98
99 pub const CmdRec = struct {
100 phase: u8,
101 marks_seen: bool,
102 start_row: u32,
103 end_row: u32,
104 exit_code: ?u8,
105 };
106
107 pub const SessionRec = struct {
108 name: []const u8,
109 pty_fd: i32,
110 child_pid: i32,
111 cols: u16,
112 rows: u16,
113 /// Engine.dumpState bytes — viewport only; scrollback loss is the
114 /// spec's named non-goal.
115 vt: []const u8,
116 /// dumpState has no title field; carried apart or every title goes
117 /// blank until the shell next sets one.
118 title: ?[]const u8,
119 cmd: CmdRec,
120 /// The return watermark `muxa await --since` answers from; losing it
121 /// turns a satisfiable await into a timeout.
122 last_return: ?proto.CmdState,
123 agent_fd: i32,
124 agent_path: ?[]const u8,
125 };
126
127 // ---- encoding ----
128 // magic ++ u16 manifest_version, then sections: u8 tag ++ u32 LE len ++
129 // bytes. Strings are u32 LE len ++ bytes; optionals are u8 present ++
130 // payload. An unknown tag is skipped by its length — the whole
131 // forward-compat story.
132
133 fn writeInt(w: anytype, comptime T: type, v: T) !void {
134 var buf: [@sizeOf(T)]u8 = undefined;
135 std.mem.writeInt(T, &buf, v, .little);
136 try w.writeAll(&buf);
137 }
138
139 fn writeStr(w: anytype, s: []const u8) !void {
140 try writeInt(w, u32, @intCast(s.len));
141 try w.writeAll(s);
142 }
143
144 fn writeOptStr(w: anytype, s: ?[]const u8) !void {
145 try w.writeAll(&.{@intFromBool(s != null)});
146 if (s) |x| try writeStr(w, x);
147 }
148
149 fn writeDaemonBody(w: anytype, d: Daemon) !void {
150 try writeStr(w, d.writer_version);
151 try writeStr(w, d.writer_path);
152 try writeStr(w, d.sock_path);
153 try writeInt(w, i32, d.listener_fd);
154 try writeOptStr(w, d.shellint_dir);
155 try writeOptStr(w, d.agent_dir);
156 try writeStr(w, d.shell);
157 try w.writeAll(&.{@intFromBool(d.shell_integration)});
158 try writeInt(w, u32, @intCast(d.extra_env.len));
159 for (d.extra_env) |p| {
160 try writeStr(w, p.key);
161 try writeOptStr(w, p.value);
162 }
163 try w.writeAll(&.{@intFromEnum(d.quic.arm)});
164 try writeInt(w, i32, d.quic.fd);
165 try writeInt(w, u32, d.quic.addr_len);
166 try w.writeAll(&d.quic.addr);
167 try writeInt(w, u64, d.quic.idle_ms);
168 try w.writeAll(&d.quic.key);
169 inline for (@typeInfo(Counters).@"struct".fields) |f|
170 try writeInt(w, u64, @field(d.counters, f.name));
171 }
172
173 fn writeSessionBody(w: anytype, s: SessionRec) !void {
174 try writeStr(w, s.name);
175 try writeInt(w, i32, s.pty_fd);
176 try writeInt(w, i32, s.child_pid);
177 try writeInt(w, u16, s.cols);
178 try writeInt(w, u16, s.rows);
179 try writeStr(w, s.vt);
180 try writeOptStr(w, s.title);
181 try w.writeAll(&.{s.cmd.phase});
182 try w.writeAll(&.{@intFromBool(s.cmd.marks_seen)});
183 try writeInt(w, u32, s.cmd.start_row);
184 try writeInt(w, u32, s.cmd.end_row);
185 try w.writeAll(&.{@intFromBool(s.cmd.exit_code != null)});
186 try w.writeAll(&.{s.cmd.exit_code orelse 0});
187 try w.writeAll(&.{@intFromBool(s.last_return != null)});
188 if (s.last_return) |lr| try w.writeAll(&proto.encodeCmdState(lr));
189 try writeInt(w, i32, s.agent_fd);
190 try writeOptStr(w, s.agent_path);
191 }
192
193 fn writeSection(w: anytype, alloc: std.mem.Allocator, tag: SectionTag, body: []const u8) !void {
194 _ = alloc;
195 try w.writeAll(&.{@intFromEnum(tag)});
196 try writeInt(w, u32, @intCast(body.len));
197 try w.writeAll(body);
198 }
199
200 pub fn writeManifest(w: anytype, alloc: std.mem.Allocator, d: Daemon, sessions: []const SessionRec) !void {
201 try w.writeAll(magic);
202 try writeInt(w, u16, manifest_version);
203
204 var body: std.ArrayList(u8) = .empty;
205 defer body.deinit(alloc);
206
207 try writeDaemonBody(body.writer(alloc), d);
208 try writeSection(w, alloc, .daemon, body.items);
209
210 for (sessions) |s| {
211 body.clearRetainingCapacity();
212 try writeSessionBody(body.writer(alloc), s);
213 try writeSection(w, alloc, .session, body.items);
214 }
215 }
216
217 // ---- parsing ----
218
219 const Cursor = struct {
220 bytes: []const u8,
221 i: usize = 0,
222
223 fn take(self: *Cursor, n: usize) error{BadManifest}![]const u8 {
224 if (self.bytes.len - self.i < n) return error.BadManifest;
225 defer self.i += n;
226 return self.bytes[self.i..][0..n];
227 }
228 fn int(self: *Cursor, comptime T: type) error{BadManifest}!T {
229 return std.mem.readInt(T, (try self.take(@sizeOf(T)))[0..@sizeOf(T)], .little);
230 }
231 fn str(self: *Cursor, alloc: std.mem.Allocator) ![]const u8 {
232 const n = try self.int(u32);
233 return alloc.dupe(u8, try self.take(n));
234 }
235 fn optStr(self: *Cursor, alloc: std.mem.Allocator) !?[]const u8 {
236 if ((try self.int(u8)) == 0) return null;
237 return try self.str(alloc);
238 }
239 fn done(self: *const Cursor) bool {
240 return self.i == self.bytes.len;
241 }
242 };
243
244 pub const Parsed = struct {
245 daemon: Daemon,
246 sessions: []SessionRec,
247 arena: std.heap.ArenaAllocator,
248
249 pub fn deinit(self: *Parsed) void {
250 self.arena.deinit();
251 }
252 };
253
254 fn parseDaemon(c: *Cursor, alloc: std.mem.Allocator) !Daemon {
255 var d: Daemon = undefined;
256 d.writer_version = try c.str(alloc);
257 d.writer_path = try c.str(alloc);
258 d.sock_path = try c.str(alloc);
259 d.listener_fd = try c.int(i32);
260 d.shellint_dir = try c.optStr(alloc);
261 d.agent_dir = try c.optStr(alloc);
262 d.shell = try c.str(alloc);
263 d.shell_integration = (try c.int(u8)) != 0;
264 const env_n = try c.int(u32);
265 const env = try alloc.alloc(EnvPair, env_n);
266 for (env) |*p| {
267 p.key = try c.str(alloc);
268 p.value = try c.optStr(alloc);
269 }
270 d.extra_env = env;
271 d.quic = .{};
272 d.quic.arm = std.meta.intToEnum(QuicArm, try c.int(u8)) catch return error.BadManifest;
273 d.quic.fd = try c.int(i32);
274 d.quic.addr_len = try c.int(u32);
275 @memcpy(&d.quic.addr, try c.take(QuicState.addr_cap));
276 d.quic.idle_ms = try c.int(u64);
277 @memcpy(&d.quic.key, try c.take(QuicState.key_len));
278 inline for (@typeInfo(Counters).@"struct".fields) |f|
279 @field(d.counters, f.name) = try c.int(u64);
280 if (!c.done()) return error.BadManifest;
281 return d;
282 }
283
284 fn parseSession(c: *Cursor, alloc: std.mem.Allocator) !SessionRec {
285 var s: SessionRec = undefined;
286 s.name = try c.str(alloc);
287 s.pty_fd = try c.int(i32);
288 s.child_pid = try c.int(i32);
289 s.cols = try c.int(u16);
290 s.rows = try c.int(u16);
291 s.vt = try c.str(alloc);
292 s.title = try c.optStr(alloc);
293 s.cmd.phase = try c.int(u8);
294 s.cmd.marks_seen = (try c.int(u8)) != 0;
295 s.cmd.start_row = try c.int(u32);
296 s.cmd.end_row = try c.int(u32);
297 const has_code = (try c.int(u8)) != 0;
298 const code = try c.int(u8);
299 s.cmd.exit_code = if (has_code) code else null;
300 if ((try c.int(u8)) != 0) {
301 s.last_return = proto.decodeCmdState(try c.take(proto.cmd_state_len)) catch
302 return error.BadManifest;
303 } else s.last_return = null;
304 s.agent_fd = try c.int(i32);
305 s.agent_path = try c.optStr(alloc);
306 if (!c.done()) return error.BadManifest;
307 return s;
308 }
309
310 pub fn parseManifest(gpa: std.mem.Allocator, bytes: []const u8) error{ BadManifest, OutOfMemory }!Parsed {
311 var arena = std.heap.ArenaAllocator.init(gpa);
312 errdefer arena.deinit();
313 const alloc = arena.allocator();
314
315 var c = Cursor{ .bytes = bytes };
316 if (!std.mem.eql(u8, try c.take(magic.len), magic)) return error.BadManifest;
317 if ((try c.int(u16)) != manifest_version) return error.BadManifest;
318
319 var daemon: ?Daemon = null;
320 var sessions: std.ArrayList(SessionRec) = .empty;
321
322 while (!c.done()) {
323 const tag = try c.int(u8);
324 const len = try c.int(u32);
325 var body = Cursor{ .bytes = try c.take(len) };
326 switch (@as(SectionTag, @enumFromInt(tag))) {
327 .daemon => daemon = try parseDaemon(&body, alloc),
328 .session => try sessions.append(alloc, try parseSession(&body, alloc)),
329 // An unknown tag was written by a newer binary; its length
330 // already walked the cursor past it, so skipping is one arm.
331 _ => {},
332 }
333 }
334
335 return .{
336 .daemon = daemon orelse return error.BadManifest,
337 .sessions = sessions.items,
338 .arena = arena,
339 };
340 }
341
342 // ---- tests ----
343
344 fn sampleDaemon() Daemon {
345 var q = QuicState{ .arm = .owned, .fd = 7, .addr_len = 16, .idle_ms = 30_000 };
346 q.addr[0] = 2;
347 q.key[0] = 0xAB;
348 q.key[31] = 0xCD;
349 return .{
350 .writer_version = "0.0.1-13",
351 .writer_path = "/usr/bin/muxd.old",
352 .sock_path = "/run/user/1000/muxd.sock",
353 .listener_fd = 3,
354 .shellint_dir = "/tmp/mux-shim-1234",
355 .agent_dir = null,
356 .shell = "/bin/zsh",
357 .shell_integration = true,
358 .extra_env = &.{ .{ .key = "FOO", .value = "bar" }, .{ .key = "UNSET_ME", .value = null } },
359 .quic = q,
360 .counters = .{ .snapshots = 5, .attaches = 9 },
361 };
362 }
363
364 fn sampleSessions() [2]SessionRec {
365 return .{
366 .{
367 .name = "build",
368 .pty_fd = 10,
369 .child_pid = 4242,
370 .cols = 120,
371 .rows = 40,
372 .vt = "\x1b[2J\x1b[Hhello",
373 .title = "make: all",
374 .cmd = .{ .phase = 1, .marks_seen = true, .start_row = 3, .end_row = 9, .exit_code = 0 },
375 .last_return = .{
376 .phase = .at_prompt,
377 .mechanism = .marks,
378 .exit_code = 0,
379 .start_row = 3,
380 .end_row = 9,
381 .seq = 77,
382 },
383 .agent_fd = 11,
384 .agent_path = "/tmp/mux-agent/agent-build.sock",
385 },
386 .{
387 .name = "0",
388 .pty_fd = 12,
389 .child_pid = 4243,
390 .cols = 80,
391 .rows = 24,
392 .vt = "",
393 .title = null,
394 .cmd = .{ .phase = 0, .marks_seen = false, .start_row = 0, .end_row = 0, .exit_code = null },
395 .last_return = null,
396 .agent_fd = -1,
397 .agent_path = null,
398 },
399 };
400 }
401
402 fn encodeSample(alloc: std.mem.Allocator) ![]u8 {
403 var out: std.ArrayList(u8) = .empty;
404 errdefer out.deinit(alloc);
405 const ss = sampleSessions();
406 try writeManifest(out.writer(alloc), alloc, sampleDaemon(), &ss);
407 return out.toOwnedSlice(alloc);
408 }
409
410 test "manifest: a round-trip loses nothing a session needs" {
411 const alloc = std.testing.allocator;
412 const bytes = try encodeSample(alloc);
413 defer alloc.free(bytes);
414
415 var p = try parseManifest(alloc, bytes);
416 defer p.deinit();
417
418 const d = sampleDaemon();
419 try std.testing.expectEqualStrings(d.writer_version, p.daemon.writer_version);
420 try std.testing.expectEqualStrings(d.sock_path, p.daemon.sock_path);
421 try std.testing.expectEqual(d.listener_fd, p.daemon.listener_fd);
422 try std.testing.expectEqualStrings(d.shellint_dir.?, p.daemon.shellint_dir.?);
423 try std.testing.expectEqual(@as(?[]const u8, null), p.daemon.agent_dir);
424 try std.testing.expect(p.daemon.shell_integration);
425 try std.testing.expectEqual(@as(usize, 2), p.daemon.extra_env.len);
426 try std.testing.expectEqualStrings("bar", p.daemon.extra_env[0].value.?);
427 try std.testing.expectEqual(@as(?[]const u8, null), p.daemon.extra_env[1].value);
428 try std.testing.expectEqual(QuicArm.owned, p.daemon.quic.arm);
429 try std.testing.expectEqual(@as(u8, 0xCD), p.daemon.quic.key[31]);
430 try std.testing.expectEqual(@as(u64, 30_000), p.daemon.quic.idle_ms);
431 try std.testing.expectEqual(@as(u64, 5), p.daemon.counters.snapshots);
432 try std.testing.expectEqual(@as(u64, 9), p.daemon.counters.attaches);
433
434 const ss = sampleSessions();
435 try std.testing.expectEqual(@as(usize, 2), p.sessions.len);
436 try std.testing.expectEqualStrings(ss[0].name, p.sessions[0].name);
437 try std.testing.expectEqual(ss[0].child_pid, p.sessions[0].child_pid);
438 try std.testing.expectEqualStrings(ss[0].vt, p.sessions[0].vt);
439 try std.testing.expectEqualStrings(ss[0].title.?, p.sessions[0].title.?);
440 try std.testing.expect(p.sessions[0].cmd.marks_seen);
441 try std.testing.expectEqual(@as(?u8, 0), p.sessions[0].cmd.exit_code);
442 try std.testing.expectEqual(@as(u64, 77), p.sessions[0].last_return.?.seq);
443 try std.testing.expectEqual(ss[0].agent_fd, p.sessions[0].agent_fd);
444 }
445
446 test "manifest: an unknown section tag is skipped by its length, not fatal" {
447 const alloc = std.testing.allocator;
448 const bytes = try encodeSample(alloc);
449 defer alloc.free(bytes);
450
451 // Splice a tag this build has never heard of between header and body.
452 var spliced: std.ArrayList(u8) = .empty;
453 defer spliced.deinit(alloc);
454 try spliced.appendSlice(alloc, bytes[0 .. magic.len + 2]);
455 try spliced.appendSlice(alloc, &.{ 0x7f, 3, 0, 0, 0, 0xAA, 0xBB, 0xCC });
456 try spliced.appendSlice(alloc, bytes[magic.len + 2 ..]);
457
458 var p = try parseManifest(alloc, spliced.items);
459 defer p.deinit();
460 try std.testing.expectEqual(@as(usize, 2), p.sessions.len);
461 }
462
463 test "manifest: a truncated section is BadManifest, not a partial adopt" {
464 const alloc = std.testing.allocator;
465 const bytes = try encodeSample(alloc);
466 defer alloc.free(bytes);
467 try std.testing.expectError(
468 error.BadManifest,
469 parseManifest(alloc, bytes[0 .. bytes.len - 5]),
470 );
471 }
472
473 test "manifest: a wrong magic is BadManifest" {
474 const alloc = std.testing.allocator;
475 const bytes = try encodeSample(alloc);
476 defer alloc.free(bytes);
477 var bad = try alloc.dupe(u8, bytes);
478 defer alloc.free(bad);
479 bad[0] = 'X';
480 try std.testing.expectError(error.BadManifest, parseManifest(alloc, bad));
481 }
482
483 test "manifest: a session with no agent and no title round-trips its absences" {
484 const alloc = std.testing.allocator;
485 const bytes = try encodeSample(alloc);
486 defer alloc.free(bytes);
487 var p = try parseManifest(alloc, bytes);
488 defer p.deinit();
489 try std.testing.expectEqual(@as(?[]const u8, null), p.sessions[1].title);
490 try std.testing.expectEqual(@as(i32, -1), p.sessions[1].agent_fd);
491 try std.testing.expectEqual(@as(?[]const u8, null), p.sessions[1].agent_path);
492 try std.testing.expectEqual(@as(?proto.CmdState, null), p.sessions[1].last_return);
493 }
494
495 test "manifest: a manifest with no daemon section is BadManifest" {
496 const alloc = std.testing.allocator;
497 var out: std.ArrayList(u8) = .empty;
498 defer out.deinit(alloc);
499 try out.appendSlice(alloc, magic);
500 try out.appendSlice(alloc, &.{ 1, 0 });
501 try std.testing.expectError(error.BadManifest, parseManifest(alloc, out.items));
502 }