a73x

3ad4a517

fix: seal the adopted fds after the last rollback, not during adoption

a73x   2026-08-27 13:22

Commit message
fix: seal the adopted fds after the last rollback, not during adoption

Flagging them inside initFromManifest broke the rollback exec: it hands
the old binary the very descriptors adoption took, and a CLOEXEC fd does
not survive that exec — the rolled-back daemon panicked on a manifest
naming fds it no longer had (e2e 14_upgrade). sealAdoptedFds runs from
resumeRun instead, past the last point a rollback can happen, and the
manifest memfd is closed there for the same reason: left open and
CLOEXEC-cleared it was inherited by every shell, one more per upgrade.

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 }