4170920f
fix: agent sockets bind through serve and gain the successor guard
a73x 2026-08-31 21:58
Commit message
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -278,14 +278,14 @@ pub const Session = struct { | |||
| 278 | /// snapshotted, never delta-served content it has never seen. Never 0 — | 278 | /// snapshotted, never delta-served content it has never seen. Never 0 — |
| 279 | /// that is a client saying "I hold nothing". | 279 | /// that is a client saying "I hold nothing". |
| 280 | epoch: u64, | 280 | epoch: u64, |
| 281 | /// This session's agent socket, or -1 when the daemon has no agent | 281 | /// This session's agent socket, or null when the daemon has no agent |
| 282 | /// directory. One listener per SESSION: the socket's path IS that | 282 | /// directory. One listener per SESSION: the socket's path IS that |
| 283 | /// session's `SSH_AUTH_SOCK`, and an agent connection says nothing else | 283 | /// session's `SSH_AUTH_SOCK`, and an agent connection says nothing else |
| 284 | /// about who is calling. -1 rather than optional, like every other fd here. | 284 | /// about who is calling. The listener and the path travel as one because |
| 285 | agent_listener: std.posix.fd_t = -1, | 285 | /// the unlink at teardown has to be guarded by what the listener was |
| 286 | /// The path of that socket — the value the shell was handed. Owned; | 286 | /// bound to — see AgentSock.release. The path is owned and freed with |
| 287 | /// freed with the listener in both teardown paths (Session.closeAgent). | 287 | /// it in both teardown paths (Session.closeAgent). |
| 288 | agent_path: ?[:0]const u8 = null, | 288 | agent_sock: ?AgentSock = null, |
| 289 | /// The pty's line-discipline bits as last put on the wire, or null | 289 | /// The pty's line-discipline bits as last put on the wire, or null |
| 290 | /// before the first poll. Deliberately "what clients have been told" | 290 | /// before the first poll. Deliberately "what clients have been told" |
| 291 | /// rather than "what the pty says": the two differ for exactly the span | 291 | /// rather than "what the pty says": the two differ for exactly the span |
| @@ -402,10 +402,22 @@ pub const Session = struct { | |||
| 402 | /// and idempotent, because a session that never got one is the | 402 | /// and idempotent, because a session that never got one is the |
| 403 | /// ordinary case, not an error. | 403 | /// ordinary case, not an error. |
| 404 | pub fn closeAgent(self: *Session, alloc: std.mem.Allocator) void { | 404 | pub fn closeAgent(self: *Session, alloc: std.mem.Allocator) void { |
| 405 | const path = self.agent_path orelse return; | 405 | if (self.agent_sock) |*a| a.release(alloc); |
| 406 | (AgentSock{ .fd = self.agent_listener, .path = path }).release(alloc); | 406 | self.agent_sock = null; |
| 407 | self.agent_listener = -1; | 407 | } |
| 408 | self.agent_path = null; | 408 | |
| 409 | /// The listening descriptor, or -1 for a session that has none: the | ||
| 410 | /// pollfd table and the upgrade's cloexec sweep want a number, and -1 | ||
| 411 | /// is what `pollIn` and those sweeps already read as "no fd here". | ||
| 412 | pub fn agentFd(self: *const Session) std.posix.fd_t { | ||
| 413 | const a = self.agent_sock orelse return -1; | ||
| 414 | return a.bound.fd; | ||
| 415 | } | ||
| 416 | |||
| 417 | /// The value the shell was handed as `SSH_AUTH_SOCK`, or null. | ||
| 418 | pub fn agentPath(self: *const Session) ?[:0]const u8 { | ||
| 419 | const a = self.agent_sock orelse return null; | ||
| 420 | return a.path; | ||
| 409 | } | 421 | } |
| 410 | }; | 422 | }; |
| 411 | 423 | ||
| @@ -647,7 +659,7 @@ pub const Server = struct { | |||
| 647 | errdefer for (&srv.sessions.table) |*slot| { | 659 | errdefer for (&srv.sessions.table) |*slot| { |
| 648 | if (slot.*) |*s| { | 660 | if (slot.*) |*s| { |
| 649 | s.eng.deinit(); | 661 | s.eng.deinit(); |
| 650 | if (s.agent_path) |p| alloc.free(p); | 662 | if (s.agentPath()) |p| alloc.free(p); |
| 651 | slot.* = null; | 663 | slot.* = null; |
| 652 | } | 664 | } |
| 653 | }; | 665 | }; |
| @@ -695,8 +707,33 @@ pub const Server = struct { | |||
| 695 | break :blk restamped; | 707 | break :blk restamped; |
| 696 | } else null; | 708 | } else null; |
| 697 | if (rec.agent_path) |p| { | 709 | if (rec.agent_path) |p| { |
| 698 | s.agent_path = try alloc.dupeZ(u8, p); | 710 | const dup = try alloc.dupeZ(u8, p); |
| 699 | s.agent_listener = rec.agent_fd; | 711 | // `adopt`, not `bind`: the fd crossed the exec already. The |
| 712 | // id is re-stamped from the file as found, which is the | ||
| 713 | // manifest rule — a watermark belongs to the space that | ||
| 714 | // minted it, and the pre-exec id was stamped in another. | ||
| 715 | // | ||
| 716 | // Re-stamping needs the FILE, so it fails when something has | ||
| 717 | // deleted the socket out of the runtime dir. That degrades | ||
| 718 | // per session — the same answer `AgentRelay.bindSock` gives a | ||
| 719 | // socket it cannot bind — and never fails the adoption: a | ||
| 720 | // whole daemon refusing to come up would take every shell in | ||
| 721 | // the table with it, and the rollback exec would then hit the | ||
| 722 | // identical missing file with MUX_UPGRADE_ROLLBACK already | ||
| 723 | // set. One session loses agent forwarding instead. | ||
| 724 | if (serve.adopt(rec.agent_fd, dup)) |b| { | ||
| 725 | s.agent_sock = .{ .bound = b, .path = dup }; | ||
| 726 | } else |err| { | ||
| 727 | std.debug.print( | ||
| 728 | "mux d: no agent socket for session {s} ({t})\n", | ||
| 729 | .{ SessionTable.safeName(rec.name), err }, | ||
| 730 | ); | ||
| 731 | alloc.free(dup); | ||
| 732 | // The descriptor is NOT closed, for the reason the | ||
| 733 | // errdefer above states: nothing the manifest handed over | ||
| 734 | // is released on this path, because a later failure hands | ||
| 735 | // every one of them back to the old binary. | ||
| 736 | } | ||
| 700 | } | 737 | } |
| 701 | const n = @min(rec.name.len, proto.session_name_max); | 738 | const n = @min(rec.name.len, proto.session_name_max); |
| 702 | @memcpy(s.name_buf[0..n], rec.name[0..n]); | 739 | @memcpy(s.name_buf[0..n], rec.name[0..n]); |
| @@ -885,7 +922,7 @@ pub const Server = struct { | |||
| 885 | // several round trips deep inside an ssh handshake, and a second | 922 | // several round trips deep inside an ssh handshake, and a second |
| 886 | // loop would pay it a poll cycle per leg. | 923 | // loop would pay it a poll cycle per leg. |
| 887 | for (&self.sessions.table, 0..) |*slot, si| { | 924 | for (&self.sessions.table, 0..) |*slot, si| { |
| 888 | fds[agent_listener_base + si] = pollIn(if (slot.*) |*s| s.agent_listener else -1); | 925 | fds[agent_listener_base + si] = pollIn(if (slot.*) |*s| s.agentFd() else -1); |
| 889 | } | 926 | } |
| 890 | for (self.agents.chans, 0..) |slot, s| { | 927 | for (self.agents.chans, 0..) |slot, s| { |
| 891 | fds[agent_chan_base + s] = pollIn(if (slot) |ch| ch.fd else -1); | 928 | fds[agent_chan_base + s] = pollIn(if (slot) |ch| ch.fd else -1); |
| @@ -2812,8 +2849,8 @@ pub const Server = struct { | |||
| 2812 | .exit_code = s.cmd.exit_code, | 2849 | .exit_code = s.cmd.exit_code, |
| 2813 | }, | 2850 | }, |
| 2814 | .last_return = s.last_return, | 2851 | .last_return = s.last_return, |
| 2815 | .agent_fd = s.agent_listener, | 2852 | .agent_fd = s.agentFd(), |
| 2816 | .agent_path = if (s.agent_path) |p| p else null, | 2853 | .agent_path = s.agentPath(), |
| 2817 | }); | 2854 | }); |
| 2818 | } | 2855 | } |
| 2819 | 2856 | ||
| @@ -2956,7 +2993,7 @@ pub const Server = struct { | |||
| 2956 | for (&self.sessions.table) |*slot| { | 2993 | for (&self.sessions.table) |*slot| { |
| 2957 | if (slot.*) |*s| { | 2994 | if (slot.*) |*s| { |
| 2958 | sealFd(s.pty.master); | 2995 | sealFd(s.pty.master); |
| 2959 | if (s.agent_listener != -1) sealFd(s.agent_listener); | 2996 | if (s.agentFd() != -1) sealFd(s.agentFd()); |
| 2960 | } | 2997 | } |
| 2961 | } | 2998 | } |
| 2962 | } | 2999 | } |
| @@ -3002,9 +3039,9 @@ pub const Server = struct { | |||
| 3002 | const s = slot.* orelse continue; | 3039 | const s = slot.* orelse continue; |
| 3003 | clearCloexec(s.pty.master) catch return; | 3040 | clearCloexec(s.pty.master) catch return; |
| 3004 | cleared.append(a, s.pty.master) catch return; | 3041 | cleared.append(a, s.pty.master) catch return; |
| 3005 | if (s.agent_listener != -1) { | 3042 | if (s.agentFd() != -1) { |
| 3006 | clearCloexec(s.agent_listener) catch return; | 3043 | clearCloexec(s.agentFd()) catch return; |
| 3007 | cleared.append(a, s.agent_listener) catch return; | 3044 | cleared.append(a, s.agentFd()) catch return; |
| 3008 | } | 3045 | } |
| 3009 | } | 3046 | } |
| 3010 | 3047 | ||
src/server/server_agent.zig
| Old | New | ||
|---|---|---|---|
| @@ -13,22 +13,25 @@ | |||
| 13 | const std = @import("std"); | 13 | const std = @import("std"); |
| 14 | const proto = @import("term").protocol; | 14 | const proto = @import("term").protocol; |
| 15 | const xdg = @import("xdg"); | 15 | const xdg = @import("xdg"); |
| 16 | const serve = @import("serve"); | ||
| 16 | const srv_mod = @import("server.zig"); | 17 | const srv_mod = @import("server.zig"); |
| 17 | const Server = srv_mod.Server; | 18 | const Server = srv_mod.Server; |
| 18 | 19 | ||
| 19 | /// A bound, listening `SSH_AUTH_SOCK` for one session: the descriptor the | 20 | /// A bound, listening `SSH_AUTH_SOCK` for one session: the listener the |
| 20 | /// daemon accepts on and the name the shell was handed. The two travel | 21 | /// daemon accepts on and the name the shell was handed. The two travel |
| 21 | /// together because they die together — see `release`. | 22 | /// together because they die together — see `release`. |
| 22 | pub const AgentSock = struct { | 23 | pub const AgentSock = struct { |
| 23 | fd: std.posix.fd_t, | 24 | bound: serve.Bound, |
| 24 | path: [:0]const u8, | 25 | path: [:0]const u8, |
| 25 | 26 | ||
| 26 | /// Close AND unlink as one act: a leftover socket file makes the next | 27 | /// Close AND unlink as one act: a leftover socket file outlives the |
| 27 | /// session of that name fail to bind, and `Server.deinit` is too late for | 28 | /// session that owned it, and `Server.deinit` is too late for a live |
| 28 | /// a live daemon. | 29 | /// daemon. `Bound.close`'s guard is what keeps this from unlinking a |
| 29 | pub fn release(self: AgentSock, alloc: std.mem.Allocator) void { | 30 | /// SUCCESSOR's socket — a session ended and another born under the same |
| 30 | std.posix.close(self.fd); | 31 | /// name puts two owners on one path, and only the newest may be deleted |
| 31 | std.fs.cwd().deleteFile(self.path) catch {}; | 32 | /// by it. |
| 33 | pub fn release(self: *AgentSock, alloc: std.mem.Allocator) void { | ||
| 34 | self.bound.close(self.path); | ||
| 32 | alloc.free(self.path); | 35 | alloc.free(self.path); |
| 33 | } | 36 | } |
| 34 | }; | 37 | }; |
| @@ -157,22 +160,26 @@ pub const AgentRelay = struct { | |||
| 157 | var bound = false; | 160 | var bound = false; |
| 158 | defer if (!bound) alloc.free(path); | 161 | defer if (!bound) alloc.free(path); |
| 159 | 162 | ||
| 160 | // initUnix refuses an overlong path itself (sockpath.max_sun_path | ||
| 161 | // is the same 107), so the length check is its error, not a second | ||
| 162 | // rule stated here. | ||
| 163 | const addr = std.net.Address.initUnix(path) catch |err| { | ||
| 164 | std.debug.print("mux d: no agent socket for session {s} ({t})\n", .{ name, err }); | ||
| 165 | return null; | ||
| 166 | }; | ||
| 167 | // Backlog 8, not the default 128: the only dialler is the ssh clients | 163 | // Backlog 8, not the default 128: the only dialler is the ssh clients |
| 168 | // of one session's shell, so the queue can only be as deep as the | 164 | // of one session's shell, so the queue can only be as deep as the |
| 169 | // commands one person has started at once. | 165 | // commands one person has started at once. `clobber_own` because the |
| 170 | const listener = addr.listen(.{ .kernel_backlog = 8 }) catch |err| { | 166 | // name is `agent-<session>.sock` inside a directory this daemon made |
| 167 | // and owns: anything already at it is a previous us. Overlong paths | ||
| 168 | // are initUnix's refusal inside `serve.bind` (sockpath.max_sun_path | ||
| 169 | // is the same 107), not a second rule stated here. CLOEXEC is | ||
| 170 | // `serve.BindOpts`'s default and is right here: this daemon forks a | ||
| 171 | // shell per session, and a listener leaked into one is a socket that | ||
| 172 | // shell could serve. `mux d upgrade` still carries it across the exec | ||
| 173 | // — `Server.execUpgrade` clears the flag on this fd by name right | ||
| 174 | // before execve and `Server.sealAdoptedFds` puts it back on the far | ||
| 175 | // side, so the fds that cross say so one at a time rather than | ||
| 176 | // standing open to every child. | ||
| 177 | const b = serve.bind(path, .{ .policy = .clobber_own, .backlog = 8 }) catch |err| { | ||
| 171 | std.debug.print("mux d: no agent socket for session {s} ({t})\n", .{ name, err }); | 178 | std.debug.print("mux d: no agent socket for session {s} ({t})\n", .{ name, err }); |
| 172 | return null; | 179 | return null; |
| 173 | }; | 180 | }; |
| 174 | bound = true; | 181 | bound = true; |
| 175 | return .{ .fd = listener.stream.handle, .path = path }; | 182 | return .{ .bound = b, .path = path }; |
| 176 | } | 183 | } |
| 177 | 184 | ||
| 178 | /// Refusal counts only mean something next to it: 0 is nobody offering. | 185 | /// Refusal counts only mean something next to it: 0 is nobody offering. |
| @@ -225,7 +232,7 @@ pub const AgentRelay = struct { | |||
| 225 | // into one would outlive the ssh that opened it, holding a channel | 232 | // into one would outlive the ssh that opened it, holding a channel |
| 226 | // open against a client that has long since gone. | 233 | // open against a client that has long since gone. |
| 227 | const fd = std.posix.accept( | 234 | const fd = std.posix.accept( |
| 228 | srv.ses(si).agent_listener, | 235 | srv.ses(si).agentFd(), |
| 229 | null, | 236 | null, |
| 230 | null, | 237 | null, |
| 231 | std.posix.SOCK.CLOEXEC, | 238 | std.posix.SOCK.CLOEXEC, |
src/server/server_sessions.zig
| Old | New | ||
|---|---|---|---|
| @@ -53,7 +53,8 @@ pub const SessionTable = struct { | |||
| 53 | rows: u16, | 53 | rows: u16, |
| 54 | agent: ?AgentSock, | 54 | agent: ?AgentSock, |
| 55 | ) !Session { | 55 | ) !Session { |
| 56 | errdefer if (agent) |a| a.release(alloc); | 56 | var agent_var = agent; |
| 57 | errdefer if (agent_var) |*a| a.release(alloc); | ||
| 57 | 58 | ||
| 58 | // The wire's number, not the engine's default: the daemon caps what | 59 | // The wire's number, not the engine's default: the daemon caps what |
| 59 | // it queues at exactly what a term_event frame can carry, so a | 60 | // it queues at exactly what a term_event frame can carry, so a |
| @@ -93,10 +94,7 @@ pub const SessionTable = struct { | |||
| 93 | errdefer pty.deinit(); | 94 | errdefer pty.deinit(); |
| 94 | 95 | ||
| 95 | var s = Session{ .eng = eng, .pty = pty, .epoch = freshEpoch() }; | 96 | var s = Session{ .eng = eng, .pty = pty, .epoch = freshEpoch() }; |
| 96 | if (agent) |a| { | 97 | s.agent_sock = agent_var; |
| 97 | s.agent_listener = a.fd; | ||
| 98 | s.agent_path = a.path; | ||
| 99 | } | ||
| 100 | @memcpy(s.name_buf[0..name.len], name); | 98 | @memcpy(s.name_buf[0..name.len], name); |
| 101 | s.name_len = @intCast(name.len); | 99 | s.name_len = @intCast(name.len); |
| 102 | return s; | 100 | return s; |
src/server/server_test_agent.zig
| Old | New | ||
|---|---|---|---|
| @@ -28,8 +28,8 @@ test "Server: a session shell is born with a live SSH_AUTH_SOCK" { | |||
| 28 | var td = try h.TestDaemon.init(alloc, "agentenv", .{ .shell = "/bin/sh" }); | 28 | var td = try h.TestDaemon.init(alloc, "agentenv", .{ .shell = "/bin/sh" }); |
| 29 | defer td.deinit(); | 29 | defer td.deinit(); |
| 30 | 30 | ||
| 31 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 31 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 32 | try std.testing.expect(td.srv.ses(0).agent_listener >= 0); | 32 | try std.testing.expect(td.srv.ses(0).agentFd() >= 0); |
| 33 | try std.testing.expect(isSocketAt(path)); | 33 | try std.testing.expect(isSocketAt(path)); |
| 34 | 34 | ||
| 35 | // And the shell was TOLD, which is the half no field can answer: the | 35 | // And the shell was TOLD, which is the half no field can answer: the |
| @@ -63,7 +63,7 @@ test "Server: a reaped session takes its agent socket with it" { | |||
| 63 | 63 | ||
| 64 | // Copied, because the reap frees the session's own: the whole point is | 64 | // Copied, because the reap frees the session's own: the whole point is |
| 65 | // to ask about the path after the session that owned it is gone. | 65 | // to ask about the path after the session that owned it is gone. |
| 66 | const path = try alloc.dupeZ(u8, td.srv.ses(0).agent_path orelse return error.NoAgentSocket); | 66 | const path = try alloc.dupeZ(u8, td.srv.ses(0).agentPath() orelse return error.NoAgentSocket); |
| 67 | defer alloc.free(path); | 67 | defer alloc.free(path); |
| 68 | try std.testing.expect(isSocketAt(path)); | 68 | try std.testing.expect(isSocketAt(path)); |
| 69 | 69 | ||
| @@ -209,7 +209,7 @@ test "Server: a session with no agent socket does not inherit the daemon's" { | |||
| 209 | var spun: usize = 0; | 209 | var spun: usize = 0; |
| 210 | while (spun < 200 and td.srv.clients[0] == null) : (spun += 1) try td.srv.pumpOnce(5); | 210 | while (spun < 200 and td.srv.clients[0] == null) : (spun += 1) try td.srv.pumpOnce(5); |
| 211 | const si = td.srv.clients[0].?.session orelse return error.ClientNeverSeated; | 211 | const si = td.srv.clients[0].?.session orelse return error.ClientNeverSeated; |
| 212 | try std.testing.expect(td.srv.sessions.table[si].?.agent_path == null); | 212 | try std.testing.expect(td.srv.sessions.table[si].?.agentPath() == null); |
| 213 | 213 | ||
| 214 | try proto.writeFrame(c.handle, .input, cmd); | 214 | try proto.writeFrame(c.handle, .input, cmd); |
| 215 | spun = 0; | 215 | spun = 0; |
| @@ -236,7 +236,7 @@ test "Server: a full channel table refuses the newest dial and says so once" { | |||
| 236 | const c = try dial.dial(td.sock_path); | 236 | const c = try dial.dial(td.sock_path); |
| 237 | defer c.close(); | 237 | defer c.close(); |
| 238 | try attachOffering(&td.srv, c.handle, 0, ""); | 238 | try attachOffering(&td.srv, c.handle, 0, ""); |
| 239 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 239 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 240 | 240 | ||
| 241 | // Exactly the table, then one more. ssh opens a channel per auth attempt, | 241 | // Exactly the table, then one more. ssh opens a channel per auth attempt, |
| 242 | // so a long-lived agent connection holding a slot is the field failure | 242 | // so a long-lived agent connection holding a slot is the field failure |
| @@ -304,7 +304,7 @@ test "Server: an agent connection with nobody offering is refused fast" { | |||
| 304 | try attachOffering(&td.srv, other.handle, 1, "b"); | 304 | try attachOffering(&td.srv, other.handle, 1, "b"); |
| 305 | try std.testing.expect(td.srv.clients[1].?.session != td.srv.clients[0].?.session); | 305 | try std.testing.expect(td.srv.clients[1].?.session != td.srv.clients[0].?.session); |
| 306 | 306 | ||
| 307 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 307 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 308 | const agent = try dial.dial(path); | 308 | const agent = try dial.dial(path); |
| 309 | defer agent.close(); | 309 | defer agent.close(); |
| 310 | 310 | ||
| @@ -327,7 +327,7 @@ test "Server: agent bytes pump both ways through a channel" { | |||
| 327 | defer c.close(); | 327 | defer c.close(); |
| 328 | try attachOffering(&td.srv, c.handle, 0, ""); | 328 | try attachOffering(&td.srv, c.handle, 0, ""); |
| 329 | 329 | ||
| 330 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 330 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 331 | const agent = try dial.dial(path); | 331 | const agent = try dial.dial(path); |
| 332 | defer agent.close(); | 332 | defer agent.close(); |
| 333 | 333 | ||
| @@ -382,7 +382,7 @@ test "Server: an agent connection is routed to the latest-active offerer" { | |||
| 382 | defer cb.close(); | 382 | defer cb.close(); |
| 383 | try attachOffering(&td.srv, cb.handle, 1, ""); | 383 | try attachOffering(&td.srv, cb.handle, 1, ""); |
| 384 | 384 | ||
| 385 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 385 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 386 | 386 | ||
| 387 | // Two dials, with the lead changing hands in between, because ONE dial | 387 | // Two dials, with the lead changing hands in between, because ONE dial |
| 388 | // cannot tell the rule apart from the wrong ones: whoever offered first, | 388 | // cannot tell the rule apart from the wrong ones: whoever offered first, |
| @@ -446,7 +446,7 @@ test "Server: agent_data for an unknown or another client's channel is dropped" | |||
| 446 | try attachOffering(&td.srv, cb.handle, 1, ""); | 446 | try attachOffering(&td.srv, cb.handle, 1, ""); |
| 447 | 447 | ||
| 448 | // B attached last, so the channel is B's. | 448 | // B attached last, so the channel is B's. |
| 449 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 449 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 450 | const agent = try dial.dial(path); | 450 | const agent = try dial.dial(path); |
| 451 | defer agent.close(); | 451 | defer agent.close(); |
| 452 | const open = (try awaitFrame(alloc, &td.srv, cb.handle, .agent_open, 200)) orelse | 452 | const open = (try awaitFrame(alloc, &td.srv, cb.handle, .agent_open, 200)) orelse |
| @@ -501,7 +501,7 @@ test "Server: an agent_data frame past the cap hangs the channel up" { | |||
| 501 | defer c.close(); | 501 | defer c.close(); |
| 502 | try attachOffering(&td.srv, c.handle, 0, ""); | 502 | try attachOffering(&td.srv, c.handle, 0, ""); |
| 503 | 503 | ||
| 504 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 504 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 505 | const agent = try dial.dial(path); | 505 | const agent = try dial.dial(path); |
| 506 | defer agent.close(); | 506 | defer agent.close(); |
| 507 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse | 507 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse |
| @@ -548,7 +548,7 @@ test "Server: a client closing a channel hangs up the agent connection without a | |||
| 548 | defer c.close(); | 548 | defer c.close(); |
| 549 | try attachOffering(&td.srv, c.handle, 0, ""); | 549 | try attachOffering(&td.srv, c.handle, 0, ""); |
| 550 | 550 | ||
| 551 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 551 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 552 | const agent = try dial.dial(path); | 552 | const agent = try dial.dial(path); |
| 553 | defer agent.close(); | 553 | defer agent.close(); |
| 554 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse | 554 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse |
| @@ -585,7 +585,7 @@ test "Server: the daemon listener, the clients it accepts, an agent listener and | |||
| 585 | defer c.close(); | 585 | defer c.close(); |
| 586 | try attachOffering(&td.srv, c.handle, 0, ""); | 586 | try attachOffering(&td.srv, c.handle, 0, ""); |
| 587 | 587 | ||
| 588 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 588 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 589 | const agent = try dial.dial(path); | 589 | const agent = try dial.dial(path); |
| 590 | defer agent.close(); | 590 | defer agent.close(); |
| 591 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse | 591 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse |
| @@ -599,7 +599,7 @@ test "Server: the daemon listener, the clients it accepts, an agent listener and | |||
| 599 | // channel that no close of ours could ever finish closing. | 599 | // channel that no close of ours could ever finish closing. |
| 600 | const flags = std.posix.FD_CLOEXEC; | 600 | const flags = std.posix.FD_CLOEXEC; |
| 601 | try std.testing.expect( | 601 | try std.testing.expect( |
| 602 | try std.posix.fcntl(td.srv.ses(0).agent_listener, std.posix.F.GETFD, 0) & flags != 0, | 602 | try std.posix.fcntl(td.srv.ses(0).agentFd(), std.posix.F.GETFD, 0) & flags != 0, |
| 603 | ); | 603 | ); |
| 604 | const ch = td.srv.agents.chans[0] orelse return error.NoChannel; | 604 | const ch = td.srv.agents.chans[0] orelse return error.NoChannel; |
| 605 | try std.testing.expect(try std.posix.fcntl(ch.fd, std.posix.F.GETFD, 0) & flags != 0); | 605 | try std.testing.expect(try std.posix.fcntl(ch.fd, std.posix.F.GETFD, 0) & flags != 0); |
| @@ -627,7 +627,7 @@ test "Server: a client's agent channels die with the client" { | |||
| 627 | const c = try dial.dial(td.sock_path); | 627 | const c = try dial.dial(td.sock_path); |
| 628 | try attachOffering(&td.srv, c.handle, 0, ""); | 628 | try attachOffering(&td.srv, c.handle, 0, ""); |
| 629 | 629 | ||
| 630 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 630 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 631 | const agent = try dial.dial(path); | 631 | const agent = try dial.dial(path); |
| 632 | defer agent.close(); | 632 | defer agent.close(); |
| 633 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse | 633 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse |
| @@ -675,7 +675,7 @@ test "Server: a QUIC client's agent channels die with the client" { | |||
| 675 | }.f); | 675 | }.f); |
| 676 | if (td.srv.clients[0] == null or !td.srv.clients[0].?.agent_offer) return error.ClientNeverOffered; | 676 | if (td.srv.clients[0] == null or !td.srv.clients[0].?.agent_offer) return error.ClientNeverOffered; |
| 677 | 677 | ||
| 678 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 678 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 679 | const agent = try dial.dial(path); | 679 | const agent = try dial.dial(path); |
| 680 | defer agent.close(); | 680 | defer agent.close(); |
| 681 | try quicPump(&td.srv, &only, 10000, &cl, struct { | 681 | try quicPump(&td.srv, &only, 10000, &cl, struct { |
| @@ -733,7 +733,7 @@ test "Server: an offerer that never answers its first request is hung up on and | |||
| 733 | defer c.close(); | 733 | defer c.close(); |
| 734 | try attachOffering(&td.srv, c.handle, 0, ""); | 734 | try attachOffering(&td.srv, c.handle, 0, ""); |
| 735 | 735 | ||
| 736 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 736 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 737 | const agent = try dial.dial(path); | 737 | const agent = try dial.dial(path); |
| 738 | defer agent.close(); | 738 | defer agent.close(); |
| 739 | const id = try openAndAsk(alloc, &td.srv, c.handle, agent.handle); | 739 | const id = try openAndAsk(alloc, &td.srv, c.handle, agent.handle); |
| @@ -771,7 +771,7 @@ test "Server: a channel that has answered once is never timed out" { | |||
| 771 | defer c.close(); | 771 | defer c.close(); |
| 772 | try attachOffering(&td.srv, c.handle, 0, ""); | 772 | try attachOffering(&td.srv, c.handle, 0, ""); |
| 773 | 773 | ||
| 774 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 774 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 775 | const agent = try dial.dial(path); | 775 | const agent = try dial.dial(path); |
| 776 | defer agent.close(); | 776 | defer agent.close(); |
| 777 | const id = try openAndAsk(alloc, &td.srv, c.handle, agent.handle); | 777 | const id = try openAndAsk(alloc, &td.srv, c.handle, agent.handle); |
| @@ -809,7 +809,7 @@ test "Server: bytes a client sends before it was asked prove nothing" { | |||
| 809 | defer c.close(); | 809 | defer c.close(); |
| 810 | try attachOffering(&td.srv, c.handle, 0, ""); | 810 | try attachOffering(&td.srv, c.handle, 0, ""); |
| 811 | 811 | ||
| 812 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 812 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 813 | const agent = try dial.dial(path); | 813 | const agent = try dial.dial(path); |
| 814 | defer agent.close(); | 814 | defer agent.close(); |
| 815 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse | 815 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse |
| @@ -847,7 +847,7 @@ test "Server: a channel nobody has asked anything on is not timed out" { | |||
| 847 | defer c.close(); | 847 | defer c.close(); |
| 848 | try attachOffering(&td.srv, c.handle, 0, ""); | 848 | try attachOffering(&td.srv, c.handle, 0, ""); |
| 849 | 849 | ||
| 850 | const path = td.srv.ses(0).agent_path orelse return error.NoAgentSocket; | 850 | const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket; |
| 851 | const agent = try dial.dial(path); | 851 | const agent = try dial.dial(path); |
| 852 | defer agent.close(); | 852 | defer agent.close(); |
| 853 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse | 853 | const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse |
| @@ -862,3 +862,59 @@ test "Server: a channel nobody has asked anything on is not timed out" { | |||
| 862 | try std.testing.expect(td.srv.agents.chans[0] != null); | 862 | try std.testing.expect(td.srv.agents.chans[0] != null); |
| 863 | try std.testing.expect(td.srv.clients[0].?.agent_offer); | 863 | try std.testing.expect(td.srv.clients[0].?.agent_offer); |
| 864 | } | 864 | } |
| 865 | |||
| 866 | test "Server: ending one session unlinks ITS agent socket and leaves every other session's alone" { | ||
| 867 | const alloc = std.testing.allocator; | ||
| 868 | |||
| 869 | // Two sessions, because one cannot show the failure this pins: a | ||
| 870 | // teardown that unlinks by name without the guard, or one that sweeps | ||
| 871 | // the agent directory, is indistinguishable from a correct one until a | ||
| 872 | // second socket is sitting there to be destroyed. | ||
| 873 | var td = try h.TestDaemon.init(alloc, "agentplural", .{ .shell = "/bin/sh" }); | ||
| 874 | defer td.deinit(); | ||
| 875 | |||
| 876 | const keep = try dial.dialAttachNamed(td.sock_path, 80, 24, "keep"); | ||
| 877 | defer keep.close(); | ||
| 878 | (try awaitFrame(alloc, &td.srv, keep.handle, .snapshot, 400) orelse | ||
| 879 | return error.NoState).deinit(alloc); | ||
| 880 | const doomed = try dial.dialAttachNamed(td.sock_path, 80, 24, "doomed"); | ||
| 881 | defer doomed.close(); | ||
| 882 | (try awaitFrame(alloc, &td.srv, doomed.handle, .snapshot, 400) orelse | ||
| 883 | return error.NoState).deinit(alloc); | ||
| 884 | |||
| 885 | var keep_path: ?[:0]const u8 = null; | ||
| 886 | var doomed_path: ?[:0]const u8 = null; | ||
| 887 | for (&td.srv.sessions.table) |*slot| { | ||
| 888 | const s = &(slot.* orelse continue); | ||
| 889 | if (std.mem.eql(u8, s.name(), "keep")) keep_path = s.agentPath(); | ||
| 890 | if (std.mem.eql(u8, s.name(), "doomed")) doomed_path = s.agentPath(); | ||
| 891 | } | ||
| 892 | const kp = keep_path orelse return error.NoAgentSocket; | ||
| 893 | // Copied, not borrowed: the ending session frees its path, and the | ||
| 894 | // assertion below has to outlive the free it is checking. | ||
| 895 | const dp = try alloc.dupeZ(u8, doomed_path orelse return error.NoAgentSocket); | ||
| 896 | defer alloc.free(dp); | ||
| 897 | try std.testing.expect(isSocketAt(kp)); | ||
| 898 | try std.testing.expect(isSocketAt(dp)); | ||
| 899 | |||
| 900 | var rq: [proto.end_req_max_len]u8 = undefined; | ||
| 901 | try proto.writeFrame(doomed.handle, .end_req, proto.encodeEndReq(&rq, false, "doomed")); | ||
| 902 | const r = (try awaitFrame(alloc, &td.srv, doomed.handle, .end_reply, 400)) orelse | ||
| 903 | return error.NoEndReply; | ||
| 904 | defer r.deinit(alloc); | ||
| 905 | try std.testing.expect((proto.parseEndReply(r.payload) orelse | ||
| 906 | return error.BadEndReply).accepted); | ||
| 907 | |||
| 908 | var waited: u32 = 0; | ||
| 909 | while (waited < 3000) : (waited += 50) { | ||
| 910 | try td.srv.pumpOnce(20); | ||
| 911 | if (!isSocketAt(dp)) break; | ||
| 912 | std.Thread.sleep(30 * std.time.ns_per_ms); | ||
| 913 | } | ||
| 914 | |||
| 915 | // Asked of the filesystem, not of the daemon: a table that has forgotten | ||
| 916 | // a session says nothing about whether its name left the directory, and | ||
| 917 | // the leftover file is what the next session of that name trips over. | ||
| 918 | try std.testing.expect(!isSocketAt(dp)); | ||
| 919 | try std.testing.expect(isSocketAt(kp)); | ||
| 920 | } | ||
src/server/server_test_upgrade.zig
| Old | New | ||
|---|---|---|---|
| @@ -134,7 +134,7 @@ test "initFromManifest: an adopted session answers a status_req without having b | |||
| 134 | s.freePending(alloc); | 134 | s.freePending(alloc); |
| 135 | if (s.title_sent) |t| alloc.free(t); | 135 | if (s.title_sent) |t| alloc.free(t); |
| 136 | s.eng.deinit(); | 136 | s.eng.deinit(); |
| 137 | if (s.agent_path) |p| alloc.free(p); | 137 | if (s.agentPath()) |p| alloc.free(p); |
| 138 | } | 138 | } |
| 139 | if (srv.agents.dir) |d| alloc.free(d); | 139 | if (srv.agents.dir) |d| alloc.free(d); |
| 140 | srv.shellint_arena.deinit(); | 140 | srv.shellint_arena.deinit(); |
| @@ -174,6 +174,69 @@ test "initFromManifest: an adopted session answers a status_req without having b | |||
| 174 | try std.testing.expect(s2.epoch != old_epoch); | 174 | try std.testing.expect(s2.epoch != old_epoch); |
| 175 | } | 175 | } |
| 176 | 176 | ||
| 177 | test "initFromManifest: a session whose agent socket file vanished loses forwarding, not the daemon" { | ||
| 178 | const alloc = std.testing.allocator; | ||
| 179 | |||
| 180 | var tmp = try TmpDir.make(); | ||
| 181 | defer tmp.cleanup(); | ||
| 182 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/goneagent.sock", .{tmp.path()}); | ||
| 183 | defer alloc.free(sock_path); | ||
| 184 | |||
| 185 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" }); | ||
| 186 | const agent_path = try alloc.dupeZ( | ||
| 187 | u8, | ||
| 188 | srv.sessions.table[0].?.agentPath() orelse return error.NoAgentSocket, | ||
| 189 | ); | ||
| 190 | defer alloc.free(agent_path); | ||
| 191 | |||
| 192 | const memfd = try std.posix.memfd_create("mux-goneagent-test", 0); | ||
| 193 | defer std.posix.close(memfd); | ||
| 194 | try srv.writeManifestTo(memfd, "0.0.1-99"); | ||
| 195 | |||
| 196 | // The seam: something outside mux cleaned the runtime directory between | ||
| 197 | // the manifest and the exec. Adoption re-stamps the socket's id from the | ||
| 198 | // FILE, so this is the one manifest field that can be missing from the | ||
| 199 | // filesystem when the new image reads it back. | ||
| 200 | try std.fs.cwd().deleteFile(agent_path); | ||
| 201 | |||
| 202 | // Memory only, as in the adopted-session tests above: deinit would | ||
| 203 | // demolish exactly what srv2 is about to inherit. | ||
| 204 | { | ||
| 205 | const s = &srv.sessions.table[0].?; | ||
| 206 | s.tracker.deinit(alloc); | ||
| 207 | s.freePending(alloc); | ||
| 208 | if (s.title_sent) |t| alloc.free(t); | ||
| 209 | s.eng.deinit(); | ||
| 210 | if (s.agentPath()) |p| alloc.free(p); | ||
| 211 | } | ||
| 212 | if (srv.agents.dir) |d| alloc.free(d); | ||
| 213 | srv.shellint_arena.deinit(); | ||
| 214 | |||
| 215 | var file = std.fs.File{ .handle = memfd }; | ||
| 216 | try file.seekTo(0); | ||
| 217 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); | ||
| 218 | defer alloc.free(buf); | ||
| 219 | var parsed = try upgrade.parseManifest(alloc, buf); | ||
| 220 | defer parsed.deinit(); | ||
| 221 | |||
| 222 | // The whole point: this returns a Server. Failing it would take every | ||
| 223 | // shell in the table down over one deleted file, and the rollback exec | ||
| 224 | // would hit the same missing file with the loop guard already set. | ||
| 225 | var srv2 = try Server.initFromManifest(alloc, &parsed, "0.0.1-100"); | ||
| 226 | defer srv2.deinit(); | ||
| 227 | |||
| 228 | // The session is here and serving; only its forwarding is gone. | ||
| 229 | const s2 = &srv2.sessions.table[0].?; | ||
| 230 | try std.testing.expect(s2.agentPath() == null); | ||
| 231 | try std.testing.expectEqual(@as(std.posix.fd_t, -1), s2.agentFd()); | ||
| 232 | const obs = try dial.dial(sock_path); | ||
| 233 | defer obs.close(); | ||
| 234 | try proto.writeFrame(obs.handle, .status_req, ""); | ||
| 235 | const reply = (try awaitFrame(alloc, &srv2, obs.handle, .status_reply, 400)) orelse | ||
| 236 | return error.NoStatusReply; | ||
| 237 | reply.deinit(alloc); | ||
| 238 | } | ||
| 239 | |||
| 177 | test "initFromManifest: the return watermark is re-stamped, never carried across seq spaces" { | 240 | test "initFromManifest: the return watermark is re-stamped, never carried across seq spaces" { |
| 178 | const alloc = std.testing.allocator; | 241 | const alloc = std.testing.allocator; |
| 179 | 242 | ||
| @@ -203,7 +266,7 @@ test "initFromManifest: the return watermark is re-stamped, never carried across | |||
| 203 | s.freePending(alloc); | 266 | s.freePending(alloc); |
| 204 | if (s.title_sent) |t| alloc.free(t); | 267 | if (s.title_sent) |t| alloc.free(t); |
| 205 | s.eng.deinit(); | 268 | s.eng.deinit(); |
| 206 | if (s.agent_path) |p| alloc.free(p); | 269 | if (s.agentPath()) |p| alloc.free(p); |
| 207 | } | 270 | } |
| 208 | if (srv.agents.dir) |d| alloc.free(d); | 271 | if (srv.agents.dir) |d| alloc.free(d); |
| 209 | srv.shellint_arena.deinit(); | 272 | srv.shellint_arena.deinit(); |
| @@ -317,8 +380,8 @@ test "sealAdoptedFds: the adopted fds are CLOEXEC again, and not one step before | |||
| 317 | // cross the exec. The adopting side must put it back. | 380 | // cross the exec. The adopting side must put it back. |
| 318 | try Server.clearCloexec(srv.bound.fd); | 381 | try Server.clearCloexec(srv.bound.fd); |
| 319 | try Server.clearCloexec(srv.sessions.table[0].?.pty.master); | 382 | try Server.clearCloexec(srv.sessions.table[0].?.pty.master); |
| 320 | if (srv.sessions.table[0].?.agent_listener != -1) | 383 | if (srv.sessions.table[0].?.agentFd() != -1) |
| 321 | try Server.clearCloexec(srv.sessions.table[0].?.agent_listener); | 384 | try Server.clearCloexec(srv.sessions.table[0].?.agentFd()); |
| 322 | 385 | ||
| 323 | // Memory only, as in the adopted-session test above: deinit would | 386 | // Memory only, as in the adopted-session test above: deinit would |
| 324 | // demolish exactly what srv2 is about to inherit. | 387 | // demolish exactly what srv2 is about to inherit. |
| @@ -328,7 +391,7 @@ test "sealAdoptedFds: the adopted fds are CLOEXEC again, and not one step before | |||
| 328 | s.freePending(alloc); | 391 | s.freePending(alloc); |
| 329 | if (s.title_sent) |t| alloc.free(t); | 392 | if (s.title_sent) |t| alloc.free(t); |
| 330 | s.eng.deinit(); | 393 | s.eng.deinit(); |
| 331 | if (s.agent_path) |p| alloc.free(p); | 394 | if (s.agentPath()) |p| alloc.free(p); |
| 332 | } | 395 | } |
| 333 | if (srv.agents.dir) |d| alloc.free(d); | 396 | if (srv.agents.dir) |d| alloc.free(d); |
| 334 | srv.shellint_arena.deinit(); | 397 | srv.shellint_arena.deinit(); |
| @@ -350,13 +413,13 @@ test "sealAdoptedFds: the adopted fds are CLOEXEC again, and not one step before | |||
| 350 | // daemon panicked adopting a manifest naming fds it no longer had. | 413 | // daemon panicked adopting a manifest naming fds it no longer had. |
| 351 | try std.testing.expect(!try hasCloexec(srv2.bound.fd)); | 414 | try std.testing.expect(!try hasCloexec(srv2.bound.fd)); |
| 352 | try std.testing.expect(!try hasCloexec(s2.pty.master)); | 415 | try std.testing.expect(!try hasCloexec(s2.pty.master)); |
| 353 | if (s2.agent_listener != -1) try std.testing.expect(!try hasCloexec(s2.agent_listener)); | 416 | if (s2.agentFd() != -1) try std.testing.expect(!try hasCloexec(s2.agentFd())); |
| 354 | 417 | ||
| 355 | srv2.sealAdoptedFds(); | 418 | srv2.sealAdoptedFds(); |
| 356 | 419 | ||
| 357 | try std.testing.expect(try hasCloexec(srv2.bound.fd)); | 420 | try std.testing.expect(try hasCloexec(srv2.bound.fd)); |
| 358 | try std.testing.expect(try hasCloexec(s2.pty.master)); | 421 | try std.testing.expect(try hasCloexec(s2.pty.master)); |
| 359 | if (s2.agent_listener != -1) try std.testing.expect(try hasCloexec(s2.agent_listener)); | 422 | if (s2.agentFd() != -1) try std.testing.expect(try hasCloexec(s2.agentFd())); |
| 360 | } | 423 | } |
| 361 | 424 | ||
| 362 | test "Server: an upgrade asked for during a session's hangup is refused, not attempted" { | 425 | test "Server: an upgrade asked for during a session's hangup is refused, not attempted" { |