a73x

0698569c

refactor: the sun_path bound belongs to the one binder

a73x   2026-08-30 08:11

Commit message
refactor: the sun_path bound belongs to the one binder

Four binaries re-compared `max_sun_path` in four wordings, and none of
them binds anything. The kernel's number is private to `sockpath` now and
`mux d` is the only caller of `tooLong` — the one place a refusal saves
work, because it is the one place about to bind. Everyone else dials and
reads `NameTooLong` off the connect, which truncates nothing and never
did: the picker row quotes it, `mux hosts` prints it, `mux a` fails with
it in its own JSON voice.

`sockpath.answers` is the connect probe, lifted out of `claim` and
shared. `mux hosts add`'s add-time refusal stays, asked of the owner.

The e2e pin that read the client's own wording now reads the daemon's, on
the client's stderr, inside a second and with no `starting` line in front
of it: the relay is what the deleted comparison used to buy.

src/cli/mux_main.zig
Old New
@@ -361,7 +361,7 @@ pub fn main(args: []const [:0]const u8) !u8 {
361 /// Whether the wall has to start the local daemon itself. Asked of the OS, 361 /// Whether the wall has to start the local daemon itself. Asked of the OS,
362 /// not of the file: the daemon dies on every reboot while its line lives on. 362 /// not of the file: the daemon dies on every reboot while its line lives on.
363 fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool { 363 fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool {
364 var buf: [sockpath.max_sun_path + "--sock ".len]u8 = undefined; 364 var buf: [std.fs.max_path_bytes + "--sock ".len]u8 = undefined;
365 const line = std.fmt.bufPrint(&buf, "--sock {s}", .{sock}) catch return false; 365 const line = std.fmt.bufPrint(&buf, "--sock {s}", .{sock}) catch return false;
366 return h.has(line) and !sockpath.answers(sock); 366 return h.has(line) and !sockpath.answers(sock);
367 } 367 }
@@ -392,13 +392,6 @@ fn attachLocal(
392 return 2; 392 return 2;
393 } 393 }
394 394
395 // WHERE the shared guard (sockpath.tooLong) sits is this binary's
396 // decision: before the auto-start, not at the connect. Otherwise mux
397 // forks a daemon that refuses the path instantly, then polls the full
398 // 2s into "daemon did not answer" — a timeout story about a path that
399 // was doomed at parse.
400 if (sockpath.tooLong("mux", sock_path)) return 1;
401
402 if (!sockpath.answers(sock_path) and !try startLocalDaemon(alloc, sock_path)) return 1; 395 if (!sockpath.answers(sock_path) and !try startLocalDaemon(alloc, sock_path)) return 1;
403 return wallview.runAttach( 396 return wallview.runAttach(
404 alloc, 397 alloc,
@@ -714,11 +707,9 @@ fn hostsAdd(arena: std.mem.Allocator, spellings: []const []const u8, path: []con
714 // The one refusal that belongs to the transport rather than the 707 // The one refusal that belongs to the transport rather than the
715 // grammar: a sun_path that cannot be bound is a host that could 708 // grammar: a sun_path that cannot be bound is a host that could
716 // never dial, and add time is the only moment the user is still 709 // never dial, and add time is the only moment the user is still
717 // looking at what they typed. 710 // looking at what they typed. Asked of the owner, in the owner's
718 if (spec == .sock and spec.sock.len > sockpath.max_sun_path) { 711 // words — the bound is the kernel's and lives in one place.
719 std.debug.print("mux hosts add: {s}: {s}\n", .{ s, hosts.reason(error.SockPathTooLong) }); 712 if (spec == .sock and sockpath.tooLong("mux hosts add", spec.sock)) return 2;
720 return 2;
721 }
722 } 713 }
723 // Strict: growing a file whose existing content is not understood would 714 // Strict: growing a file whose existing content is not understood would
724 // re-save garbage as if it had been read. 715 // re-save garbage as if it had been read.
@@ -922,12 +913,12 @@ test "hosts list: a line longer than the row buffer is still shown, because rm m
922 var buf: [512]u8 = undefined; 913 var buf: [512]u8 = undefined;
923 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); 914 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
924 915
925 // A `--sock` path no `sun_path` could hold: refused at usage altitude, 916 // A host the grammar refuses, so the row is produced without a dial
926 // so the row is produced without a dial and the check is about the 917 // and the check is about the printing and nothing else. Long by the
927 // printing and nothing else. 918 // host name, which is the half a fixed row buffer would have cut.
928 var long: [900]u8 = undefined; 919 var long: [900]u8 = undefined;
929 @memcpy(long[0..8], "--sock /"); 920 @memset(&long, 'h');
930 @memset(long[8..], 'h'); 921 long[899] = '#';
931 var line_buf: [1024]u8 = undefined; 922 var line_buf: [1024]u8 = undefined;
932 try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "{s}\n", .{long})); 923 try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "{s}\n", .{long}));
933 924
@@ -945,7 +936,11 @@ test "hosts list: a line longer than the row buffer is still shown, because rm m
945 defer alloc.free(text); 936 defer alloc.free(text);
946 // Whole, and with its verdict: a truncated line is one `rm` cannot take. 937 // Whole, and with its verdict: a truncated line is one `rm` cannot take.
947 try std.testing.expect(std.mem.startsWith(u8, text, &long)); 938 try std.testing.expect(std.mem.startsWith(u8, text, &long));
948 try std.testing.expect(std.mem.endsWith(u8, text, "\t[bad host: socket path too long to bind]\n")); 939 try std.testing.expect(std.mem.endsWith(
940 u8,
941 text,
942 "\t[bad host: names a session after '#': the wall lists daemons and shows every session they have]\n",
943 ));
949 } 944 }
950 945
951 test "wall: a LISTED local daemon that nothing answers on is one this wall starts" { 946 test "wall: a LISTED local daemon that nothing answers on is one this wall starts" {
src/cli/muxa.zig
Old New
@@ -1136,19 +1136,6 @@ pub fn main(args: []const [:0]const u8) !u8 {
1136 else => |e| return e, 1136 else => |e| return e,
1137 }; 1137 };
1138 1138
1139 // Refused by name, before connecting: connect would bounce a too-long
1140 // path off the kernel with a generic error, and the path is the whole
1141 // story. Every binary owes this check in its own words (sockpath).
1142 if (sock_path.len > sockpath.max_sun_path) {
1143 var buf: [64]u8 = undefined;
1144 const detail = std.fmt.bufPrint(
1145 &buf,
1146 "{d} bytes, max {d}",
1147 .{ sock_path.len, sockpath.max_sun_path },
1148 ) catch "too long";
1149 return fail("socket path too long", detail);
1150 }
1151
1152 var conn = Conn.open(alloc, sock_path) catch |e| { 1139 var conn = Conn.open(alloc, sock_path) catch |e| {
1153 // The path goes in the detail: an agent client pointed at the wrong socket 1140 // The path goes in the detail: an agent client pointed at the wrong socket
1154 // is this binary's likeliest field failure, and an agent reading 1141 // is this binary's likeliest field failure, and an agent reading
src/cli/webhub_main.zig
Old New
@@ -155,13 +155,6 @@ pub fn main(args: []const [:0]const u8) !u8 {
155 ); 155 );
156 return 2; 156 return 2;
157 }, 157 },
158 error.SockPathTooLong => {
159 std.debug.print(
160 "mux web: socket path too long (max {d} bytes)\n",
161 .{sockpath.max_sun_path},
162 );
163 return 2;
164 },
165 else => return err, 158 else => return err,
166 }; 159 };
167 } 160 }
src/client/client.zig
Old New
@@ -275,14 +275,13 @@ pub const Target = union(enum) {
275 /// `asked` is required, never defaulted: see `HandoffTarget.fromRecipe`. 275 /// `asked` is required, never defaulted: see `HandoffTarget.fromRecipe`.
276 pub fn fromSpec(alloc: std.mem.Allocator, spec: hosts.Spec, key: ?[]const u8, idle_ms: u32, asked: bool) SpecError!Target { 276 pub fn fromSpec(alloc: std.mem.Allocator, spec: hosts.Spec, key: ?[]const u8, idle_ms: u32, asked: bool) SpecError!Target {
277 return switch (spec) { 277 return switch (spec) {
278 // sun_path is a fixed array in the kernel's struct: a longer 278 // No length guard here, deliberately: `Address.initUnix`
279 // path cannot be dialed at all, so it is refused here, at 279 // answers `NameTooLong` and truncates nothing, so a doomed
280 // usage altitude, rather than at a connect that fails with a 280 // path fails as a dial like any other and the picker row
281 // truncated name nobody typed. 281 // quotes the kernel's own word. The one binder is where a
282 .sock => |path| if (path.len > sockpath.max_sun_path) 282 // path is refused by name, because it is the only place a
283 error.SockPathTooLong 283 // refusal saves anything.
284 else 284 .sock => |path| .{ .sock = try alloc.dupe(u8, path) },
285 .{ .sock = try alloc.dupe(u8, path) },
286 .host => |h| blk: { 285 .host => |h| blk: {
287 const hd = try alloc.dupe(u8, h); 286 const hd = try alloc.dupe(u8, h);
288 const r = try handoff.recipeFor(alloc, hd, false); 287 const r = try handoff.recipeFor(alloc, hd, false);
@@ -320,7 +319,7 @@ pub const Target = union(enum) {
320 319
321 /// What resolving a spelling can fail at: a key that is not there to prove 320 /// What resolving a spelling can fail at: a key that is not there to prove
322 /// the dial with, and a socket path the kernel cannot hold. 321 /// the dial with, and a socket path the kernel cannot hold.
323 pub const SpecError = error{ MissingKey, SockPathTooLong, OutOfMemory }; 322 pub const SpecError = error{ MissingKey, OutOfMemory };
324 323
325 /// What the open produced — the live wire. Distinct from `Target` because a 324 /// What the open produced — the live wire. Distinct from `Target` because a
326 /// `hand` recipe yields either a quic or a pipe link, and which one is 325 /// `hand` recipe yields either a quic or a pipe link, and which one is
@@ -2775,13 +2774,3 @@ test "Target.fromSpec: a quic spelling with no key frees the path it refused" {
2775 Target.fromSpec(alloc, .{ .quic = "h:1" }, null, 30_000, false), 2774 Target.fromSpec(alloc, .{ .quic = "h:1" }, null, 30_000, false),
2776 ); 2775 );
2777 } 2776 }
2778
2779 test "Target.fromSpec: a sun_path-overflowing socket is refused at usage altitude" {
2780 // Refused where it was typed, not at a connect(2) that would bind a
2781 // truncated name nobody asked for.
2782 const long = "/" ++ "x" ** 200;
2783 try std.testing.expectError(
2784 error.SockPathTooLong,
2785 Target.fromSpec(std.testing.allocator, .{ .sock = long }, null, 30_000, false),
2786 );
2787 }
src/client/hosts.zig
Old New
@@ -100,7 +100,6 @@ pub fn reason(err: anyerror) []const u8 {
100 error.BadByte => "control byte in host", 100 error.BadByte => "control byte in host",
101 error.BadSpelling => "punctuation in host: a host line names a machine, not a command", 101 error.BadSpelling => "punctuation in host: a host line names a machine, not a command",
102 error.MissingSockPath => "names no path", 102 error.MissingSockPath => "names no path",
103 error.SockPathTooLong => "socket path too long to bind",
104 else => @errorName(err), 103 else => @errorName(err),
105 }; 104 };
106 } 105 }
src/sockpath.zig
Old New
@@ -10,15 +10,17 @@
10 const std = @import("std"); 10 const std = @import("std");
11 11
12 /// The usable bytes of `sockaddr_un.sun_path`: the field is 108 and the 12 /// The usable bytes of `sockaddr_un.sun_path`: the field is 108 and the
13 /// last one belongs to the NUL. Every binary refuses a longer path by 13 /// last one belongs to the NUL. Private, and that is the point — the
14 /// name before acting on it — the number is the kernel's and belongs in 14 /// number is the kernel's, and every binary that once re-compared it
15 /// one place. 15 /// grew its own wording for the same refusal.
16 pub const max_sun_path = 107; 16 const max_sun_path = 107;
17 17
18 /// Checked before anything acts on the path: connect would bounce a 18 /// The refusal, for whoever is about to BIND. Everyone else dials and
19 /// too-long one off the kernel with a generic error, and the path is the 19 /// reads the kernel's own `NameTooLong`.
20 /// whole story. `mux a` answers in JSON and keeps its own wording.
21 pub fn tooLong(prefix: []const u8, path: []const u8) bool { 20 pub fn tooLong(prefix: []const u8, path: []const u8) bool {
21 // Named rather than bounced off a connect: the bind would fail with a
22 // generic error, and the path is the whole story. `mux a` answers in
23 // JSON and keeps its own wording.
22 if (path.len <= max_sun_path) return false; 24 if (path.len <= max_sun_path) return false;
23 std.debug.print( 25 std.debug.print(
24 "{s}: socket path too long ({d} bytes, max {d}): {s}\n", 26 "{s}: socket path too long ({d} bytes, max {d}): {s}\n",
@@ -94,11 +96,13 @@ pub fn answers(path: []const u8) bool {
94 s.close(); 96 s.close();
95 return true; 97 return true;
96 } else |err| return switch (err) { 98 } else |err| return switch (err) {
97 // Two errors are a no; every other one is a yes. A path whose 99 // Three errors are a no: nothing at the path, nothing listening,
98 // connect failed for some third reason may still be a live 100 // and a path the kernel's `sun_path` cannot even hold — no daemon
99 // daemon's, and a false here is a licence to unlink it — the field 101 // is reachable through any of them. Every OTHER error is a yes,
100 // incident `claim` below exists for. 102 // because a connect that failed for some third reason may still be
101 error.FileNotFound, error.ConnectionRefused => false, 103 // a live daemon's, and a false here is a licence to unlink it —
104 // the field incident `claim` below exists for.
105 error.FileNotFound, error.ConnectionRefused, error.NameTooLong => false,
102 else => true, 106 else => true,
103 }; 107 };
104 } 108 }
@@ -174,6 +178,12 @@ test "answers: a live listener, a stale socket file, and a path with nothing on
174 listener.deinit(); 178 listener.deinit();
175 try std.testing.expect(!answers(path)); 179 try std.testing.expect(!answers(path));
176 try std.fs.cwd().access(path, .{}); 180 try std.fs.cwd().access(path, .{});
181
182 // A path longer than `sun_path` is a no as flatly as a missing one:
183 // nothing is listening there and nothing could be. A yes here would
184 // tell the client's attach a daemon was already up and send it
185 // straight to a dial, instead of to the one binder's refusal.
186 try std.testing.expect(!answers("/" ++ "x" ** 200));
177 } 187 }
178 188
179 test "PathId: names the file it was taken from, not the path, and not a successor" { 189 test "PathId: names the file it was taken from, not the path, and not a successor" {
src/tui/wall_test_host.zig
Old New
@@ -566,14 +566,6 @@ test "addHost: the prompt adds a DAEMON — a flag, a session and a host already
566 try std.testing.expectEqualStrings("[no room on the wall for another host]", wv.takeNotice(&shared, &buf)); 566 try std.testing.expectEqualStrings("[no room on the wall for another host]", wv.takeNotice(&shared, &buf));
567 } 567 }
568 568
569 test "resolveHost refuses a sun_path-overflowing sock path" {
570 const alloc = std.testing.allocator;
571 // Refused at usage altitude, not at a connect that fails with a
572 // truncated sun_path nobody typed.
573 const long = "--sock /" ++ "x" ** 200;
574 try std.testing.expectError(error.SockPathTooLong, client.resolveHost(alloc, long, null, 30_000));
575 }
576
577 test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cannot narrate" { 569 test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cannot narrate" {
578 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 570 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
579 defer arena.deinit(); 571 defer arena.deinit();
test/e2e_01_boot.sh
Old New
@@ -250,16 +250,31 @@ grep -qaF "$(printf '\033[22;0t')" "$OUT.dead" && {
250 echo "e2e FAIL: a client that entered nothing still pushed the title stack" 250 echo "e2e FAIL: a client that entered nothing still pushed the title stack"
251 cat -v "$OUT.dead"; exit 1; } 251 cat -v "$OUT.dead"; exit 1; }
252 252
253 # M13 spec amendment: a socket path past sun_path's 107 usable bytes is 253 # A socket path past sun_path's 107 usable bytes is refused by name, and
254 # refused by name in both binaries, before either acts on it — the failure 254 # the refusal belongs to the one binder: `mux d` reads its own flags,
255 # it replaced was a 2s poll ending in "daemon did not answer", a timeout 255 # refuses at parse and exits 1 before any fork. The client no longer
256 # story about a path that was doomed at parse. 256 # compares the length at all — it asks for a daemon and relays what the
257 # daemon says — so this is also the pin on that relay: the DAEMON's words,
258 # on the client's stderr, fast, with no `starting` line in front of them.
259 # The failure both replaced was a 2s poll ending in "daemon did not
260 # answer", a timeout story about a path doomed at parse.
257 LONGSOCK="/tmp/$(printf 'a%.0s' $(seq 1 110)).sock" 261 LONGSOCK="/tmp/$(printf 'a%.0s' $(seq 1 110)).sock"
258 "$MUX" d dump --sock "$LONGSOCK" > "$OUT.long" 2>&1 && { echo "e2e FAIL: mux d accepted long sock"; exit 1; } 262 "$MUX" d dump --sock "$LONGSOCK" > "$OUT.long" 2>&1 && { echo "e2e FAIL: mux d accepted long sock"; exit 1; }
259 grep -q "mux d: socket path too long" "$OUT.long" || { echo "e2e FAIL:"; cat "$OUT.long"; exit 1; } 263 grep -q "mux d: socket path too long" "$OUT.long" || { echo "e2e FAIL:"; cat "$OUT.long"; exit 1; }
264 LONGT0=$(date +%s)
260 "$MUX" --sock "$LONGSOCK" > "$OUT.longc" 2>&1 && { echo "e2e FAIL: mux accepted long sock"; exit 1; } 265 "$MUX" --sock "$LONGSOCK" > "$OUT.longc" 2>&1 && { echo "e2e FAIL: mux accepted long sock"; exit 1; }
261 grep -q "mux: socket path too long" "$OUT.longc" || { echo "e2e FAIL:"; cat "$OUT.longc"; exit 1; } 266 LONGEL=$(( $(date +%s) - LONGT0 ))
267 grep -q "mux d: socket path too long" "$OUT.longc" || {
268 echo "e2e FAIL: the client did not relay the daemon's refusal:"; cat "$OUT.longc"; exit 1; }
262 grep -q "daemon did not answer" "$OUT.longc" && { echo "e2e FAIL: still the timeout story"; cat "$OUT.longc"; exit 1; } 269 grep -q "daemon did not answer" "$OUT.longc" && { echo "e2e FAIL: still the timeout story"; cat "$OUT.longc"; exit 1; }
270 # No `starting` line: the daemon refuses at parse, so nothing was ever
271 # forked and nothing should have announced that it was about to be. This
272 # is what a re-comparison in the client used to buy, and what the owner's
273 # own refusal buys now.
274 grep -q "starting" "$OUT.longc" && {
275 echo "e2e FAIL: a doomed path still announced a start:"; cat "$OUT.longc"; exit 1; }
276 [ "$LONGEL" -le 1 ] || {
277 echo "e2e FAIL: a doomed --sock took ${LONGEL}s to be refused (want <=1)"; exit 1; }
263 "$MUX" d --version --sock "$LONGSOCK" >/dev/null 2>&1 || { echo "e2e FAIL: --version refused over sock length"; exit 1; } 278 "$MUX" d --version --sock "$LONGSOCK" >/dev/null 2>&1 || { echo "e2e FAIL: --version refused over sock length"; exit 1; }
264 279
265 rm_swept "$OUT.dead" "$OUT.long" "$OUT.longc" 280 rm_swept "$OUT.dead" "$OUT.long" "$OUT.longc"