f1d041f3
refactor: separate daemon upgrade orchestration
a73x 2026-09-06 09:23
Commit message
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 | } | ||