a73x

4170920f

fix: agent sockets bind through serve and gain the successor guard

a73x   2026-08-31 21:58

Commit message
fix: agent sockets bind through serve and gain the successor guard

`AgentSock` carries a `serve.Bound` instead of a bare fd, so `release` is
`Bound.close` — the guarded unlink the daemon socket has had all along and
the agent sockets never did. It matters here for the same reason it does
there: a session ended and another born under the same name puts two owners
on one path, and only the newest may be deleted by it.

`Session.agent_listener`/`agent_path` become one `agent_sock: ?AgentSock`,
because the descriptor and the name have to reach the unlink together. The
-1 sentinel the pollfd table and the upgrade's cloexec sweep read is now
`Session.agentFd()`; `agentPath()` is the value the shell was handed.
`initFromManifest` takes `serve.adopt`, re-stamping the id from the file as
found — the fd crossed the exec, the id belongs to this space.

New test, plural by construction: two sessions, end one, then ask the
FILESYSTEM whether the other's socket is still there and the ended one's is
gone. With one session a teardown that unlinks nothing and one that unlinks
correctly are the same green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWxBL1HBULH1ZwTNzzKTja

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" {