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
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-"); |