66faa2ff
feat: the upgrade manifest — sections a stranger can skip
a73x 2026-08-26 15:01
Commit message
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 | } | ||