a73x

a514d02b

refactor: the shared root asks the OS in portable words, and sockpath owns the runtime dir

a73x   2026-09-03 15:10

Commit message
refactor: the shared root asks the OS in portable words, and sockpath owns the runtime dir

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

CLAUDE.md
Old New
@@ -261,7 +261,7 @@ own. Test fixtures in `test/`:
261 directories and a wall's `mux-ask-PID.sock` are unlinked by their owner 261 directories and a wall's `mux-ask-PID.sock` are unlinked by their owner
262 on the normal way out; a SIGKILL or a closed terminal window runs 262 on the normal way out; a SIGKILL or a closed terminal window runs
263 nothing, so the next daemon or wall to create one in that directory 263 nothing, so the next daemon or wall to create one in that directory
264 calls `xdg.reapDeadPid` first and removes every entry whose pid `/proc` 264 calls `xdg.reapDeadPid` first and removes every entry whose pid the OS
265 no longer has. A live pid's entry stays even when it is no longer a mux. 265 no longer has. A live pid's entry stays even when it is no longer a mux.
266 - **A daemon lives until `mux d stop`; emptiness is not an exit.** `x` ends a 266 - **A daemon lives until `mux d stop`; emptiness is not an exit.** `x` ends a
267 session, never a box: `reap` and `pumpOnce` answer nothing, a shell's code 267 session, never a box: `reap` and `pumpOnce` answer nothing, a shell's code
build.zig
Old New
@@ -229,7 +229,7 @@ const mod_table = [_]ModSpec{
229 // sits here rather than in either front so the CLI wall and the browser 229 // sits here rather than in either front so the CLI wall and the browser
230 // hub resolve a host line the same way. Nothing here WRITES that file — 230 // hub resolve a host line the same way. Nothing here WRITES that file —
231 // `wall_host.recordHost` and `webhub_main` do. 231 // `wall_host.recordHost` and `webhub_main` do.
232 .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "quic", "xdg", "sockpath", "serve", "dial", "link", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 232 .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "quic", "xdg", "serve", "dial", "link", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
233 // ---- the two fronts ---- 233 // ---- the two fronts ----
234 // The browser hub's HTTP/WebSocket decisions: Origin gate, route table, 234 // The browser hub's HTTP/WebSocket decisions: Origin gate, route table,
235 // WS endpoint naming. Assets are injected (the exe root @embedFiles 235 // WS endpoint naming. Assets are injected (the exe root @embedFiles
@@ -252,7 +252,7 @@ const mod_table = [_]ModSpec{
252 // loop's alone, which the root never spells, and the painter takes 252 // loop's alone, which the root never spells, and the painter takes
253 // `term.engine` and `term.protocol`; the decoder and the key table they 253 // `term.engine` and `term.protocol`; the decoder and the key table they
254 // also want reach them through `client`'s seams. 254 // also want reach them through `client`'s seams.
255 .{ .name = "wall", .path = "src/tui/wallview.zig", .link_libc = true, .imports = &.{ "term", "client", "proxy", "spawn", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 255 .{ .name = "wall", .path = "src/tui/wallview.zig", .link_libc = true, .imports = &.{ "term", "client", "proxy", "spawn", "client_os", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
256 // ---- the one binary ---- 256 // ---- the one binary ----
257 // Four words, one image — and one row: the daemon's entrypoint, the 257 // Four words, one image — and one row: the daemon's entrypoint, the
258 // client's and the hub's are CHILD FILES of the dispatcher, so a second 258 // client's and the hub's are CHILD FILES of the dispatcher, so a second
src/client/client.zig
Old New
@@ -12,7 +12,6 @@ const proto = @import("term").protocol;
12 const TmpDir = @import("testtmp").TmpDir; 12 const TmpDir = @import("testtmp").TmpDir;
13 const quic = @import("quic"); 13 const quic = @import("quic");
14 const xdg = @import("xdg"); 14 const xdg = @import("xdg");
15 const sockpath = @import("sockpath");
16 // The daemon-socket dial, under a name that is not `dial`: `Transport.open` 15 // The daemon-socket dial, under a name that is not `dial`: `Transport.open`
17 // already takes a `dial: ?*handoff.Dial` parameter, and a parameter that 16 // already takes a `dial: ?*handoff.Dial` parameter, and a parameter that
18 // shadows a file-scope declaration does not compile. 17 // shadows a file-scope declaration does not compile.
@@ -2379,8 +2378,10 @@ test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays
2379 // A hosts line naming a box that is down used to put ssh's `No route to 2378 // A hosts line naming a box that is down used to put ssh's `No route to
2380 // host` onto the wall's alternate screen every poll, because the child's 2379 // host` onto the wall's alternate screen every poll, because the child's
2381 // stderr was INHERITED. It is a pipe mux reads now, whoever dialled. The 2380 // stderr was INHERITED. It is a pipe mux reads now, whoever dialled. The
2382 // fake records where its stderr POINTED, off `/proc/$$/fd/2`, so "piped" is 2381 // fake records where its stderr POINTED, off `/dev/fd/2`, so "piped" is
2383 // exact. BOTH values of `asked`, because the rule is the spawn's. 2382 // exact — `/dev/fd` because every OS this builds for has it and the
2383 // Linux-only spelling would have to be ported alongside the test.
2384 // BOTH values of `asked`, because the rule is the spawn's.
2384 const alloc = std.testing.allocator; 2385 const alloc = std.testing.allocator;
2385 var stdin = try FakeStdin.install(""); 2386 var stdin = try FakeStdin.install("");
2386 defer stdin.deinit(); 2387 defer stdin.deinit();
@@ -2393,7 +2394,7 @@ test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays
2393 2394
2394 var script_buf: [1024]u8 = undefined; 2395 var script_buf: [1024]u8 = undefined;
2395 const script = try std.fmt.bufPrint(&script_buf, 2396 const script = try std.fmt.bufPrint(&script_buf,
2396 \\readlink /proc/$$/fd/2 > {[d]s}/e 2397 \\readlink /dev/fd/2 > {[d]s}/e
2397 \\printf 'boom: no route\n' >&2 2398 \\printf 'boom: no route\n' >&2
2398 \\exit 1 2399 \\exit 1
2399 , .{ .d = tmp.path() }); 2400 , .{ .d = tmp.path() });
src/sockpath.zig
Old New
@@ -4,12 +4,13 @@
4 //! other refuses to delete a successor's socket. Nothing here knows a Server 4 //! other refuses to delete a successor's socket. Nothing here knows a Server
5 //! exists; a path is all it takes. 5 //! exists; a path is all it takes.
6 const std = @import("std"); 6 const std = @import("std");
7 const builtin = @import("builtin");
7 8
8 /// The usable bytes of `sockaddr_un.sun_path`: the field is 108 and the 9 /// The usable bytes of `sockaddr_un.sun_path`: the field less the NUL.
9 /// last one belongs to the NUL. Private, and that is the point — the 10 /// Derived from the kernel's own struct rather than spelled — 108 on
10 /// number is the kernel's, and every binary that once re-compared it 11 /// Linux, 104 on the BSDs — and private, because every binary that once
11 /// grew its own wording for the same refusal. 12 /// re-compared it grew its own wording for the same refusal.
12 const max_sun_path = 107; 13 const max_sun_path = @sizeOf(@FieldType(std.posix.sockaddr.un, "path")) - 1;
13 14
14 /// The refusal, for whoever is about to BIND. Everyone else dials and 15 /// The refusal, for whoever is about to BIND. Everyone else dials and
15 /// reads the kernel's own `NameTooLong`. 16 /// reads the kernel's own `NameTooLong`.
@@ -42,10 +43,21 @@ pub fn defaultOrExplain(alloc: std.mem.Allocator, prefix: []const u8) !?[]const
42 }; 43 };
43 } 44 }
44 45
45 /// No fallback when `$XDG_RUNTIME_DIR` is unset: a guess cannot make two 46 /// The directory the default daemon socket and every per-wall socket live
46 /// binaries agree on one daemon, so the caller names it. 47 /// in, or null. On Linux that is `$XDG_RUNTIME_DIR` and there is NO
48 /// fallback: a guess cannot make two binaries agree on one daemon, so the
49 /// caller names it with --sock. Another OS spells its own default here,
50 /// once, so the daemon, the client and the askpass listener agree by
51 /// construction.
52 pub fn runtimeDir() ?[]const u8 {
53 return switch (builtin.os.tag) {
54 .linux => std.posix.getenv("XDG_RUNTIME_DIR"),
55 else => @compileError("mux has no default runtime directory for " ++ @tagName(builtin.os.tag)),
56 };
57 }
58
47 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { 59 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
48 return sockPathFrom(alloc, std.posix.getenv("XDG_RUNTIME_DIR")); 60 return sockPathFrom(alloc, runtimeDir());
49 } 61 }
50 62
51 /// `defaultSockPath` with its environment named rather than read, because 63 /// `defaultSockPath` with its environment named rather than read, because
@@ -128,6 +140,16 @@ pub fn claim(path: []const u8) !void {
128 }; 140 };
129 } 141 }
130 142
143 test "sockpath.max_sun_path is the kernel's field less its NUL, not a number of ours" {
144 try std.testing.expectEqual(@sizeOf(@FieldType(std.posix.sockaddr.un, "path")) - 1, max_sun_path);
145
146 // And on the one OS mux runs on today the derivation must still land on
147 // the number the comments and the e2e scripts reason about. Without
148 // this line the assertion above is a tautology, true of any expression
149 // the constant is spelled with.
150 if (builtin.os.tag == .linux) try std.testing.expectEqual(107, max_sun_path);
151 }
152
131 test "default path: an unset XDG_RUNTIME_DIR is refused, never guessed" { 153 test "default path: an unset XDG_RUNTIME_DIR is refused, never guessed" {
132 const alloc = std.testing.allocator; 154 const alloc = std.testing.allocator;
133 155
src/tui/wallview.zig
Old New
@@ -19,6 +19,10 @@ const select = @import("select.zig");
19 // client: one interaction core, not a second copy (interact.zig). 19 // client: one interaction core, not a second copy (interact.zig).
20 const interact = @import("interact.zig"); 20 const interact = @import("interact.zig");
21 const layout = @import("client").layout; 21 const layout = @import("client").layout;
22 // Where a per-wall socket lives, asked of the row that owns the answer for
23 // the daemon socket too: one spelling of the runtime directory, not a
24 // second getenv beside it.
25 const sockpath = @import("sockpath");
22 const TmpDir = @import("testtmp").TmpDir; 26 const TmpDir = @import("testtmp").TmpDir;
23 const wall_host = @import("wall_host.zig"); 27 const wall_host = @import("wall_host.zig");
24 const wall_layout = @import("wall_layout.zig"); 28 const wall_layout = @import("wall_layout.zig");
@@ -180,8 +184,9 @@ pub const Shared = struct {
180 /// arrive with the picker already up. 184 /// arrive with the picker already up.
181 ask_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), 185 ask_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
182 /// Where this wall answers ssh's prompts, and the binary ssh execs to 186 /// Where this wall answers ssh's prompts, and the binary ssh execs to
183 /// ask. Null is "ssh keeps its own prompts": without `$XDG_RUNTIME_DIR` 187 /// ask. Null is "ssh keeps its own prompts": without the runtime
184 /// the socket would land on a shared /tmp, answerable by any local user. 188 /// directory `sockpath.runtimeDir` names, the socket would land on a
189 /// shared /tmp, answerable by any local user.
185 prompts: ?*askpass.Listener = null, 190 prompts: ?*askpass.Listener = null,
186 prompt_exe: []const u8 = "", 191 prompt_exe: []const u8 = "",
187 192
@@ -1861,11 +1866,13 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1861 1866
1862 // ssh's prompts get somewhere to go, on a TERMINAL only: without one 1867 // ssh's prompts get somewhere to go, on a TERMINAL only: without one
1863 // there is no popup, so ssh keeps its prompts and fails them visibly. 1868 // there is no popup, so ssh keeps its prompts and fails them visibly.
1864 // `$XDG_RUNTIME_DIR` and no fallback: a shared /tmp socket would be a 1869 // The runtime directory and no fallback: a shared /tmp socket would be
1865 // password prompt any local user could answer. 1870 // a password prompt any local user could answer. `sockpath.runtimeDir`
1871 // is the one place that names it, so the popup lands beside the daemon
1872 // socket on whatever OS this is.
1866 var ask_exe_buf: [std.fs.max_path_bytes]u8 = undefined; 1873 var ask_exe_buf: [std.fs.max_path_bytes]u8 = undefined;
1867 if (is_tty) { 1874 if (is_tty) {
1868 if (std.posix.getenv("XDG_RUNTIME_DIR")) |rt| { 1875 if (sockpath.runtimeDir()) |rt| {
1869 if (askpass.Listener.start(alloc, rt, .{ 1876 if (askpass.Listener.start(alloc, rt, .{
1870 .ctx = &shared, 1877 .ctx = &shared,
1871 .wake = ringKeyboardCtx, 1878 .wake = ringKeyboardCtx,
src/xdg.zig
Old New
@@ -176,10 +176,19 @@ pub fn reapDeadPid(parent: []const u8, prefix: []const u8) void {
176 // else's files, not a pid we can ask about. 176 // else's files, not a pid we can ask about.
177 if (n == 0) continue; 177 if (n == 0) continue;
178 if (n < rest.len and rest[n] != '-' and rest[n] != '.') continue; 178 if (n < rest.len and rest[n] != '-' and rest[n] != '.') continue;
179 const pid = std.fmt.parseInt(u32, rest[0..n], 10) catch continue; 179 // Parsed as the type `kill` takes, so a digit run too long to BE a
180 var buf: [32]u8 = undefined; 180 // pid leaves with every other name we cannot ask about. Widening
181 const proc = std.fmt.bufPrint(&buf, "/proc/{d}", .{pid}) catch continue; 181 // first and narrowing at the call would panic on the cast instead,
182 const alive = if (std.fs.cwd().access(proc, .{})) true else |_| false; 182 // in a function whose contract is to reap nothing it cannot judge.
183 const pid = std.fmt.parseInt(std.posix.pid_t, rest[0..n], 10) catch continue;
184 // `kill(pid, 0)`: ESRCH — `error.ProcessNotFound` — is the ONE answer
185 // that means the pid is gone. Every other answer keeps the entry:
186 // EPERM says alive-but-not-ours, and an errno neither this Zig nor
187 // this kernel version has a name for says the OS would not answer,
188 // which is not evidence of death. Deleting on "don't know" would
189 // remove a live daemon's agent socket out from under it. A live
190 // pid's entry stays even when it is no longer a mux.
191 const alive = if (std.posix.kill(pid, 0)) true else |err| err != error.ProcessNotFound;
183 if (alive) continue; 192 if (alive) continue;
184 d.deleteTree(entry.name) catch {}; 193 d.deleteTree(entry.name) catch {};
185 } 194 }
@@ -420,8 +429,19 @@ test "reapDeadPid: a dead owner's entry goes; a live owner's, a stranger's and a
420 try tmp.dir.writeFile(.{ .sub_path = try std.fmt.bufPrint(&inner, "{s}/agent-0.sock", .{dead_dir}), .data = "" }); 429 try tmp.dir.writeFile(.{ .sub_path = try std.fmt.bufPrint(&inner, "{s}/agent-0.sock", .{dead_dir}), .data = "" });
421 try tmp.dir.writeFile(.{ .sub_path = dead_sock, .data = "" }); 430 try tmp.dir.writeFile(.{ .sub_path = dead_sock, .data = "" });
422 try tmp.dir.makePath(ours); 431 try tmp.dir.makePath(ours);
432 // A pid that is alive but is not a mux — pid 1 — keeps its entry. The
433 // liveness question is `kill(pid, 0)`, which answers for every process
434 // this uid may signal and EPERM for the ones it may not; both are alive,
435 // and pid 1 is the EPERM case for every unprivileged run of this suite.
436 // ESRCH is the only answer that removes anything, so an errno with no
437 // name in this Zig — `error.Unexpected` — keeps the entry too. That case
438 // has no cheap fixture: no signal this test can send produces it.
423 try tmp.dir.makePath("mux-agent-1-abc"); // pid 1 is alive in every pid namespace 439 try tmp.dir.makePath("mux-agent-1-abc"); // pid 1 is alive in every pid namespace
424 try tmp.dir.makePath("mux-agent-abc"); 440 try tmp.dir.makePath("mux-agent-abc");
441 // Digits that no pid can hold. `kill` takes an i32, so this is a name
442 // to leave alone exactly like `mux-agent-abc`; reading it as a wide
443 // integer and narrowing at the call would abort the whole reap here.
444 try tmp.dir.makePath("mux-agent-3000000000-x");
425 try tmp.dir.makePath(not_a_pid); 445 try tmp.dir.makePath(not_a_pid);
426 446
427 reapDeadPid(tmp.path(), "mux-agent-"); 447 reapDeadPid(tmp.path(), "mux-agent-");
@@ -430,6 +450,7 @@ test "reapDeadPid: a dead owner's entry goes; a live owner's, a stranger's and a
430 try tmp.dir.access(ours, .{}); 450 try tmp.dir.access(ours, .{});
431 try tmp.dir.access("mux-agent-1-abc", .{}); 451 try tmp.dir.access("mux-agent-1-abc", .{});
432 try tmp.dir.access("mux-agent-abc", .{}); 452 try tmp.dir.access("mux-agent-abc", .{});
453 try tmp.dir.access("mux-agent-3000000000-x", .{});
433 try tmp.dir.access(not_a_pid, .{}); 454 try tmp.dir.access(not_a_pid, .{});
434 455
435 reapDeadPid(tmp.path(), "mux-ask-"); 456 reapDeadPid(tmp.path(), "mux-ask-");