3ad4a517
fix: seal the adopted fds after the last rollback, not during adoption
a73x 2026-08-27 13:22
Commit message
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -526,6 +526,14 @@ fn resumeRun(alloc: std.mem.Allocator, o: Opts, resume_fd: std.posix.fd_t) !u8 { | |||
| 526 | // the demolition list. | 526 | // the demolition list. |
| 527 | if (std.mem.eql(u8, fail_at, "session")) | 527 | if (std.mem.eql(u8, fail_at, "session")) |
| 528 | return rollback(alloc, parsed.daemon.writer_path, resume_fd, "session (--resume-fail-at)"); | 528 | return rollback(alloc, parsed.daemon.writer_path, resume_fd, "session (--resume-fail-at)"); |
| 529 | |||
| 530 | // The last rollback point is behind us, so the descriptors it would have | ||
| 531 | // handed back are this image's to keep — and to stop handing on. Rollback | ||
| 532 | // was the manifest's last reader; left open, it is CLOEXEC-cleared like | ||
| 533 | // the rest and every shell spawned from here inherits it. | ||
| 534 | std.posix.close(resume_fd); | ||
| 535 | srv.sealAdoptedFds(); | ||
| 536 | |||
| 529 | defer srv.deinit(); | 537 | defer srv.deinit(); |
| 530 | 538 | ||
| 531 | // The marker's job ended the moment this image started serving. | 539 | // The marker's job ended the moment this image started serving. |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -3292,9 +3292,33 @@ pub const Server = struct { | |||
| 3292 | _ = try std.posix.fcntl(fd, std.posix.F.SETFD, flags & ~@as(usize, std.posix.FD_CLOEXEC)); | 3292 | _ = try std.posix.fcntl(fd, std.posix.F.SETFD, flags & ~@as(usize, std.posix.FD_CLOEXEC)); |
| 3293 | } | 3293 | } |
| 3294 | 3294 | ||
| 3295 | /// The adopting side of clearCloexec: a flag cleared for the exec that | ||
| 3296 | /// stays cleared is inherited by every shell this daemon spawns next. | ||
| 3297 | pub fn setCloexec(fd: std.posix.fd_t) !void { | ||
| 3298 | const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0); | ||
| 3299 | _ = try std.posix.fcntl(fd, std.posix.F.SETFD, flags | @as(usize, std.posix.FD_CLOEXEC)); | ||
| 3300 | } | ||
| 3301 | |||
| 3295 | fn restoreCloexec(fds: []const std.posix.fd_t) void { | 3302 | fn restoreCloexec(fds: []const std.posix.fd_t) void { |
| 3296 | for (fds) |fd| { | 3303 | for (fds) |fd| setCloexec(fd) catch {}; |
| 3297 | _ = std.posix.fcntl(fd, std.posix.F.SETFD, std.posix.FD_CLOEXEC) catch {}; | 3304 | } |
| 3305 | |||
| 3306 | fn sealFd(fd: std.posix.fd_t) void { | ||
| 3307 | setCloexec(fd) catch |e| | ||
| 3308 | std.debug.print("muxd: seal fd {d}: {t}\n", .{ fd, e }); | ||
| 3309 | } | ||
| 3310 | |||
| 3311 | /// Put the flag back on every fd `execUpgrade` cleared and this image | ||
| 3312 | /// keeps — the manifest memfd is cleared there too, and closed by the | ||
| 3313 | /// caller rather than walked here. Only once adoption is FINAL: | ||
| 3314 | /// `initFromManifest` leaves them clear, a rollback exec carries them. | ||
| 3315 | pub fn sealAdoptedFds(self: *Server) void { | ||
| 3316 | sealFd(self.listener.stream.handle); | ||
| 3317 | if (self.quicListener()) |q| sealFd(q.fd); | ||
| 3318 | for (&self.sessions.table) |*slot| { | ||
| 3319 | const s = slot.* orelse continue; | ||
| 3320 | sealFd(s.pty.master); | ||
| 3321 | if (s.agent_listener != -1) sealFd(s.agent_listener); | ||
| 3298 | } | 3322 | } |
| 3299 | } | 3323 | } |
| 3300 | 3324 | ||
src/server_test_upgrade.zig
| Old | New | ||
|---|---|---|---|
| @@ -320,3 +320,70 @@ test "clearCloexec: a CLOEXEC memfd's flag flips" { | |||
| 320 | const after = try std.posix.fcntl(fd, std.posix.F.GETFD, 0); | 320 | const after = try std.posix.fcntl(fd, std.posix.F.GETFD, 0); |
| 321 | try std.testing.expect(after & std.posix.FD_CLOEXEC == 0); | 321 | try std.testing.expect(after & std.posix.FD_CLOEXEC == 0); |
| 322 | } | 322 | } |
| 323 | |||
| 324 | /// FD_CLOEXEC, read back from the kernel — a daemon reporting on its own | ||
| 325 | /// fd table cannot catch itself being wrong. | ||
| 326 | fn hasCloexec(fd: std.posix.fd_t) !bool { | ||
| 327 | const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0); | ||
| 328 | return flags & std.posix.FD_CLOEXEC != 0; | ||
| 329 | } | ||
| 330 | |||
| 331 | test "sealAdoptedFds: the adopted fds are CLOEXEC again, and not one step before the last rollback" { | ||
| 332 | const alloc = std.testing.allocator; | ||
| 333 | |||
| 334 | var tmp = try TmpDir.make(); | ||
| 335 | defer tmp.cleanup(); | ||
| 336 | const dir_path = tmp.path(); | ||
| 337 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/cloexec.sock", .{dir_path}); | ||
| 338 | defer alloc.free(sock_path); | ||
| 339 | |||
| 340 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" }); | ||
| 341 | const memfd = try std.posix.memfd_create("mux-cloexec-test", 0); | ||
| 342 | defer std.posix.close(memfd); | ||
| 343 | try srv.writeManifestTo(memfd, "0.0.1-99"); | ||
| 344 | |||
| 345 | // What execUpgrade does on the way out: the flag is cleared so the fds | ||
| 346 | // cross the exec. The adopting side must put it back. | ||
| 347 | try Server.clearCloexec(srv.listener.stream.handle); | ||
| 348 | try Server.clearCloexec(srv.sessions.table[0].?.pty.master); | ||
| 349 | if (srv.sessions.table[0].?.agent_listener != -1) | ||
| 350 | try Server.clearCloexec(srv.sessions.table[0].?.agent_listener); | ||
| 351 | |||
| 352 | // Memory only, as in the adopted-session test above: deinit would | ||
| 353 | // demolish exactly what srv2 is about to inherit. | ||
| 354 | { | ||
| 355 | const s = &srv.sessions.table[0].?; | ||
| 356 | s.tracker.deinit(alloc); | ||
| 357 | s.freePending(alloc); | ||
| 358 | if (s.title_sent) |t| alloc.free(t); | ||
| 359 | s.eng.deinit(); | ||
| 360 | if (s.agent_path) |p| alloc.free(p); | ||
| 361 | } | ||
| 362 | if (srv.agents.dir) |d| alloc.free(d); | ||
| 363 | srv.shellint_arena.deinit(); | ||
| 364 | |||
| 365 | var file = std.fs.File{ .handle = memfd }; | ||
| 366 | try file.seekTo(0); | ||
| 367 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); | ||
| 368 | defer alloc.free(buf); | ||
| 369 | var parsed = try upgrade.parseManifest(alloc, buf); | ||
| 370 | defer parsed.deinit(); | ||
| 371 | |||
| 372 | var srv2 = try Server.initFromManifest(alloc, &parsed, "0.0.1-100"); | ||
| 373 | defer srv2.deinit(); | ||
| 374 | const s2 = &srv2.sessions.table[0].?; | ||
| 375 | |||
| 376 | // Adoption alone must NOT set it: `rollback` execs the old binary with | ||
| 377 | // these very descriptors, and a flag set here closes them at that exec. | ||
| 378 | // Flagged in initFromManifest, this test was green and the rolled-back | ||
| 379 | // daemon panicked adopting a manifest naming fds it no longer had. | ||
| 380 | try std.testing.expect(!try hasCloexec(srv2.listener.stream.handle)); | ||
| 381 | try std.testing.expect(!try hasCloexec(s2.pty.master)); | ||
| 382 | if (s2.agent_listener != -1) try std.testing.expect(!try hasCloexec(s2.agent_listener)); | ||
| 383 | |||
| 384 | srv2.sealAdoptedFds(); | ||
| 385 | |||
| 386 | try std.testing.expect(try hasCloexec(srv2.listener.stream.handle)); | ||
| 387 | try std.testing.expect(try hasCloexec(s2.pty.master)); | ||
| 388 | if (s2.agent_listener != -1) try std.testing.expect(try hasCloexec(s2.agent_listener)); | ||
| 389 | } | ||