3b47d5d5
refactor: the daemon socket binds through serve
a73x 2026-08-31 21:58
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -196,7 +196,7 @@ const mod_table = [_]ModSpec{ | |||
| 196 | // daemon itself when nobody handed it a --key — and for the shim | 196 | // daemon itself when nobody handed it a --key — and for the shim |
| 197 | // directory shell integration writes under the same 0700 policy. | 197 | // directory shell integration writes under the same 0700 policy. |
| 198 | // `pty` stays a row of its own: the ptyclient fixture consumes it. | 198 | // `pty` stays a row of its own: the ptyclient fixture consumes it. |
| 199 | .{ .name = "daemon", .path = "src/server/server.zig", .link_libc = true, .imports = &.{ "term", "pty", "sockpath", "quic", "xdg", "proxy" }, .test_imports = &.{ "testtmp", "dial" }, .quic_tests = true }, | 199 | .{ .name = "daemon", .path = "src/server/server.zig", .link_libc = true, .imports = &.{ "term", "pty", "sockpath", "serve", "quic", "xdg", "proxy" }, .test_imports = &.{ "testtmp", "dial" }, .quic_tests = true }, |
| 200 | // The agent-facing client. It speaks frames and owns no terminal, which | 200 | // The agent-facing client. It speaks frames and owns no terminal, which |
| 201 | // is the whole point — it attaches at 0x0 and never claims the grid. | 201 | // is the whole point — it attaches at 0x0 and never claims the grid. |
| 202 | // The transport modules are the CLI client's, minus everything that | 202 | // The transport modules are the CLI client's, minus everything that |
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ const DeltaTracker = @import("term").delta.DeltaTracker; | |||
| 12 | const cmdmod = @import("cmd.zig"); | 12 | const cmdmod = @import("cmd.zig"); |
| 13 | const shellint = @import("shellint.zig"); | 13 | const shellint = @import("shellint.zig"); |
| 14 | const sockpath = @import("sockpath"); | 14 | const sockpath = @import("sockpath"); |
| 15 | const serve = @import("serve"); | ||
| 15 | const quic = @import("quic"); | 16 | const quic = @import("quic"); |
| 16 | const xdg = @import("xdg"); | 17 | const xdg = @import("xdg"); |
| 17 | // Re-exported for the daemon's own main (src/cli/main.zig) — the only | 18 | // Re-exported for the daemon's own main (src/cli/main.zig) — the only |
| @@ -421,13 +422,14 @@ pub const Server = struct { | |||
| 421 | spawn_shell: [:0]const u8, | 422 | spawn_shell: [:0]const u8, |
| 422 | spawn_shell_integration: bool, | 423 | spawn_shell_integration: bool, |
| 423 | spawn_extra_env: []const Pty.EnvPair, | 424 | spawn_extra_env: []const Pty.EnvPair, |
| 424 | listener: std.net.Server, | 425 | /// The listening socket and what its file was when we bound it, so |
| 426 | /// teardown can tell our socket from one that replaced it. One field | ||
| 427 | /// rather than a descriptor beside a std.net.Server holding the same | ||
| 428 | /// number: the fd has one owner, and `serve.Bound.close` is the only | ||
| 429 | /// thing that closes it. Not optional — init cannot return without one, | ||
| 430 | /// and an absent-means-false arm is the silent no-unlink 6090604 fixed. | ||
| 431 | bound: serve.Bound, | ||
| 425 | sock_path: []const u8, | 432 | sock_path: []const u8, |
| 426 | /// What the socket file was when we bound it, so teardown can tell our | ||
| 427 | /// socket from one that replaced it. See sockpath.PathId. Not optional: | ||
| 428 | /// init cannot return without one, and an absent-means-false arm is the | ||
| 429 | /// silent no-unlink 6090604 fixed. | ||
| 430 | path_id: sockpath.PathId, | ||
| 431 | /// The attached interactive clients, across every session; each sees | 433 | /// The attached interactive clients, across every session; each sees |
| 432 | /// every update of the one session it is attached to. | 434 | /// every update of the one session it is attached to. |
| 433 | clients: [max_clients]?ClientSlot = @splat(null), | 435 | clients: [max_clients]?ClientSlot = @splat(null), |
| @@ -501,7 +503,10 @@ pub const Server = struct { | |||
| 501 | 503 | ||
| 502 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { | 504 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { |
| 503 | // Before the shell is spawned, so refusing costs nobody a fork and | 505 | // Before the shell is spawned, so refusing costs nobody a fork and |
| 504 | // leaves no process to reap. | 506 | // leaves no process to reap. `serve.bind` below runs the same refusal |
| 507 | // again, and the repeat is not redundant: it is the one that decides, | ||
| 508 | // covering the window this early check opens by refusing before the | ||
| 509 | // fork rather than at the bind. | ||
| 505 | try sockpath.claim(opts.sock_path); | 510 | try sockpath.claim(opts.sock_path); |
| 506 | 511 | ||
| 507 | // Shell integration, decided and written before the fork: whatever | 512 | // Shell integration, decided and written before the fork: whatever |
| @@ -534,9 +539,7 @@ pub const Server = struct { | |||
| 534 | s0.eng.deinit(); | 539 | s0.eng.deinit(); |
| 535 | } | 540 | } |
| 536 | 541 | ||
| 537 | const addr = try std.net.Address.initUnix(opts.sock_path); | 542 | const bound = try serve.bind(opts.sock_path, .{ .policy = .refuse_live }); |
| 538 | const listener = try addr.listen(.{}); | ||
| 539 | const path_id = try sockpath.PathId.of(opts.sock_path); | ||
| 540 | var srv: Server = .{ | 543 | var srv: Server = .{ |
| 541 | .alloc = alloc, | 544 | .alloc = alloc, |
| 542 | .spawn_plan = plan, | 545 | .spawn_plan = plan, |
| @@ -544,9 +547,8 @@ pub const Server = struct { | |||
| 544 | .spawn_shell_integration = opts.shell_integration, | 547 | .spawn_shell_integration = opts.shell_integration, |
| 545 | .spawn_extra_env = opts.extra_env, | 548 | .spawn_extra_env = opts.extra_env, |
| 546 | .version = opts.version, | 549 | .version = opts.version, |
| 547 | .listener = listener, | 550 | .bound = bound, |
| 548 | .sock_path = opts.sock_path, | 551 | .sock_path = opts.sock_path, |
| 549 | .path_id = path_id, | ||
| 550 | .shellint_arena = shellint_arena, | 552 | .shellint_arena = shellint_arena, |
| 551 | .shellint_dir = plan.shellint_dir, | 553 | .shellint_dir = plan.shellint_dir, |
| 552 | .agents = .{ .dir = agent_dir }, | 554 | .agents = .{ .dir = agent_dir }, |
| @@ -617,15 +619,12 @@ pub const Server = struct { | |||
| 617 | .spawn_shell_integration = d.shell_integration, | 619 | .spawn_shell_integration = d.shell_integration, |
| 618 | .spawn_extra_env = extra_env, | 620 | .spawn_extra_env = extra_env, |
| 619 | .version = version, | 621 | .version = version, |
| 620 | // The fd is the listener: no bind, no listen, no claim. The | 622 | // The fd is the listener: no bind, no listen, no claim. A claim |
| 621 | // address is rebuilt from the path only because std.net.Server | 623 | // here would probe the path, find our own inherited listener |
| 622 | // carries one; nothing reads it back. | 624 | // answering, and refuse the daemon its own socket. `adopt` only |
| 623 | .listener = .{ | 625 | // re-stamps the id from the file as found. |
| 624 | .listen_address = try std.net.Address.initUnix(sock_path), | 626 | .bound = try serve.adopt(d.listener_fd, sock_path), |
| 625 | .stream = .{ .handle = d.listener_fd }, | ||
| 626 | }, | ||
| 627 | .sock_path = sock_path, | 627 | .sock_path = sock_path, |
| 628 | .path_id = try sockpath.PathId.of(sock_path), | ||
| 629 | .shellint_arena = shellint_arena, | 628 | .shellint_arena = shellint_arena, |
| 630 | .shellint_dir = injection.dir, | 629 | .shellint_dir = injection.dir, |
| 631 | .agents = .{ .dir = agent_dir }, | 630 | .agents = .{ .dir = agent_dir }, |
| @@ -807,15 +806,9 @@ pub const Server = struct { | |||
| 807 | .borrowed => {}, | 806 | .borrowed => {}, |
| 808 | .none => {}, | 807 | .none => {}, |
| 809 | } | 808 | } |
| 810 | // Unlink only if the path still names *our* socket: a newer daemon | 809 | // Close, then unlink only if the path still names *our* socket — the |
| 811 | // may have replaced the file, and deleting that one would steal its | 810 | // guard and its rationale live in serve.Bound.close now. |
| 812 | // clients. The stat comes AFTER the close, because a successor only | 811 | self.bound.close(self.sock_path); |
| 813 | // claims once nothing is listening — that narrows the race to the | ||
| 814 | // stat→unlink gap, which is the floor Linux gives for deleting by name. | ||
| 815 | self.listener.deinit(); | ||
| 816 | if (self.path_id.stillAt(self.sock_path)) { | ||
| 817 | std.fs.cwd().deleteFile(self.sock_path) catch {}; | ||
| 818 | } | ||
| 819 | // Two passes over one deadline: every child is asked to go before any | 812 | // Two passes over one deadline: every child is asked to go before any |
| 820 | // is waited on, so a table of shells that ignore TERM costs one | 813 | // is waited on, so a table of shells that ignore TERM costs one |
| 821 | // `term_grace_ms` and not one each. | 814 | // `term_grace_ms` and not one each. |
| @@ -871,7 +864,7 @@ pub const Server = struct { | |||
| 871 | for (&self.sessions.table, 0..) |*slot, si| { | 864 | for (&self.sessions.table, 0..) |*slot, si| { |
| 872 | fds[si] = pollIn(if (slot.*) |*s| s.pty.master else -1); | 865 | fds[si] = pollIn(if (slot.*) |*s| s.pty.master else -1); |
| 873 | } | 866 | } |
| 874 | fds[listener_idx] = pollIn(self.listener.stream.handle); | 867 | fds[listener_idx] = pollIn(self.bound.fd); |
| 875 | for (&self.clients, 0..) |*slot, i| { | 868 | for (&self.clients, 0..) |*slot, i| { |
| 876 | if (slot.*) |*c| { | 869 | if (slot.*) |*c| { |
| 877 | // POLLOUT only while something is owed: asking for it on an | 870 | // POLLOUT only while something is owed: asking for it on an |
| @@ -1033,21 +1026,27 @@ pub const Server = struct { | |||
| 1033 | } | 1026 | } |
| 1034 | 1027 | ||
| 1035 | fn acceptConn(self: *Server) void { | 1028 | fn acceptConn(self: *Server) void { |
| 1036 | const conn = self.listener.accept() catch return; | 1029 | // CLOEXEC on the accepted fd, which is what std.net.Server.accept |
| 1030 | // did here before: the daemon forks a shell per session and a live | ||
| 1031 | // client connection leaked into one outlives every close of ours. | ||
| 1032 | // Client connections are also the fds `mux d upgrade` DROPS, so | ||
| 1033 | // unlike the listener this one has no reason to cross an exec. | ||
| 1034 | const fd = std.posix.accept(self.bound.fd, null, null, std.posix.SOCK.CLOEXEC) catch return; | ||
| 1035 | const conn: std.net.Stream = .{ .handle = fd }; | ||
| 1037 | // Nonblocking from the accept, and through the promotion into a | 1036 | // Nonblocking from the accept, and through the promotion into a |
| 1038 | // client slot: a blocking fd lets one peer that stops reading stop the | 1037 | // client slot: a blocking fd lets one peer that stops reading stop the |
| 1039 | // daemon's only loop. Every write here tolerates a short write. | 1038 | // daemon's only loop. Every write here tolerates a short write. |
| 1040 | setNonblocking(conn.stream.handle) catch { | 1039 | setNonblocking(conn.handle) catch { |
| 1041 | conn.stream.close(); | 1040 | conn.close(); |
| 1042 | return; | 1041 | return; |
| 1043 | }; | 1042 | }; |
| 1044 | for (&self.observers) |*slot| { | 1043 | for (&self.observers) |*slot| { |
| 1045 | if (slot.* == null) { | 1044 | if (slot.* == null) { |
| 1046 | slot.* = .{ .fd = conn.stream.handle, .since_ms = monoMs() }; | 1045 | slot.* = .{ .fd = conn.handle, .since_ms = monoMs() }; |
| 1047 | return; | 1046 | return; |
| 1048 | } | 1047 | } |
| 1049 | } | 1048 | } |
| 1050 | conn.stream.close(); // out of slots | 1049 | conn.close(); // out of slots |
| 1051 | } | 1050 | } |
| 1052 | 1051 | ||
| 1053 | fn setNonblocking(fd: std.posix.fd_t) !void { | 1052 | fn setNonblocking(fd: std.posix.fd_t) !void { |
| @@ -2773,7 +2772,7 @@ pub const Server = struct { | |||
| 2773 | .writer_version = writer_version, | 2772 | .writer_version = writer_version, |
| 2774 | .writer_path = writer_path, | 2773 | .writer_path = writer_path, |
| 2775 | .sock_path = self.sock_path, | 2774 | .sock_path = self.sock_path, |
| 2776 | .listener_fd = self.listener.stream.handle, | 2775 | .listener_fd = self.bound.fd, |
| 2777 | .shellint_dir = self.shellint_dir, | 2776 | .shellint_dir = self.shellint_dir, |
| 2778 | .agent_dir = self.agents.dir, | 2777 | .agent_dir = self.agents.dir, |
| 2779 | .shell = self.spawn_shell, | 2778 | .shell = self.spawn_shell, |
| @@ -2952,7 +2951,7 @@ pub const Server = struct { | |||
| 2952 | /// The flag back on every fd `execUpgrade` cleared; the memfd it also | 2951 | /// The flag back on every fd `execUpgrade` cleared; the memfd it also |
| 2953 | /// cleared is the caller's to close. | 2952 | /// cleared is the caller's to close. |
| 2954 | pub fn sealAdoptedFds(self: *Server) void { | 2953 | pub fn sealAdoptedFds(self: *Server) void { |
| 2955 | sealFd(self.listener.stream.handle); | 2954 | sealFd(self.bound.fd); |
| 2956 | if (self.quicListener()) |q| sealFd(q.fd); | 2955 | if (self.quicListener()) |q| sealFd(q.fd); |
| 2957 | for (&self.sessions.table) |*slot| { | 2956 | for (&self.sessions.table) |*slot| { |
| 2958 | if (slot.*) |*s| { | 2957 | if (slot.*) |*s| { |
| @@ -2989,7 +2988,7 @@ pub const Server = struct { | |||
| 2989 | // failure that took an early exit. | 2988 | // failure that took an early exit. |
| 2990 | defer restoreCloexec(cleared.items); | 2989 | defer restoreCloexec(cleared.items); |
| 2991 | 2990 | ||
| 2992 | const listener_fd = self.listener.stream.handle; | 2991 | const listener_fd = self.bound.fd; |
| 2993 | clearCloexec(listener_fd) catch return; | 2992 | clearCloexec(listener_fd) catch return; |
| 2994 | cleared.append(a, listener_fd) catch return; | 2993 | cleared.append(a, listener_fd) catch return; |
| 2995 | 2994 | ||
src/server/server_test_agent.zig
| Old | New | ||
|---|---|---|---|
| @@ -575,7 +575,7 @@ test "Server: a client closing a channel hangs up the agent connection without a | |||
| 575 | try std.testing.expect(echo == null); | 575 | try std.testing.expect(echo == null); |
| 576 | } | 576 | } |
| 577 | 577 | ||
| 578 | test "Server: an agent listener and the connections it accepts are close-on-exec" { | 578 | test "Server: the daemon listener, the clients it accepts, an agent listener and its channels are close-on-exec" { |
| 579 | const alloc = std.testing.allocator; | 579 | const alloc = std.testing.allocator; |
| 580 | 580 | ||
| 581 | var td = try h.TestDaemon.init(alloc, "agentcloexec", .{ .shell = "/bin/cat" }); | 581 | var td = try h.TestDaemon.init(alloc, "agentcloexec", .{ .shell = "/bin/cat" }); |
| @@ -603,6 +603,19 @@ test "Server: an agent listener and the connections it accepts are close-on-exec | |||
| 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); |
| 606 | |||
| 607 | // The daemon's own listener and the client connection it accepted, on | ||
| 608 | // the same grounds and unpinned until now: `serve.bind` and the raw | ||
| 609 | // `posix.accept` that replaced `std.net` both have to be ASKED for this | ||
| 610 | // flag, where std.net set it unconditionally, so a conversion can drop | ||
| 611 | // it and stay green everywhere else. | ||
| 612 | try std.testing.expect(try std.posix.fcntl(td.srv.bound.fd, std.posix.F.GETFD, 0) & flags != 0); | ||
| 613 | const seated = td.srv.clients[0] orelse return error.ClientNeverSeated; | ||
| 614 | const seated_fd = switch (seated.sink) { | ||
| 615 | .socket => |fd| fd, | ||
| 616 | .quic => return error.NotAUnixClient, | ||
| 617 | }; | ||
| 618 | try std.testing.expect(try std.posix.fcntl(seated_fd, std.posix.F.GETFD, 0) & flags != 0); | ||
| 606 | } | 619 | } |
| 607 | 620 | ||
| 608 | test "Server: a client's agent channels die with the client" { | 621 | test "Server: a client's agent channels die with the client" { |
src/server/server_test_upgrade.zig
| Old | New | ||
|---|---|---|---|
| @@ -315,7 +315,7 @@ test "sealAdoptedFds: the adopted fds are CLOEXEC again, and not one step before | |||
| 315 | 315 | ||
| 316 | // What execUpgrade does on the way out: the flag is cleared so the fds | 316 | // What execUpgrade does on the way out: the flag is cleared so the fds |
| 317 | // cross the exec. The adopting side must put it back. | 317 | // cross the exec. The adopting side must put it back. |
| 318 | try Server.clearCloexec(srv.listener.stream.handle); | 318 | try Server.clearCloexec(srv.bound.fd); |
| 319 | try Server.clearCloexec(srv.sessions.table[0].?.pty.master); | 319 | try Server.clearCloexec(srv.sessions.table[0].?.pty.master); |
| 320 | if (srv.sessions.table[0].?.agent_listener != -1) | 320 | if (srv.sessions.table[0].?.agent_listener != -1) |
| 321 | try Server.clearCloexec(srv.sessions.table[0].?.agent_listener); | 321 | try Server.clearCloexec(srv.sessions.table[0].?.agent_listener); |
| @@ -348,13 +348,13 @@ test "sealAdoptedFds: the adopted fds are CLOEXEC again, and not one step before | |||
| 348 | // these very descriptors, and a flag set here closes them at that exec. | 348 | // these very descriptors, and a flag set here closes them at that exec. |
| 349 | // Flagged in initFromManifest, this test was green and the rolled-back | 349 | // Flagged in initFromManifest, this test was green and the rolled-back |
| 350 | // daemon panicked adopting a manifest naming fds it no longer had. | 350 | // daemon panicked adopting a manifest naming fds it no longer had. |
| 351 | try std.testing.expect(!try hasCloexec(srv2.listener.stream.handle)); | 351 | try std.testing.expect(!try hasCloexec(srv2.bound.fd)); |
| 352 | try std.testing.expect(!try hasCloexec(s2.pty.master)); | 352 | try std.testing.expect(!try hasCloexec(s2.pty.master)); |
| 353 | if (s2.agent_listener != -1) try std.testing.expect(!try hasCloexec(s2.agent_listener)); | 353 | if (s2.agent_listener != -1) try std.testing.expect(!try hasCloexec(s2.agent_listener)); |
| 354 | 354 | ||
| 355 | srv2.sealAdoptedFds(); | 355 | srv2.sealAdoptedFds(); |
| 356 | 356 | ||
| 357 | try std.testing.expect(try hasCloexec(srv2.listener.stream.handle)); | 357 | try std.testing.expect(try hasCloexec(srv2.bound.fd)); |
| 358 | try std.testing.expect(try hasCloexec(s2.pty.master)); | 358 | try std.testing.expect(try hasCloexec(s2.pty.master)); |
| 359 | if (s2.agent_listener != -1) try std.testing.expect(try hasCloexec(s2.agent_listener)); | 359 | if (s2.agent_listener != -1) try std.testing.expect(try hasCloexec(s2.agent_listener)); |
| 360 | } | 360 | } |