a73x

f1d041f3

refactor: separate daemon upgrade orchestration

a73x   2026-09-06 09:23

Commit message
refactor: separate daemon upgrade orchestration

src/server/server.zig
Old New
@@ -22,16 +22,14 @@ const server_os = @import("server_os");
22 pub const quic_server = @import("quic_server.zig"); 22 pub const quic_server = @import("quic_server.zig");
23 pub const upgrade = @import("upgrade.zig"); 23 pub const upgrade = @import("upgrade.zig");
24 const proxy = @import("proxy"); 24 const proxy = @import("proxy");
25 const TmpDir = @import("testtmp").TmpDir;
26 // The agent relay is a sub-file of this module, not a row of its own: it is 25 // The agent relay is a sub-file of this module, not a row of its own: it is
27 // one cluster of Server's state, and a module row would make it a seam 26 // one cluster of Server's state, and a module row would make it a seam
28 // anything in the build could name. 27 // anything in the build could name.
29 const agent_mod = @import("server_agent.zig"); 28 const agent_mod = @import("server_agent.zig");
29 const upgrade_ops = @import("server_upgrade.zig");
30 const SessionTable = @import("server_sessions.zig").SessionTable; 30 const SessionTable = @import("server_sessions.zig").SessionTable;
31 const AgentRelay = agent_mod.AgentRelay; 31 const AgentRelay = agent_mod.AgentRelay;
32 const AgentSock = agent_mod.AgentSock; 32 const AgentSock = agent_mod.AgentSock;
33 const AgentChan = agent_mod.AgentChan;
34 const AgentCloseKind = agent_mod.AgentCloseKind;
35 pub const max_agent_chans = agent_mod.max_agent_chans; 33 pub const max_agent_chans = agent_mod.max_agent_chans;
36 34
37 /// One slot per ATTACH, not per session: every tile on a wall is its own 35 /// One slot per ATTACH, not per session: every tile on a wall is its own
@@ -80,7 +78,7 @@ pub const SockWatch = struct {
80 /// daemon started on the path) left a log with nothing in it about 78 /// daemon started on the path) left a log with nothing in it about
81 /// either; these lines are the trail that would have dated it 79 /// either; these lines are the trail that would have dated it
82 /// (issue 04b3019d). 80 /// (issue 04b3019d).
83 fn logSocket(path: []const u8, comptime fmt: []const u8, args: anytype) void { 81 pub fn logSocket(path: []const u8, comptime fmt: []const u8, args: anytype) void {
84 std.debug.print("mux d: socket {s}: " ++ fmt ++ "\n", .{path} ++ args); 82 std.debug.print("mux d: socket {s}: " ++ fmt ++ "\n", .{path} ++ args);
85 } 83 }
86 84
@@ -227,7 +225,7 @@ const Sink = union(enum) {
227 225
228 /// THIS CLIENT'S channel, never the transport it shares: one UDP socket 226 /// THIS CLIENT'S channel, never the transport it shares: one UDP socket
229 /// carries every QUIC client on this daemon. 227 /// carries every QUIC client on this daemon.
230 fn close(self: Sink) void { 228 pub fn close(self: Sink) void {
231 switch (self) { 229 switch (self) {
232 .socket => |fd| std.posix.close(fd), 230 .socket => |fd| std.posix.close(fd),
233 .quic => |q| q.listener.closeConn(q.id), 231 .quic => |q| q.listener.closeConn(q.id),
@@ -643,209 +641,7 @@ pub const Server = struct {
643 return srv; 641 return srv;
644 } 642 }
645 643
646 /// Adopt the manifest an exec-ing daemon left in its carrier. 644 pub const initFromManifest = upgrade_ops.initFromManifest;
647 pub fn initFromManifest(
648 alloc: std.mem.Allocator,
649 parsed: *const upgrade.Parsed,
650 version: []const u8,
651 ) !Server {
652 // Before any request can ask: the comparison is against the image
653 // that BOOTED, not the first one asked about. `mux d upgrade` keeps
654 // the pid but execs a new image, which zeroes every global — so this
655 // records the CANDIDATE, and the daemon is graded against the file
656 // it is now running rather than the one it started life as.
657 server_os.noteBootImage();
658
659 // Same pid, same children, same descriptors. No `sockpath.claim`: the
660 // inherited listener fd IS the claim, and claim's probe would find our
661 // own socket answering. `version` is THIS binary's, never the
662 // manifest's — the skew check measures against what is running now.
663 const d = parsed.daemon;
664
665 // Everything the Server keeps a slice of is copied out of the
666 // manifest arena, which dies with the caller's `Parsed` while the
667 // Server outlives it.
668 var shellint_arena = std.heap.ArenaAllocator.init(alloc);
669 errdefer shellint_arena.deinit();
670 const a = shellint_arena.allocator();
671 const sock_path = try a.dupe(u8, d.sock_path);
672 const shell_z = try a.dupeZ(u8, d.shell);
673 const extra_env = try a.alloc(Pty.EnvPair, d.extra_env.len);
674 for (d.extra_env, extra_env) |src, *dst| dst.* = .{
675 .key = try a.dupeZ(u8, src.key),
676 .value = if (src.value) |v| try a.dupeZ(u8, v) else null,
677 };
678
679 // The shim directory crossed as a path: live shells hold it in
680 // ZDOTDIR and it is pid-named, so a fresh one would orphan the old.
681 // Rewritten with THIS binary's scripts under the name shells hold —
682 // `shellint.prepare` creates exclusively, since adopting is an attack.
683 const injection: shellint.Injection = if (d.shellint_dir) |src| blk: {
684 const dir = try a.dupe(u8, src);
685 std.fs.cwd().deleteTree(dir) catch {};
686 break :blk shellint.prepare(a, dir, shell_z) catch |err| {
687 std.debug.print(
688 "mux d: resumed without shell integration ({s}: {t}); " ++
689 "sessions spawned from here run without command marks\n",
690 .{ dir, err },
691 );
692 break :blk shellint.no_injection;
693 };
694 } else shellint.no_injection;
695
696 const opts: Options = .{
697 .sock_path = sock_path,
698 .shell = shell_z,
699 .shell_integration = d.shell_integration,
700 .extra_env = extra_env,
701 .version = version,
702 };
703 const plan = try planFrom(a, opts, injection);
704
705 const agent_dir: ?[]const u8 = if (d.agent_dir) |dir| try alloc.dupe(u8, dir) else null;
706 errdefer if (agent_dir) |dir| alloc.free(dir);
707
708 var srv: Server = .{
709 .alloc = alloc,
710 .spawn_plan = plan,
711 .spawn_shell = shell_z,
712 .spawn_shell_integration = d.shell_integration,
713 .spawn_extra_env = extra_env,
714 .version = version,
715 // The fd is the listener: no bind, no listen, no claim. A claim
716 // here would probe the path, find our own inherited listener
717 // answering, and refuse the daemon its own socket. `adopt` only
718 // re-stamps the id from the file as found.
719 .bound = try serve.adopt(d.listener_fd, sock_path),
720 .sock_path = sock_path,
721 .shellint_arena = shellint_arena,
722 .shellint_dir = injection.dir,
723 .agents = .{ .dir = agent_dir },
724 };
725 logSocket(sock_path, "adopted across upgrade dev={d} ino={d}", .{ srv.bound.path_id.dev, srv.bound.path_id.ino });
726
727 // Cumulative, so an upgrade is not mistaken for a restart by
728 // anything sampling `mux d stats`. Saturating rather than @intCast
729 // below: a manifest is bytes, and a corrupt counter must not panic a
730 // daemon that is otherwise able to serve.
731 srv.stats = d.counters;
732 srv.agents.refused_no_offer =
733 std.math.cast(u32, d.counters.agent_refused_no_offer) orelse std.math.maxInt(u32);
734 srv.agents.refused_full =
735 std.math.cast(u32, d.counters.agent_refused_full) orelse std.math.maxInt(u32);
736
737 // Frees what this constructor allocated and NOTHING the manifest
738 // handed over: no descriptor is closed and no child is signalled on
739 // a failed adoption, because the rollback exec is about to hand all
740 // of them to the old binary.
741 errdefer for (&srv.sessions.table) |*slot| {
742 if (slot.*) |*s| {
743 s.eng.deinit();
744 if (s.agentPath()) |p| alloc.free(p);
745 slot.* = null;
746 }
747 };
748
749 for (parsed.sessions, 0..) |rec, i| {
750 // A manifest from a binary with a bigger table would otherwise
751 // index past ours; the sessions that fit are still served.
752 if (i >= max_sessions) break;
753 const eng = try Engine.init(alloc, .{
754 .cols = rec.cols,
755 .rows = rec.rows,
756 .clipboard_max = proto.clipboard_base64_max,
757 });
758 // Into the slot before anything else can fail, so the errdefer
759 // above owns the engine from here on.
760 srv.sessions.table[i] = .{
761 .eng = eng,
762 .pty = Pty.adopt(rec.pty_fd, rec.child_pid),
763 .epoch = SessionTable.freshEpoch(),
764 };
765 const s = &srv.sessions.table[i].?;
766 eng.feed(rec.vt);
767 // The title goes back in as an OSC 0 so the ENGINE owns it
768 // again: `sampleTermTitle` then announces it to the first client
769 // that attaches, with no resume-shaped exception anywhere
770 // downstream. title_sent stays null — nobody has been told.
771 if (rec.title) |t| {
772 const osc = try std.fmt.allocPrint(alloc, "\x1b]0;{s}\x07", .{t});
773 defer alloc.free(osc);
774 eng.feed(osc);
775 }
776 s.cmd = .{
777 .phase = std.meta.intToEnum(proto.CmdPhase, rec.cmd.phase) catch .at_prompt,
778 .marks_seen = rec.cmd.marks_seen,
779 .start_row = rec.cmd.start_row,
780 .end_row = rec.cmd.end_row,
781 .exit_code = rec.cmd.exit_code,
782 };
783 // The verdict crosses; its watermark cannot. The delta tracker is
784 // rebuilt from zero here, so an old-space seq is a watermark from
785 // the future that no later return can exceed.
786 s.last_return = if (rec.last_return) |lr| blk: {
787 var restamped = lr;
788 restamped.seq = s.tracker.seq;
789 break :blk restamped;
790 } else null;
791 if (rec.agent_path) |p| {
792 const dup = try alloc.dupeZ(u8, p);
793 // `adopt`, not `bind`: the fd crossed the exec already. The
794 // id is re-stamped from the file as found, which is the
795 // manifest rule — a watermark belongs to the space that
796 // minted it, and the pre-exec id was stamped in another.
797 //
798 // Re-stamping needs the FILE, so it fails when something has
799 // deleted the socket out of the runtime dir. That degrades
800 // per session — the same answer `AgentRelay.bindSock` gives a
801 // socket it cannot bind — and never fails the adoption: a
802 // whole daemon refusing to come up would take every shell in
803 // the table with it, and the rollback exec would then hit the
804 // identical missing file with MUX_UPGRADE_ROLLBACK already
805 // set. One session loses agent forwarding instead.
806 if (serve.adopt(rec.agent_fd, dup)) |b| {
807 s.agent_sock = .{ .bound = b, .path = dup };
808 } else |err| {
809 std.debug.print(
810 "mux d: no agent socket for session {s} ({t})\n",
811 .{ SessionTable.safeName(rec.name), err },
812 );
813 alloc.free(dup);
814 // The descriptor is NOT closed here, for the reason the
815 // errdefer above states: nothing the manifest handed over
816 // is released on this path, because a later failure hands
817 // every one of them back to the old binary. It is
818 // recorded instead — no session names it now, so nothing
819 // else could find it again — and `sealAdoptedFds` closes
820 // it once that hand-back window has passed.
821 srv.orphaned_fds[srv.orphaned_n] = rec.agent_fd;
822 srv.orphaned_n += 1;
823 }
824 }
825 const n = @min(rec.name.len, proto.session_name_max);
826 @memcpy(s.name_buf[0..n], rec.name[0..n]);
827 s.name_len = @intCast(n);
828 }
829
830 // The UDP socket crossed bound and the key as bytes, so TLS stands
831 // back up on the same port with the same PSK. Both arms become
832 // `.owned`: whatever held the previous reference went with the image.
833 switch (d.quic.arm) {
834 .none => {},
835 .borrowed, .owned => {
836 const l = try quic_server.Listener.initFromFd(
837 alloc,
838 d.quic.fd,
839 .{ .bytes = d.quic.key },
840 undefined,
841 d.quic.idle_ms,
842 );
843 srv.quic = .{ .owned = l };
844 },
845 }
846
847 return srv;
848 }
849 645
850 /// What `Pty.spawnArgv` has to be handed, once shell integration has 646 /// What `Pty.spawnArgv` has to be handed, once shell integration has
851 /// had its say. Every slice points into the arena `prepareSpawn` was 647 /// had its say. Every slice points into the arena `prepareSpawn` was
@@ -874,7 +670,7 @@ pub const Server = struct {
874 } 670 }
875 671
876 /// The plan for an injection somebody else decided on. 672 /// The plan for an injection somebody else decided on.
877 fn planFrom( 673 pub fn planFrom(
878 a: std.mem.Allocator, 674 a: std.mem.Allocator,
879 opts: Options, 675 opts: Options,
880 injection: shellint.Injection, 676 injection: shellint.Injection,
@@ -2958,354 +2754,12 @@ pub const Server = struct {
2958 return @intCast(self.ses(si).eng.term.cols); 2754 return @intCast(self.ses(si).eng.term.cols);
2959 } 2755 }
2960 2756
2961 // The manifest an exec-ing daemon leaves for its replacement. The 2757 pub const writeManifestTo = upgrade_ops.writeManifestTo;
2962 // writer's version and path are the rollback target's identity. 2758 pub const validateUpgrade = upgrade_ops.validateUpgrade;
2963 pub fn writeManifestTo( 2759 pub const clearCloexec = upgrade_ops.clearCloexec;
2964 self: *Server, 2760 pub const setCloexec = upgrade_ops.setCloexec;
2965 fd: std.posix.fd_t, 2761 pub const sealAdoptedFds = upgrade_ops.sealAdoptedFds;
2966 writer_version: []const u8, 2762 pub const execUpgrade = upgrade_ops.execUpgrade;
2967 ) !void {
2968 const a = self.alloc;
2969
2970 // The rollback target is THIS binary, resolved here rather than
2971 // taken from a caller: the first draft took it as a parameter and
2972 // both call sites handed it the CANDIDATE's path, which would have
2973 // made a failed adoption exec the broken binary again in a loop.
2974 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
2975 const writer_path = try std.fs.selfExePath(&exe_buf);
2976
2977 // QUIC runtime state: arm, fd, bound address, idle, key. The key
2978 // crosses as bytes — no path names the carrier and the file may have
2979 // moved. None means no QUIC at all.
2980 var quic_state: upgrade.QuicState = .{};
2981 switch (self.quic) {
2982 .none => {},
2983 .borrowed, .owned => |l| {
2984 quic_state.arm = if (self.quic == .owned) .owned else .borrowed;
2985 quic_state.fd = l.fd;
2986 quic_state.idle_ms = l.idle_ms;
2987 const addr = l.boundAddr();
2988 const addr_bytes = std.mem.asBytes(&addr.any);
2989 quic_state.addr_len = @intCast(addr_bytes.len);
2990 @memcpy(quic_state.addr[0..addr_bytes.len], addr_bytes);
2991 if (quic_server.Listener.currentKey()) |k| @memcpy(&quic_state.key, &k.bytes);
2992 },
2993 }
2994
2995 // extra_env: upgrade.EnvPair is { []const u8, ?[]const u8 } while
2996 // Pty.EnvPair is { [:0]const u8, ?[:0]const u8 }; the manifest's
2997 // shape is what crosses, so the mapping lives here.
2998 const extra_env = try a.alloc(upgrade.EnvPair, self.spawn_extra_env.len);
2999 defer a.free(extra_env);
3000 for (self.spawn_extra_env, extra_env) |src, *dst| {
3001 dst.* = .{ .key = src.key, .value = if (src.value) |v| v else null };
3002 }
3003
3004 // The relay hands over its counters as one value; nothing here reads
3005 // its fields, so the manifest cannot drift when the table's shape does.
3006 const ac = self.agents.counters();
3007 // AgentRelay owns the two refusal counters; `self.stats` only ever
3008 // carried them, so they are stamped from the relay on the way out
3009 // and whatever adoption left in the carrier is overwritten here.
3010 var counters = self.stats;
3011 counters.agent_refused_no_offer = ac.refused_no_offer;
3012 counters.agent_refused_full = ac.refused_full;
3013 const daemon: upgrade.Daemon = .{
3014 .writer_version = writer_version,
3015 .writer_path = writer_path,
3016 .sock_path = self.sock_path,
3017 .listener_fd = self.bound.fd,
3018 .shellint_dir = self.shellint_dir,
3019 .agent_dir = self.agents.dir,
3020 .shell = self.spawn_shell,
3021 .shell_integration = self.spawn_shell_integration,
3022 .extra_env = extra_env,
3023 .quic = quic_state,
3024 .counters = counters,
3025 };
3026
3027 // One SessionRec per live session. dumpState is viewport-only by
3028 // construction; the title is carried apart because dumpState has
3029 // no title field.
3030 var recs: std.ArrayList(upgrade.SessionRec) = .empty;
3031 defer recs.deinit(a);
3032 for (&self.sessions.table) |*slot| {
3033 // BY POINTER: `slot.* orelse continue` copies the Session, and
3034 // `name()` slices that copy's `name_buf`, which dies with the
3035 // iteration — every record would dangle into one reused stack slot.
3036 if (slot.* == null) continue;
3037 const s = &slot.*.?;
3038 const vt = try s.eng.dumpState(a);
3039 const title_bytes = s.eng.title();
3040 const title: ?[]const u8 = if (title_bytes.len > 0) title_bytes else null;
3041 try recs.append(a, .{
3042 .name = s.name(),
3043 .pty_fd = s.pty.master,
3044 .child_pid = s.pty.child,
3045 .cols = @intCast(s.eng.term.cols),
3046 .rows = @intCast(s.eng.term.rows),
3047 .vt = vt,
3048 .title = title,
3049 .cmd = .{
3050 .phase = @intFromEnum(s.cmd.phase),
3051 .marks_seen = s.cmd.marks_seen,
3052 .start_row = s.cmd.start_row,
3053 .end_row = s.cmd.end_row,
3054 .exit_code = s.cmd.exit_code,
3055 },
3056 .last_return = s.last_return,
3057 .agent_fd = s.agentFd(),
3058 .agent_path = s.agentPath(),
3059 });
3060 }
3061
3062 // Write to a buffer first, then to the fd: the manifest writer's
3063 // anytype contract is an ArrayList writer (as the upgrade module's
3064 // own tests use), and serializing to memory avoids coupling the
3065 // manifest format to a particular fd writer's interface.
3066 var manifest_buf: std.ArrayList(u8) = .empty;
3067 defer manifest_buf.deinit(a);
3068 try upgrade.writeManifest(manifest_buf.writer(a), a, daemon, recs.items);
3069 var file = std.fs.File{ .handle = fd };
3070 try file.seekTo(0);
3071 try file.writeAll(manifest_buf.items);
3072
3073 // dumpState allocated each iteration; freed now that the manifest
3074 // carries a copy.
3075 for (recs.items) |r| a.free(r.vt);
3076 }
3077
3078 // A refusal reason, or null to accept. Ordered cheapest refusal first,
3079 // and each reason distinct so the requester can tell what failed.
3080 pub fn validateUpgrade(self: *Server, req: proto.UpgradeReq, my_version: []const u8) ?[]const u8 {
3081 const a = self.alloc;
3082 // 0. No session mid-hangup: `endSession` already closed that master,
3083 // and the exec's `clearCloexec` is `unreachable` on -1, not an error.
3084 // Before the child-run checks, which cost two spawns.
3085 for (self.sessions.table) |slot| {
3086 if (slot) |session| {
3087 if (session.pty.master < 0) return a.dupe(u8, "session ending, retry") catch null;
3088 }
3089 }
3090
3091 // 1. Version: strictly newer, or equal under allow_same_version.
3092 // The reason names BOTH versions so the operator can see the skew.
3093 const newer = upgrade.strictlyNewer(req.version, my_version) catch
3094 return a.dupe(u8, "version: unparseable version string") catch null;
3095 if (!newer) {
3096 if (req.allow_same_version and std.mem.eql(u8, req.version, my_version)) {
3097 // Equal under the flag: allowed.
3098 } else {
3099 return std.fmt.allocPrint(a, "version: {s} is not newer than {s}", .{ req.version, my_version }) catch null;
3100 }
3101 }
3102
3103 // 2. Path: absolute and executable. A relative path would be
3104 // resolved against the daemon's cwd, not the requester's.
3105 if (req.path.len == 0 or req.path[0] != '/')
3106 return a.dupe(u8, "path: not absolute") catch null;
3107 std.posix.access(req.path, std.posix.X_OK) catch
3108 return a.dupe(u8, "path: not executable") catch null;
3109
3110 // 3–4 are child-run checks; --check does not exist until chunk D,
3111 // so they are structured here but the e2e legs drive them then.
3112 if (self.checkVersionOutput(req.path, req.version)) |reason| return reason;
3113 if (self.checkManifestResume(req.path, my_version)) |reason| return reason;
3114
3115 return null;
3116 }
3117
3118 // Child-run `path --version` must print exactly `mux <version>\n`.
3119 // A daemon of v0.0.1-15 or older expects `muxd <version>` here and so
3120 // refuses this binary as a candidate; `mux d upgrade` says what to do
3121 // about it when the refusal comes back.
3122 fn checkVersionOutput(self: *Server, path: []const u8, version: []const u8) ?[]const u8 {
3123 const a = self.alloc;
3124 const result = std.process.Child.run(.{
3125 .allocator = a,
3126 .argv = &.{ path, "--version" },
3127 .max_output_bytes = 256,
3128 }) catch return a.dupe(u8, "version: cannot spawn candidate") catch null;
3129 defer a.free(result.stdout);
3130 defer a.free(result.stderr);
3131 if (result.term != .Exited or result.term.Exited != 0)
3132 return a.dupe(u8, "version: nonzero exit") catch null;
3133 const expected = std.fmt.allocPrint(a, "mux {s}\n", .{version}) catch
3134 return a.dupe(u8, "version: oom") catch null;
3135 defer a.free(expected);
3136 if (!std.mem.eql(u8, result.stdout, expected))
3137 return a.dupe(u8, "version: output mismatch") catch null;
3138 return null;
3139 }
3140
3141 // Child-run `path run --resume-fd N --check` must exit 0. The carrier
3142 // is written fresh (not CLOEXEC — children must inherit it). --check
3143 // does not exist until chunk D; this helper is the structure the e2e
3144 // legs will drive.
3145 fn checkManifestResume(self: *Server, path: []const u8, my_version: []const u8) ?[]const u8 {
3146 const a = self.alloc;
3147 const carrier = server_os.anonFd("mux-upgrade") catch
3148 return a.dupe(u8, "check: cannot create the manifest carrier") catch null;
3149 defer std.posix.close(carrier);
3150
3151 self.writeManifestTo(carrier, my_version) catch
3152 return a.dupe(u8, "check: cannot write manifest") catch null;
3153
3154 var fdbuf: [12]u8 = undefined;
3155 const fd_str = std.fmt.bufPrint(&fdbuf, "{d}", .{carrier}) catch
3156 return a.dupe(u8, "check: oom") catch null;
3157 const result = std.process.Child.run(.{
3158 .allocator = a,
3159 .argv = &.{ path, "d", "start", "--resume-fd", fd_str, "--check" },
3160 .max_output_bytes = 4096,
3161 }) catch return a.dupe(u8, "check: cannot spawn candidate") catch null;
3162 defer a.free(result.stdout);
3163 defer a.free(result.stderr);
3164 if (result.term != .Exited or result.term.Exited != 0)
3165 return a.dupe(u8, "check: nonzero exit") catch null;
3166 return null;
3167 }
3168
3169 // Clear FD_CLOEXEC on a descriptor so it survives execve. The upgrade
3170 // exec keeps the listener, QUIC UDP, pty masters, agent listeners and
3171 // the manifest carrier. Most of those are CLOEXEC by default; the
3172 // carrier is not (`server_os.anonFd` never sets the flag) and is on the
3173 // list because `restoreCloexec` re-seals every fd here after a failed
3174 // exec, so a carrier missing from it would stay inheritable by the
3175 // shells this daemon spawns next.
3176 pub fn clearCloexec(fd: std.posix.fd_t) !void {
3177 const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0);
3178 _ = try std.posix.fcntl(fd, std.posix.F.SETFD, flags & ~@as(usize, std.posix.FD_CLOEXEC));
3179 }
3180
3181 /// The adopting side of clearCloexec: a flag cleared for the exec that
3182 /// stays cleared is inherited by every shell this daemon spawns next.
3183 pub fn setCloexec(fd: std.posix.fd_t) !void {
3184 const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0);
3185 _ = try std.posix.fcntl(fd, std.posix.F.SETFD, flags | @as(usize, std.posix.FD_CLOEXEC));
3186 }
3187
3188 fn restoreCloexec(fds: []const std.posix.fd_t) void {
3189 for (fds) |fd| setCloexec(fd) catch {};
3190 }
3191
3192 fn sealFd(fd: std.posix.fd_t) void {
3193 setCloexec(fd) catch |e|
3194 std.debug.print("mux d: seal fd {d}: {t}\n", .{ fd, e });
3195 }
3196
3197 /// The flag back on every fd `execUpgrade` cleared; the manifest carrier
3198 /// it also cleared is the caller's to close. The caller runs this only
3199 /// once the last rollback point is behind it, which is why the fds no
3200 /// session adopted are CLOSED here rather than sealed: before this point
3201 /// they still belong to the binary a rollback would exec, and after it
3202 /// nothing will ever name them again. Clearing the count keeps a second
3203 /// call from double-closing.
3204 pub fn sealAdoptedFds(self: *Server) void {
3205 sealFd(self.bound.fd);
3206 if (self.quicListener()) |q| sealFd(q.fd);
3207 for (&self.sessions.table) |*slot| {
3208 if (slot.*) |*s| {
3209 sealFd(s.pty.master);
3210 if (s.agentFd() != -1) sealFd(s.agentFd());
3211 }
3212 }
3213 for (self.orphaned_fds[0..self.orphaned_n]) |fd| std.posix.close(fd);
3214 self.orphaned_n = 0;
3215 }
3216
3217 // The exec itself. NOTHING from `Server.deinit` runs here: the process
3218 // that owns the socket path, the shim dir and the agent dir never exits,
3219 // it becomes the new binary. Every sink is bare-closed, NEVER sent
3220 // `exit_status` — that is a dying shell's word and makes clients exit
3221 // instead of redial. A failed exec must leave a working daemon.
3222 fn execUpgrade(self: *Server, path: []const u8, carrier: std.posix.fd_t) void {
3223 const a = self.alloc;
3224
3225 // Every return below is a FAILED exec, so the arming is spent either
3226 // way. A `pending_upgrade` left set would put the run loop into an
3227 // exec on a loop.
3228 defer {
3229 a.free(path);
3230 std.posix.close(carrier);
3231 self.pending_upgrade = null;
3232 }
3233
3234 // Collect the fds to clear CLOEXEC on, so they can be restored on
3235 // failure. The listener, QUIC UDP (if any), every pty master, every
3236 // agent listener, and the manifest carrier.
3237 var cleared: std.ArrayList(std.posix.fd_t) = .empty;
3238 defer cleared.deinit(a);
3239 // After the list's own defer, so it runs before it: one restore
3240 // covering every return keeps a half-cleared table from surviving a
3241 // failure that took an early exit.
3242 defer restoreCloexec(cleared.items);
3243
3244 const listener_fd = self.bound.fd;
3245 clearCloexec(listener_fd) catch return;
3246 cleared.append(a, listener_fd) catch return;
3247
3248 if (self.quicListener()) |q| {
3249 clearCloexec(q.fd) catch return;
3250 q.closeAll();
3251 cleared.append(a, q.fd) catch return;
3252 }
3253
3254 for (&self.sessions.table) |*slot| {
3255 const s = slot.* orelse continue;
3256 clearCloexec(s.pty.master) catch return;
3257 cleared.append(a, s.pty.master) catch return;
3258 if (s.agentFd() != -1) {
3259 clearCloexec(s.agentFd()) catch return;
3260 cleared.append(a, s.agentFd()) catch return;
3261 }
3262 }
3263
3264 clearCloexec(carrier) catch return;
3265 cleared.append(a, carrier) catch return;
3266
3267 // Bare-close every client sink and observer. NOT exit_status: that
3268 // tells a client its shell died, and it exits instead of redialing.
3269 for (&self.clients) |*slot| {
3270 if (slot.*) |*c| {
3271 c.pending.deinit(a);
3272 c.inbound.deinit(a);
3273 c.sink.close();
3274 slot.* = null;
3275 }
3276 }
3277 for (&self.observers) |*slot| {
3278 if (slot.*) |*o| {
3279 o.inbound.deinit(a);
3280 std.posix.close(o.fd);
3281 }
3282 }
3283 @memset(&self.observers, null);
3284
3285 // Build argv: {"mux", "d", "start", "--resume-fd", "<n>"}. The
3286 // candidate is by definition NEWER than this binary, so it reads the
3287 // mode word.
3288 var fd_buf: [12]u8 = undefined;
3289 const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{carrier}) catch return;
3290 const argv = [_:null]?[*:0]const u8{
3291 "mux",
3292 "d",
3293 "start",
3294 "--resume-fd",
3295 fd_str.ptr,
3296 };
3297
3298 // path is []const u8; execveZ needs [*:0]const u8.
3299 const path_z = a.dupeZ(u8, path) catch return;
3300 defer a.free(path_z);
3301
3302 // execveZ returns a plain error set (not an error union): on success
3303 // it never returns, so any return is a failure.
3304 const exec_err = std.posix.execveZ(path_z.ptr, &argv, std.c.environ);
3305 std.debug.print("mux d: upgrade exec failed: {s}\n", .{@errorName(exec_err)});
3306 // execveZ only returns on failure (caught above); on success we
3307 // never reach here.
3308 }
3309 2763
3310 pub const stats_main_fmt = 2764 pub const stats_main_fmt =
3311 "snapshots={d} snapshot_bytes={d} deltas={d} delta_bytes={d}" ++ 2765 "snapshots={d} snapshot_bytes={d} deltas={d} delta_bytes={d}" ++
src/server/server_upgrade.zig
Old New
@@ -0,0 +1,568 @@
1 //! Live daemon upgrade orchestration: adopt and write manifests, validate a
2 //! candidate, and preserve descriptors across exec and rollback. The wire
3 //! format lives in upgrade.zig; Server retains the public entry points.
4 const std = @import("std");
5 const srv_mod = @import("server.zig");
6 const Server = srv_mod.Server;
7 const Options = Server.Options;
8 const max_sessions = srv_mod.max_sessions;
9 const SessionTable = @import("server_sessions.zig").SessionTable;
10 const Engine = @import("engine").Engine;
11 const Pty = @import("pty").Pty;
12 const proto = @import("term").protocol;
13 const shellint = @import("shellint.zig");
14 const serve = @import("serve");
15 const server_os = @import("server_os");
16 const quic_server = @import("quic_server.zig");
17 const upgrade = @import("upgrade.zig");
18
19 /// Adopt the manifest an exec-ing daemon left in its carrier.
20 pub fn initFromManifest(
21 alloc: std.mem.Allocator,
22 parsed: *const upgrade.Parsed,
23 version: []const u8,
24 ) !Server {
25 // Before any request can ask: the comparison is against the image
26 // that BOOTED, not the first one asked about. `mux d upgrade` keeps
27 // the pid but execs a new image, which zeroes every global — so this
28 // records the CANDIDATE, and the daemon is graded against the file
29 // it is now running rather than the one it started life as.
30 server_os.noteBootImage();
31
32 // Same pid, same children, same descriptors. No `sockpath.claim`: the
33 // inherited listener fd IS the claim, and claim's probe would find our
34 // own socket answering. `version` is THIS binary's, never the
35 // manifest's — the skew check measures against what is running now.
36 const d = parsed.daemon;
37
38 // Everything the Server keeps a slice of is copied out of the
39 // manifest arena, which dies with the caller's `Parsed` while the
40 // Server outlives it.
41 var shellint_arena = std.heap.ArenaAllocator.init(alloc);
42 errdefer shellint_arena.deinit();
43 const a = shellint_arena.allocator();
44 const sock_path = try a.dupe(u8, d.sock_path);
45 const shell_z = try a.dupeZ(u8, d.shell);
46 const extra_env = try a.alloc(Pty.EnvPair, d.extra_env.len);
47 for (d.extra_env, extra_env) |src, *dst| dst.* = .{
48 .key = try a.dupeZ(u8, src.key),
49 .value = if (src.value) |v| try a.dupeZ(u8, v) else null,
50 };
51
52 // The shim directory crossed as a path: live shells hold it in
53 // ZDOTDIR and it is pid-named, so a fresh one would orphan the old.
54 // Rewritten with THIS binary's scripts under the name shells hold —
55 // `shellint.prepare` creates exclusively, since adopting is an attack.
56 const injection: shellint.Injection = if (d.shellint_dir) |src| blk: {
57 const dir = try a.dupe(u8, src);
58 std.fs.cwd().deleteTree(dir) catch {};
59 break :blk shellint.prepare(a, dir, shell_z) catch |err| {
60 std.debug.print(
61 "mux d: resumed without shell integration ({s}: {t}); " ++
62 "sessions spawned from here run without command marks\n",
63 .{ dir, err },
64 );
65 break :blk shellint.no_injection;
66 };
67 } else shellint.no_injection;
68
69 const opts: Options = .{
70 .sock_path = sock_path,
71 .shell = shell_z,
72 .shell_integration = d.shell_integration,
73 .extra_env = extra_env,
74 .version = version,
75 };
76 const plan = try Server.planFrom(a, opts, injection);
77
78 const agent_dir: ?[]const u8 = if (d.agent_dir) |dir| try alloc.dupe(u8, dir) else null;
79 errdefer if (agent_dir) |dir| alloc.free(dir);
80
81 var srv: Server = .{
82 .alloc = alloc,
83 .spawn_plan = plan,
84 .spawn_shell = shell_z,
85 .spawn_shell_integration = d.shell_integration,
86 .spawn_extra_env = extra_env,
87 .version = version,
88 // The fd is the listener: no bind, no listen, no claim. A claim
89 // here would probe the path, find our own inherited listener
90 // answering, and refuse the daemon its own socket. `adopt` only
91 // re-stamps the id from the file as found.
92 .bound = try serve.adopt(d.listener_fd, sock_path),
93 .sock_path = sock_path,
94 .shellint_arena = shellint_arena,
95 .shellint_dir = injection.dir,
96 .agents = .{ .dir = agent_dir },
97 };
98 srv_mod.logSocket(sock_path, "adopted across upgrade dev={d} ino={d}", .{ srv.bound.path_id.dev, srv.bound.path_id.ino });
99
100 // Cumulative, so an upgrade is not mistaken for a restart by
101 // anything sampling `mux d stats`. Saturating rather than @intCast
102 // below: a manifest is bytes, and a corrupt counter must not panic a
103 // daemon that is otherwise able to serve.
104 srv.stats = d.counters;
105 srv.agents.refused_no_offer =
106 std.math.cast(u32, d.counters.agent_refused_no_offer) orelse std.math.maxInt(u32);
107 srv.agents.refused_full =
108 std.math.cast(u32, d.counters.agent_refused_full) orelse std.math.maxInt(u32);
109
110 // Frees what this constructor allocated and NOTHING the manifest
111 // handed over: no descriptor is closed and no child is signalled on
112 // a failed adoption, because the rollback exec is about to hand all
113 // of them to the old binary.
114 errdefer for (&srv.sessions.table) |*slot| {
115 if (slot.*) |*s| {
116 s.eng.deinit();
117 if (s.agentPath()) |p| alloc.free(p);
118 slot.* = null;
119 }
120 };
121
122 for (parsed.sessions, 0..) |rec, i| {
123 // A manifest from a binary with a bigger table would otherwise
124 // index past ours; the sessions that fit are still served.
125 if (i >= max_sessions) break;
126 const eng = try Engine.init(alloc, .{
127 .cols = rec.cols,
128 .rows = rec.rows,
129 .clipboard_max = proto.clipboard_base64_max,
130 });
131 // Into the slot before anything else can fail, so the errdefer
132 // above owns the engine from here on.
133 srv.sessions.table[i] = .{
134 .eng = eng,
135 .pty = Pty.adopt(rec.pty_fd, rec.child_pid),
136 .epoch = SessionTable.freshEpoch(),
137 };
138 const s = &srv.sessions.table[i].?;
139 eng.feed(rec.vt);
140 // The title goes back in as an OSC 0 so the ENGINE owns it
141 // again: `sampleTermTitle` then announces it to the first client
142 // that attaches, with no resume-shaped exception anywhere
143 // downstream. title_sent stays null — nobody has been told.
144 if (rec.title) |t| {
145 const osc = try std.fmt.allocPrint(alloc, "\x1b]0;{s}\x07", .{t});
146 defer alloc.free(osc);
147 eng.feed(osc);
148 }
149 s.cmd = .{
150 .phase = std.meta.intToEnum(proto.CmdPhase, rec.cmd.phase) catch .at_prompt,
151 .marks_seen = rec.cmd.marks_seen,
152 .start_row = rec.cmd.start_row,
153 .end_row = rec.cmd.end_row,
154 .exit_code = rec.cmd.exit_code,
155 };
156 // The verdict crosses; its watermark cannot. The delta tracker is
157 // rebuilt from zero here, so an old-space seq is a watermark from
158 // the future that no later return can exceed.
159 s.last_return = if (rec.last_return) |lr| blk: {
160 var restamped = lr;
161 restamped.seq = s.tracker.seq;
162 break :blk restamped;
163 } else null;
164 if (rec.agent_path) |p| {
165 const dup = try alloc.dupeZ(u8, p);
166 // `adopt`, not `bind`: the fd crossed the exec already. The
167 // id is re-stamped from the file as found, which is the
168 // manifest rule — a watermark belongs to the space that
169 // minted it, and the pre-exec id was stamped in another.
170 //
171 // Re-stamping needs the FILE, so it fails when something has
172 // deleted the socket out of the runtime dir. That degrades
173 // per session — the same answer `AgentRelay.bindSock` gives a
174 // socket it cannot bind — and never fails the adoption: a
175 // whole daemon refusing to come up would take every shell in
176 // the table with it, and the rollback exec would then hit the
177 // identical missing file with MUX_UPGRADE_ROLLBACK already
178 // set. One session loses agent forwarding instead.
179 if (serve.adopt(rec.agent_fd, dup)) |b| {
180 s.agent_sock = .{ .bound = b, .path = dup };
181 } else |err| {
182 std.debug.print(
183 "mux d: no agent socket for session {s} ({t})\n",
184 .{ SessionTable.safeName(rec.name), err },
185 );
186 alloc.free(dup);
187 // The descriptor is NOT closed here, for the reason the
188 // errdefer above states: nothing the manifest handed over
189 // is released on this path, because a later failure hands
190 // every one of them back to the old binary. It is
191 // recorded instead — no session names it now, so nothing
192 // else could find it again — and `sealAdoptedFds` closes
193 // it once that hand-back window has passed.
194 srv.orphaned_fds[srv.orphaned_n] = rec.agent_fd;
195 srv.orphaned_n += 1;
196 }
197 }
198 const n = @min(rec.name.len, proto.session_name_max);
199 @memcpy(s.name_buf[0..n], rec.name[0..n]);
200 s.name_len = @intCast(n);
201 }
202
203 // The UDP socket crossed bound and the key as bytes, so TLS stands
204 // back up on the same port with the same PSK. Both arms become
205 // `.owned`: whatever held the previous reference went with the image.
206 switch (d.quic.arm) {
207 .none => {},
208 .borrowed, .owned => {
209 const l = try quic_server.Listener.initFromFd(
210 alloc,
211 d.quic.fd,
212 .{ .bytes = d.quic.key },
213 undefined,
214 d.quic.idle_ms,
215 );
216 srv.quic = .{ .owned = l };
217 },
218 }
219
220 return srv;
221 }
222
223 // The manifest an exec-ing daemon leaves for its replacement. The
224 // writer's version and path are the rollback target's identity.
225 pub fn writeManifestTo(
226 self: *Server,
227 fd: std.posix.fd_t,
228 writer_version: []const u8,
229 ) !void {
230 const a = self.alloc;
231
232 // The rollback target is THIS binary, resolved here rather than
233 // taken from a caller: the first draft took it as a parameter and
234 // both call sites handed it the CANDIDATE's path, which would have
235 // made a failed adoption exec the broken binary again in a loop.
236 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
237 const writer_path = try std.fs.selfExePath(&exe_buf);
238
239 // QUIC runtime state: arm, fd, bound address, idle, key. The key
240 // crosses as bytes — no path names the carrier and the file may have
241 // moved. None means no QUIC at all.
242 var quic_state: upgrade.QuicState = .{};
243 switch (self.quic) {
244 .none => {},
245 .borrowed, .owned => |l| {
246 quic_state.arm = if (self.quic == .owned) .owned else .borrowed;
247 quic_state.fd = l.fd;
248 quic_state.idle_ms = l.idle_ms;
249 const addr = l.boundAddr();
250 const addr_bytes = std.mem.asBytes(&addr.any);
251 quic_state.addr_len = @intCast(addr_bytes.len);
252 @memcpy(quic_state.addr[0..addr_bytes.len], addr_bytes);
253 if (quic_server.Listener.currentKey()) |k| @memcpy(&quic_state.key, &k.bytes);
254 },
255 }
256
257 // extra_env: upgrade.EnvPair is { []const u8, ?[]const u8 } while
258 // Pty.EnvPair is { [:0]const u8, ?[:0]const u8 }; the manifest's
259 // shape is what crosses, so the mapping lives here.
260 const extra_env = try a.alloc(upgrade.EnvPair, self.spawn_extra_env.len);
261 defer a.free(extra_env);
262 for (self.spawn_extra_env, extra_env) |src, *dst| {
263 dst.* = .{ .key = src.key, .value = if (src.value) |v| v else null };
264 }
265
266 // The relay hands over its counters as one value; nothing here reads
267 // its fields, so the manifest cannot drift when the table's shape does.
268 const ac = self.agents.counters();
269 // AgentRelay owns the two refusal counters; `self.stats` only ever
270 // carried them, so they are stamped from the relay on the way out
271 // and whatever adoption left in the carrier is overwritten here.
272 var counters = self.stats;
273 counters.agent_refused_no_offer = ac.refused_no_offer;
274 counters.agent_refused_full = ac.refused_full;
275 const daemon: upgrade.Daemon = .{
276 .writer_version = writer_version,
277 .writer_path = writer_path,
278 .sock_path = self.sock_path,
279 .listener_fd = self.bound.fd,
280 .shellint_dir = self.shellint_dir,
281 .agent_dir = self.agents.dir,
282 .shell = self.spawn_shell,
283 .shell_integration = self.spawn_shell_integration,
284 .extra_env = extra_env,
285 .quic = quic_state,
286 .counters = counters,
287 };
288
289 // One SessionRec per live session. dumpState is viewport-only by
290 // construction; the title is carried apart because dumpState has
291 // no title field.
292 var recs: std.ArrayList(upgrade.SessionRec) = .empty;
293 defer recs.deinit(a);
294 for (&self.sessions.table) |*slot| {
295 // BY POINTER: `slot.* orelse continue` copies the Session, and
296 // `name()` slices that copy's `name_buf`, which dies with the
297 // iteration — every record would dangle into one reused stack slot.
298 if (slot.* == null) continue;
299 const s = &slot.*.?;
300 const vt = try s.eng.dumpState(a);
301 const title_bytes = s.eng.title();
302 const title: ?[]const u8 = if (title_bytes.len > 0) title_bytes else null;
303 try recs.append(a, .{
304 .name = s.name(),
305 .pty_fd = s.pty.master,
306 .child_pid = s.pty.child,
307 .cols = @intCast(s.eng.term.cols),
308 .rows = @intCast(s.eng.term.rows),
309 .vt = vt,
310 .title = title,
311 .cmd = .{
312 .phase = @intFromEnum(s.cmd.phase),
313 .marks_seen = s.cmd.marks_seen,
314 .start_row = s.cmd.start_row,
315 .end_row = s.cmd.end_row,
316 .exit_code = s.cmd.exit_code,
317 },
318 .last_return = s.last_return,
319 .agent_fd = s.agentFd(),
320 .agent_path = s.agentPath(),
321 });
322 }
323
324 // Write to a buffer first, then to the fd: the manifest writer's
325 // anytype contract is an ArrayList writer (as the upgrade module's
326 // own tests use), and serializing to memory avoids coupling the
327 // manifest format to a particular fd writer's interface.
328 var manifest_buf: std.ArrayList(u8) = .empty;
329 defer manifest_buf.deinit(a);
330 try upgrade.writeManifest(manifest_buf.writer(a), a, daemon, recs.items);
331 var file = std.fs.File{ .handle = fd };
332 try file.seekTo(0);
333 try file.writeAll(manifest_buf.items);
334
335 // dumpState allocated each iteration; freed now that the manifest
336 // carries a copy.
337 for (recs.items) |r| a.free(r.vt);
338 }
339
340 // A refusal reason, or null to accept. Ordered cheapest refusal first,
341 // and each reason distinct so the requester can tell what failed.
342 pub fn validateUpgrade(self: *Server, req: proto.UpgradeReq, my_version: []const u8) ?[]const u8 {
343 const a = self.alloc;
344 // 0. No session mid-hangup: `endSession` already closed that master,
345 // and the exec's `clearCloexec` is `unreachable` on -1, not an error.
346 // Before the child-run checks, which cost two spawns.
347 for (self.sessions.table) |slot| {
348 if (slot) |session| {
349 if (session.pty.master < 0) return a.dupe(u8, "session ending, retry") catch null;
350 }
351 }
352
353 // 1. Version: strictly newer, or equal under allow_same_version.
354 // The reason names BOTH versions so the operator can see the skew.
355 const newer = upgrade.strictlyNewer(req.version, my_version) catch
356 return a.dupe(u8, "version: unparseable version string") catch null;
357 if (!newer) {
358 if (req.allow_same_version and std.mem.eql(u8, req.version, my_version)) {
359 // Equal under the flag: allowed.
360 } else {
361 return std.fmt.allocPrint(a, "version: {s} is not newer than {s}", .{ req.version, my_version }) catch null;
362 }
363 }
364
365 // 2. Path: absolute and executable. A relative path would be
366 // resolved against the daemon's cwd, not the requester's.
367 if (req.path.len == 0 or req.path[0] != '/')
368 return a.dupe(u8, "path: not absolute") catch null;
369 std.posix.access(req.path, std.posix.X_OK) catch
370 return a.dupe(u8, "path: not executable") catch null;
371
372 // 3–4 run the candidate directly: first its advertised version, then
373 // its ability to resume the manifest this daemon would hand over.
374 if (checkVersionOutput(self, req.path, req.version)) |reason| return reason;
375 if (checkManifestResume(self, req.path, my_version)) |reason| return reason;
376
377 return null;
378 }
379
380 // Child-run `path --version` must print exactly `mux <version>\n`.
381 // A daemon of v0.0.1-15 or older expects `muxd <version>` here and so
382 // refuses this binary as a candidate; `mux d upgrade` says what to do
383 // about it when the refusal comes back.
384 fn checkVersionOutput(self: *Server, path: []const u8, version: []const u8) ?[]const u8 {
385 const a = self.alloc;
386 const result = std.process.Child.run(.{
387 .allocator = a,
388 .argv = &.{ path, "--version" },
389 .max_output_bytes = 256,
390 }) catch return a.dupe(u8, "version: cannot spawn candidate") catch null;
391 defer a.free(result.stdout);
392 defer a.free(result.stderr);
393 if (result.term != .Exited or result.term.Exited != 0)
394 return a.dupe(u8, "version: nonzero exit") catch null;
395 const expected = std.fmt.allocPrint(a, "mux {s}\n", .{version}) catch
396 return a.dupe(u8, "version: oom") catch null;
397 defer a.free(expected);
398 if (!std.mem.eql(u8, result.stdout, expected))
399 return a.dupe(u8, "version: output mismatch") catch null;
400 return null;
401 }
402
403 // Child-run `path d start --resume-fd N --check` must exit 0. The carrier
404 // is written fresh (not CLOEXEC — children must inherit it).
405 fn checkManifestResume(self: *Server, path: []const u8, my_version: []const u8) ?[]const u8 {
406 const a = self.alloc;
407 const carrier = server_os.anonFd("mux-upgrade") catch
408 return a.dupe(u8, "check: cannot create the manifest carrier") catch null;
409 defer std.posix.close(carrier);
410
411 self.writeManifestTo(carrier, my_version) catch
412 return a.dupe(u8, "check: cannot write manifest") catch null;
413
414 var fdbuf: [12]u8 = undefined;
415 const fd_str = std.fmt.bufPrint(&fdbuf, "{d}", .{carrier}) catch
416 return a.dupe(u8, "check: oom") catch null;
417 const result = std.process.Child.run(.{
418 .allocator = a,
419 .argv = &.{ path, "d", "start", "--resume-fd", fd_str, "--check" },
420 .max_output_bytes = 4096,
421 }) catch return a.dupe(u8, "check: cannot spawn candidate") catch null;
422 defer a.free(result.stdout);
423 defer a.free(result.stderr);
424 if (result.term != .Exited or result.term.Exited != 0)
425 return a.dupe(u8, "check: nonzero exit") catch null;
426 return null;
427 }
428
429 // Clear FD_CLOEXEC on a descriptor so it survives execve. The upgrade
430 // exec keeps the listener, QUIC UDP, pty masters, agent listeners and
431 // the manifest carrier. Most of those are CLOEXEC by default; the
432 // carrier is not (`server_os.anonFd` never sets the flag) and is on the
433 // list because `restoreCloexec` re-seals every fd here after a failed
434 // exec, so a carrier missing from it would stay inheritable by the
435 // shells this daemon spawns next.
436 pub fn clearCloexec(fd: std.posix.fd_t) !void {
437 const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0);
438 _ = try std.posix.fcntl(fd, std.posix.F.SETFD, flags & ~@as(usize, std.posix.FD_CLOEXEC));
439 }
440
441 /// The adopting side of clearCloexec: a flag cleared for the exec that
442 /// stays cleared is inherited by every shell this daemon spawns next.
443 pub fn setCloexec(fd: std.posix.fd_t) !void {
444 const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0);
445 _ = try std.posix.fcntl(fd, std.posix.F.SETFD, flags | @as(usize, std.posix.FD_CLOEXEC));
446 }
447
448 fn restoreCloexec(fds: []const std.posix.fd_t) void {
449 for (fds) |fd| setCloexec(fd) catch {};
450 }
451
452 fn sealFd(fd: std.posix.fd_t) void {
453 setCloexec(fd) catch |e|
454 std.debug.print("mux d: seal fd {d}: {t}\n", .{ fd, e });
455 }
456
457 /// The flag back on every fd `execUpgrade` cleared; the manifest carrier
458 /// it also cleared is the caller's to close. The caller runs this only
459 /// once the last rollback point is behind it, which is why the fds no
460 /// session adopted are CLOSED here rather than sealed: before this point
461 /// they still belong to the binary a rollback would exec, and after it
462 /// nothing will ever name them again. Clearing the count keeps a second
463 /// call from double-closing.
464 pub fn sealAdoptedFds(self: *Server) void {
465 sealFd(self.bound.fd);
466 if (self.quicListener()) |q| sealFd(q.fd);
467 for (&self.sessions.table) |*slot| {
468 if (slot.*) |*s| {
469 sealFd(s.pty.master);
470 if (s.agentFd() != -1) sealFd(s.agentFd());
471 }
472 }
473 for (self.orphaned_fds[0..self.orphaned_n]) |fd| std.posix.close(fd);
474 self.orphaned_n = 0;
475 }
476
477 // The exec itself. NOTHING from `Server.deinit` runs here: the process
478 // that owns the socket path, the shim dir and the agent dir never exits,
479 // it becomes the new binary. Every sink is bare-closed, NEVER sent
480 // `exit_status` — that is a dying shell's word and makes clients exit
481 // instead of redial. A failed exec must leave a working daemon.
482 pub fn execUpgrade(self: *Server, path: []const u8, carrier: std.posix.fd_t) void {
483 const a = self.alloc;
484
485 // Every return below is a FAILED exec, so the arming is spent either
486 // way. A `pending_upgrade` left set would put the run loop into an
487 // exec on a loop.
488 defer {
489 a.free(path);
490 std.posix.close(carrier);
491 self.pending_upgrade = null;
492 }
493
494 // Collect the fds to clear CLOEXEC on, so they can be restored on
495 // failure. The listener, QUIC UDP (if any), every pty master, every
496 // agent listener, and the manifest carrier.
497 var cleared: std.ArrayList(std.posix.fd_t) = .empty;
498 defer cleared.deinit(a);
499 // After the list's own defer, so it runs before it: one restore
500 // covering every return keeps a half-cleared table from surviving a
501 // failure that took an early exit.
502 defer restoreCloexec(cleared.items);
503
504 const listener_fd = self.bound.fd;
505 clearCloexec(listener_fd) catch return;
506 cleared.append(a, listener_fd) catch return;
507
508 if (self.quicListener()) |q| {
509 clearCloexec(q.fd) catch return;
510 q.closeAll();
511 cleared.append(a, q.fd) catch return;
512 }
513
514 for (&self.sessions.table) |*slot| {
515 const s = slot.* orelse continue;
516 clearCloexec(s.pty.master) catch return;
517 cleared.append(a, s.pty.master) catch return;
518 if (s.agentFd() != -1) {
519 clearCloexec(s.agentFd()) catch return;
520 cleared.append(a, s.agentFd()) catch return;
521 }
522 }
523
524 clearCloexec(carrier) catch return;
525 cleared.append(a, carrier) catch return;
526
527 // Bare-close every client sink and observer. NOT exit_status: that
528 // tells a client its shell died, and it exits instead of redialing.
529 for (&self.clients) |*slot| {
530 if (slot.*) |*c| {
531 c.pending.deinit(a);
532 c.inbound.deinit(a);
533 c.sink.close();
534 slot.* = null;
535 }
536 }
537 for (&self.observers) |*slot| {
538 if (slot.*) |*o| {
539 o.inbound.deinit(a);
540 std.posix.close(o.fd);
541 }
542 }
543 @memset(&self.observers, null);
544
545 // Build argv: {"mux", "d", "start", "--resume-fd", "<n>"}. The
546 // candidate is by definition NEWER than this binary, so it reads the
547 // mode word.
548 var fd_buf: [12]u8 = undefined;
549 const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{carrier}) catch return;
550 const argv = [_:null]?[*:0]const u8{
551 "mux",
552 "d",
553 "start",
554 "--resume-fd",
555 fd_str.ptr,
556 };
557
558 // path is []const u8; execveZ needs [*:0]const u8.
559 const path_z = a.dupeZ(u8, path) catch return;
560 defer a.free(path_z);
561
562 // execveZ returns a plain error set (not an error union): on success
563 // it never returns, so any return is a failure.
564 const exec_err = std.posix.execveZ(path_z.ptr, &argv, std.c.environ);
565 std.debug.print("mux d: upgrade exec failed: {s}\n", .{@errorName(exec_err)});
566 // execveZ only returns on failure (caught above); on success we
567 // never reach here.
568 }