a73x

1b4ed4a8

refactor: one owner turns a spelling into a Target

a73x   2026-08-29 10:01

Commit message
refactor: one owner turns a spelling into a Target

`wall_host.resolveHost` and `webhub.resolveTile` had the same three arms,
the same sun_path refusal and the same `MissingKey` mapping; the eight-field
`HandoffTarget` literal was spelled in four places. `client.Target.fromSpec`
and `client.HandoffTarget.fromRecipe` are now the one road, and this commit
moves the wall onto them.

Ownership reconciled three ways. Allocation: the shared fn dupes ALWAYS —
webhub already did, wall_host borrowed from the spelling it had copied — so
a caller may resolve out of a scratch buffer. The wall's table is arena-backed
and never returns, which is why only the two `addHost` tests grew a free.
Type: `hosts.Spec` is now an alias of `wall.Spec` rather than a second
declaration of the same three arms, so the strict door and the lenient one
answer the same resolver. `asked`: a required parameter with no default,
against a field that has one — a road that never heard of the field still
gets the harmless half, while a caller standing at this door must say
which kind of dial it is building, and the compiler is what asks.

Patch: dedup-D1

build.zig
Old New
@@ -268,7 +268,7 @@ const mod_table = [_]ModSpec{
268 // attach records its own tile (the wall is attach history), and the 268 // attach records its own tile (the wall is attach history), and the
269 // chord switches that re-dial from inside client.attach have to record 269 // chord switches that re-dial from inside client.attach have to record
270 // theirs too, so the writer cannot live up in mux_main. 270 // theirs too, so the writer cannot live up in mux_main.
271 .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "keymap", "quic", "handoff", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 271 .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "keymap", "quic", "handoff", "wall", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
272 // The daemon entrypoint loads the key and constructs the listener, so 272 // The daemon entrypoint loads the key and constructs the listener, so
273 // it needs quic/quic_server directly rather than through the server. 273 // it needs quic/quic_server directly rather than through the server.
274 // `muxd endpoint` prints the announce line handoff spells; sockpath is 274 // `muxd endpoint` prints the announce line handoff spells; sockpath is
src/client/client.zig
Old New
@@ -22,6 +22,9 @@ const proto = @import("protocol");
22 const TmpDir = @import("testtmp").TmpDir; 22 const TmpDir = @import("testtmp").TmpDir;
23 const quic = @import("quic"); 23 const quic = @import("quic");
24 const handoff = @import("handoff"); 24 const handoff = @import("handoff");
25 const wall = @import("wall");
26 const xdg = @import("xdg");
27 const sockpath = @import("sockpath");
25 28
26 /// A session name held by value. The names a switch travels on are decoded 29 /// A session name held by value. The names a switch travels on are decoded
27 /// out of a frame payload that is freed before the re-dial, so they cannot 30 /// out of a frame payload that is freed before the re-dial, so they cannot
@@ -233,6 +236,21 @@ pub const HandoffTarget = struct {
233 /// exists and where the user is owed the progress of a wait they are 236 /// exists and where the user is owed the progress of a wait they are
234 /// sitting through. 237 /// sitting through.
235 quiet: bool = false, 238 quiet: bool = false,
239
240 /// The recipe→target literal: a field added above is added here, not
241 /// at every dial. `asked` is a parameter with NO default though
242 /// the field has one — Zig cannot omit it, and that compile error is
243 /// the pin the field's default is not.
244 pub fn fromRecipe(host: []const u8, r: handoff.Recipe, idle_ms: u32, asked: bool) HandoffTarget {
245 return .{
246 .host = host,
247 .ssh_argv = r.ssh_argv,
248 .start_argv = r.start_argv,
249 .cache_path = r.cache_path,
250 .idle_ms = idle_ms,
251 .asked = asked,
252 };
253 }
236 }; 254 };
237 255
238 /// A union, not four nullable fields — "exactly one is set" stops being a 256 /// A union, not four nullable fields — "exactly one is set" stops being a
@@ -242,8 +260,65 @@ pub const Target = union(enum) {
242 via: []const u8, 260 via: []const u8,
243 quic: QuicTarget, 261 quic: QuicTarget,
244 hand: HandoffTarget, 262 hand: HandoffTarget,
263
264 /// The one road from a spelling's `wall.Spec` to the dial it names:
265 /// the wall's host lines and the hub's tiles resolve here, and the
266 /// CLI's `.host` arm shares `fromRecipe`. What can fail is
267 /// `SpecError`; the word for it is each door's own.
268 ///
269 /// Every slice is OWNED by `alloc` — the spelling a caller parsed may
270 /// be a scratch buffer, and a target outlives the read that made it.
271 ///
272 /// `asked` is required, never defaulted: see `HandoffTarget.fromRecipe`.
273 pub fn fromSpec(alloc: std.mem.Allocator, spec: wall.Spec, key: ?[]const u8, idle_ms: u32, asked: bool) SpecError!Target {
274 return switch (spec) {
275 // sun_path is a fixed array in the kernel's struct: a longer
276 // path cannot be dialed at all, so it is refused here, at
277 // usage altitude, rather than at a connect that fails with a
278 // truncated name nobody typed.
279 .sock => |path| if (path.len > sockpath.max_sun_path)
280 error.SockPathTooLong
281 else
282 .{ .sock = try alloc.dupe(u8, path) },
283 .host => |h| blk: {
284 const hd = try alloc.dupe(u8, h);
285 const r = try handoff.recipeFor(alloc, hd, false);
286 break :blk .{ .hand = HandoffTarget.fromRecipe(hd, r, idle_ms, asked) };
287 },
288 .quic => |hp| blk: {
289 const key_path = switch (xdg.resolveKeyPath(alloc, key) catch |err| switch (err) {
290 // No HOME is no default key path, which is the same
291 // outcome for this caller as a default that isn't
292 // there: nothing to authenticate the dial with.
293 error.NoHome => return error.MissingKey,
294 else => |e| return e,
295 }) {
296 // `.given` borrows from argv/env, which outlives
297 // nothing in particular from the target's point of view.
298 .given => |kp| try alloc.dupe(u8, kp),
299 .default => |kp| kp,
300 // `.missing` is an ALLOCATED path too — the refusal is
301 // the one arm that does not keep it, so it is the one
302 // arm that has to free it.
303 .missing => |kp| {
304 alloc.free(kp);
305 return error.MissingKey;
306 },
307 };
308 break :blk .{ .quic = .{
309 .host_port = try alloc.dupe(u8, hp),
310 .key_path = key_path,
311 .idle_ms = idle_ms,
312 } };
313 },
314 };
315 }
245 }; 316 };
246 317
318 /// What resolving a spelling can fail at: a key that is not there to prove
319 /// the dial with, and a socket path the kernel cannot hold.
320 pub const SpecError = error{ MissingKey, SockPathTooLong, OutOfMemory };
321
247 /// What the open produced — the live wire. Distinct from `Target` because a 322 /// What the open produced — the live wire. Distinct from `Target` because a
248 /// `hand` recipe yields either a quic or a pipe link, and which one is 323 /// `hand` recipe yields either a quic or a pipe link, and which one is
249 /// decided inside `openHandoff` at runtime. 324 /// decided inside `openHandoff` at runtime.
@@ -1715,9 +1790,8 @@ fn shimMade(dir: []const u8, name: []const u8) !bool {
1715 } 1790 }
1716 1791
1717 test "openHandoff: a HandoffTarget nobody configured starts nothing" { 1792 test "openHandoff: a HandoffTarget nobody configured starts nothing" {
1718 // Seven dial paths spell `.asked = false` and one spells `true`. The 1793 // The default is the value a NEW path inherits by forgetting the line,
1719 // default is the value a NEW path inherits by forgetting the line, and 1794 // and nothing fails loudly when it does: the symptom is a daemon (and a
1720 // nothing fails loudly when it does: the symptom is a daemon (and a
1721 // shell in session 0) appearing on someone else's box. So the default 1795 // shell in session 0) appearing on someone else's box. So the default
1722 // is the harmless half, and the ask is what has to be written down. 1796 // is the harmless half, and the ask is what has to be written down.
1723 const alloc = std.testing.allocator; 1797 const alloc = std.testing.allocator;
@@ -2283,7 +2357,7 @@ test "client: every spelling this writes, the wall grammar reads back the same"
2283 }; 2357 };
2284 inline for (cases) |c| { 2358 inline for (cases) |c| {
2285 const spelling = try wallSpelling(&buf, c[0], c[1]); 2359 const spelling = try wallSpelling(&buf, c[0], c[1]);
2286 const p = try @import("wall").parseSpelling(spelling); 2360 const p = try wall.parseSpelling(spelling);
2287 try std.testing.expectEqualStrings(c[1], p.session); 2361 try std.testing.expectEqualStrings(c[1], p.session);
2288 switch (c[0]) { 2362 switch (c[0]) {
2289 .sock => |path| try std.testing.expectEqualStrings(path, p.spec.sock), 2363 .sock => |path| try std.testing.expectEqualStrings(path, p.spec.sock),
@@ -2537,3 +2611,90 @@ test "listSessions: a poll that failed still reports the login it paid for" {
2537 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .sock = "/nonexistent/mux.sock" }, &out, 200, &link)); 2611 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .sock = "/nonexistent/mux.sock" }, &out, 200, &link));
2538 try std.testing.expectEqual(std.meta.Tag(Link).fd, link); 2612 try std.testing.expectEqual(std.meta.Tag(Link).fd, link);
2539 } 2613 }
2614
2615 test "Target.fromSpec: asked is the caller's word, never a default" {
2616 // The COMPILE-TIME half of this claim cannot be written as a runtime
2617 // assertion: `fromSpec` and `HandoffTarget.fromRecipe` take `asked` as
2618 // a parameter with no default, so a call that omits it does not build.
2619 // That is the pin — the field's `= false` default stays for a road
2620 // that never heard of the field, and this door has no such road.
2621 // What is runtime-checkable is that the word is CARRIED, not dropped
2622 // and re-defaulted somewhere between here and the recipe.
2623 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
2624 defer arena.deinit();
2625 const alloc = arena.allocator();
2626
2627 // Both values, off one spelling: a fromSpec that hard-coded either one
2628 // would pass a test that only ever asked for the other.
2629 for ([_]bool{ true, false }) |asked| {
2630 const t = try Target.fromSpec(alloc, .{ .host = "box" }, null, 30_000, asked);
2631 try std.testing.expectEqual(asked, t.hand.asked);
2632 try std.testing.expectEqual(@as(u32, 30_000), t.hand.idle_ms);
2633 // The start line is the recipe's, and it is what `asked` gates.
2634 try std.testing.expect(t.hand.start_argv.len > 0);
2635 }
2636 }
2637
2638 test "Target.fromSpec: the target owns every slice, so a scratch spelling may be reused" {
2639 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
2640 defer arena.deinit();
2641 const alloc = arena.allocator();
2642
2643 // The hub parses a POST body and the wall parses a filter's buffer;
2644 // both are overwritten before the dial the target describes.
2645 var scratch: [15]u8 = "box ".*;
2646 const t = try Target.fromSpec(alloc, .{ .host = scratch[0..3] }, null, 30_000, false);
2647 @memset(&scratch, 'z');
2648 try std.testing.expectEqualStrings("box", t.hand.host);
2649
2650 var kbuf: [7]u8 = "/k ".*;
2651 const q = try Target.fromSpec(alloc, .{ .quic = "h:1" }, kbuf[0..2], 30_000, false);
2652 @memset(&kbuf, 'z');
2653 try std.testing.expectEqualStrings("/k", q.quic.key_path);
2654 try std.testing.expectEqualStrings("h:1", q.quic.host_port);
2655 }
2656
2657 test "Target.fromSpec: a quic spelling with no key frees the path it refused" {
2658 // `std.testing.allocator` IS the assertion: `xdg.resolveKeyPath`'s
2659 // `.missing` arm hands back an allocated path, and the refusal that
2660 // does not keep it must free it. The two live callers pass arenas or a
2661 // long-lived allocator, so nothing else in the product would ever say
2662 // so out loud.
2663 var tmp = try TmpDir.make();
2664 defer tmp.cleanup();
2665
2666 // Pointed at an empty directory rather than the developer's own config:
2667 // whether ~/.config/mux/key exists on the machine running the suite
2668 // decides which arm this takes, and a test that grades a different arm
2669 // per box grades nothing.
2670 const libc = @cImport({
2671 @cInclude("stdlib.h");
2672 });
2673 const alloc = std.testing.allocator;
2674 const prior = std.posix.getenv("XDG_CONFIG_HOME");
2675 const prior_z = if (prior) |p| try alloc.dupeZ(u8, p) else null;
2676 defer if (prior_z) |p| alloc.free(p);
2677 const cfg = try alloc.dupeZ(u8, tmp.path());
2678 defer alloc.free(cfg);
2679 _ = libc.setenv("XDG_CONFIG_HOME", cfg.ptr, 1);
2680 defer if (prior_z) |p| {
2681 _ = libc.setenv("XDG_CONFIG_HOME", p.ptr, 1);
2682 } else {
2683 _ = libc.unsetenv("XDG_CONFIG_HOME");
2684 };
2685
2686 try std.testing.expectError(
2687 error.MissingKey,
2688 Target.fromSpec(alloc, .{ .quic = "h:1" }, null, 30_000, false),
2689 );
2690 }
2691
2692 test "Target.fromSpec: a sun_path-overflowing socket is refused at usage altitude" {
2693 // Refused where it was typed, not at a connect(2) that would bind a
2694 // truncated name nobody asked for.
2695 const long = "/" ++ "x" ** 200;
2696 try std.testing.expectError(
2697 error.SockPathTooLong,
2698 Target.fromSpec(std.testing.allocator, .{ .sock = long }, null, 30_000, false),
2699 );
2700 }
src/client/hosts.zig
Old New
@@ -12,7 +12,10 @@ const std = @import("std");
12 const wall = @import("wall"); 12 const wall = @import("wall");
13 const xdg = @import("xdg"); 13 const xdg = @import("xdg");
14 14
15 pub const Spec = union(enum) { sock: []const u8, host: []const u8, quic: []const u8 }; 15 /// The wall grammar's, not a second copy of it: `hosts.parse` is the
16 /// strict door onto the same three spellings `wall.parseSpelling` reads,
17 /// and one type is what lets `client.Target.fromSpec` answer both.
18 pub const Spec = wall.Spec;
16 pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling }; 19 pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling };
17 20
18 pub fn parse(line: []const u8) ParseError!Spec { 21 pub fn parse(line: []const u8) ParseError!Spec {
src/tui/wall_host.zig
Old New
@@ -9,8 +9,6 @@ const client = @import("client");
9 const wall = @import("wall"); 9 const wall = @import("wall");
10 const hosts = @import("hosts"); 10 const hosts = @import("hosts");
11 const handoff = @import("handoff"); 11 const handoff = @import("handoff");
12 const xdg = @import("xdg");
13 const sockpath = @import("sockpath");
14 const wall_layout = @import("wall_layout.zig"); 12 const wall_layout = @import("wall_layout.zig");
15 const wv = @import("wallview.zig"); 13 const wv = @import("wallview.zig");
16 const Shared = wv.Shared; 14 const Shared = wv.Shared;
@@ -30,7 +28,7 @@ pub const Resolved = struct {
30 agent: bool = false, 28 agent: bool = false,
31 }; 29 };
32 30
33 pub const ResolveError = hosts.ParseError || error{ MissingKey, SockPathTooLong, OutOfMemory }; 31 pub const ResolveError = hosts.ParseError || client.SpecError;
34 32
35 /// A daemon on the wall: what to dial, and the line that named it. The 33 /// A daemon on the wall: what to dial, and the line that named it. The
36 /// spelling is the sidecar's key and what `mux hosts` prints back, so it 34 /// spelling is the sidecar's key and what `mux hosts` prints back, so it
@@ -44,47 +42,11 @@ pub fn resolveHost(
44 key: ?[]const u8, 42 key: ?[]const u8,
45 idle_ms: u32, 43 idle_ms: u32,
46 ) ResolveError!HostSpec { 44 ) ResolveError!HostSpec {
47 const target: client.Target = switch (try hosts.parse(spelling)) { 45 // A host line is a listing, not an attach anyone waited for. The
48 // Refused here, at usage altitude, not at a connect that fails 46 // POLLER runs off this spec once a second: an asked copy would print
49 // with a truncated sun_path nobody typed. 47 // the fallback line onto the wall's alternate screen every cycle, and
50 .sock => |path| if (path.len > sockpath.max_sun_path) 48 // would start a daemon on a box whose owner just stopped one.
51 return error.SockPathTooLong 49 const target = try client.Target.fromSpec(alloc, try hosts.parse(spelling), key, idle_ms, false);
52 else
53 .{ .sock = path },
54 .host => |h| blk: {
55 const r = try handoff.recipeFor(alloc, h, false);
56 break :blk .{
57 .hand = .{
58 .host = h,
59 .ssh_argv = r.ssh_argv,
60 .start_argv = r.start_argv,
61 .cache_path = r.cache_path,
62 .idle_ms = idle_ms,
63 // A host line is a listing, not an attach anyone waited
64 // for. The POLLER runs off this spec once a second: an
65 // asked copy would print the fallback line onto the
66 // wall's alternate screen every cycle, and would start
67 // a daemon on a box whose owner just stopped one.
68 .asked = false,
69 },
70 };
71 },
72 .quic => |hp| blk: {
73 const key_path = switch (xdg.resolveKeyPath(alloc, key) catch |err| switch (err) {
74 error.NoHome => return error.MissingKey,
75 else => |e| return e,
76 }) {
77 .given => |kp| kp,
78 .default => |kp| kp,
79 .missing => return error.MissingKey,
80 };
81 break :blk .{ .quic = .{
82 .host_port = hp,
83 .key_path = key_path,
84 .idle_ms = idle_ms,
85 } };
86 },
87 };
88 return .{ .spelling = spelling, .target = target, .poll_target = try pollTargetFor(alloc, target) }; 50 return .{ .spelling = spelling, .target = target, .poll_target = try pollTargetFor(alloc, target) };
89 } 51 }
90 52
@@ -100,14 +62,7 @@ pub fn pollTargetFor(alloc: std.mem.Allocator, target: client.Target) !client.Ta
100 else => return target, 62 else => return target,
101 }; 63 };
102 const r = try handoff.recipeFor(alloc, h.host, true); 64 const r = try handoff.recipeFor(alloc, h.host, true);
103 return .{ .hand = .{ 65 return .{ .hand = client.HandoffTarget.fromRecipe(h.host, r, h.idle_ms, false) };
104 .host = h.host,
105 .ssh_argv = r.ssh_argv,
106 .start_argv = r.start_argv,
107 .cache_path = r.cache_path,
108 .idle_ms = h.idle_ms,
109 .asked = false,
110 } };
111 } 66 }
112 67
113 /// One wording for every spelling the wall will not take. 68 /// One wording for every spelling the wall will not take.
src/tui/wall_test_host.zig
Old New
@@ -493,7 +493,10 @@ test "addHost: a forgotten host's slot comes back, so a and x cannot fill the ta
493 }; 493 };
494 // `run`'s allocator is an ARENA that never returns, so a reused slot's 494 // `run`'s allocator is an ARENA that never returns, so a reused slot's
495 // old spec is deliberately not freed there; a test outlives its own. 495 // old spec is deliberately not freed there; a test outlives its own.
496 // The target owns its own path: `Target.fromSpec` copies every slice,
497 // because the spelling a caller parsed may be a scratch buffer.
496 defer alloc.free(table[at].spec.spelling); 498 defer alloc.free(table[at].spec.spelling);
499 defer alloc.free(table[at].spec.target.sock);
497 try std.testing.expectEqual(@as(usize, 0), at); 500 try std.testing.expectEqual(@as(usize, 0), at);
498 try std.testing.expectEqual(@as(usize, 2), n); 501 try std.testing.expectEqual(@as(usize, 2), n);
499 try std.testing.expectEqualStrings("/c", table[0].spec.target.sock); 502 try std.testing.expectEqualStrings("/c", table[0].spec.target.sock);
@@ -538,6 +541,7 @@ test "addHost: the prompt adds a DAEMON — a flag, a session and a host already
538 // `run`'s allocator never returns, so the table's copy is deliberately 541 // `run`'s allocator never returns, so the table's copy is deliberately
539 // never freed there; a test outlives its allocator's bookkeeping. 542 // never freed there; a test outlives its allocator's bookkeeping.
540 defer alloc.free(table[at].spec.spelling); 543 defer alloc.free(table[at].spec.spelling);
544 defer alloc.free(table[at].spec.target.sock);
541 try std.testing.expectEqual(@as(usize, 2), at); 545 try std.testing.expectEqual(@as(usize, 2), at);
542 try std.testing.expectEqual(@as(usize, 3), n); 546 try std.testing.expectEqual(@as(usize, 3), n);
543 try std.testing.expectEqualStrings("/c", table[2].spec.target.sock); 547 try std.testing.expectEqualStrings("/c", table[2].spec.target.sock);
src/tui/wallview.zig
Old New
@@ -23,8 +23,6 @@ const client = @import("client");
23 const wall = @import("wall"); 23 const wall = @import("wall");
24 const hosts = @import("hosts"); 24 const hosts = @import("hosts");
25 const handoff = @import("handoff"); 25 const handoff = @import("handoff");
26 const xdg = @import("xdg");
27 const sockpath = @import("sockpath");
28 const proxy = @import("proxy"); 26 const proxy = @import("proxy");
29 const Engine = @import("engine").Engine; 27 const Engine = @import("engine").Engine;
30 const paint = @import("paint"); 28 const paint = @import("paint");