a73x

fed80c17

refactor: the hub resolves a tile through the one resolver

a73x   2026-08-29 10:01

Commit message
refactor: the hub resolves a tile through the one resolver

`webhub.resolveTile` kept the label and the session split and hands the
spec to `client.Target.fromSpec`; its three arms, its sun_path refusal and
its `MissingKey` mapping are gone. The arena is passed as the allocator, so
the dupes the hub needs are the ones the shared fn already makes — and with
nothing left in this file that resolves a path or a key, `xdg` and
`sockpath` leave the module's imports.

`ResolveError` is now an alias of `client.SpecError`: one door has one list
of ways to fail through it. `asked = false` stays spelled at the call site,
because the sentence is about the HUB, not about resolving.

Patch: dedup-D1

build.zig
Old New
@@ -282,7 +282,7 @@ const mod_table = [_]ModSpec{
282 // @embedFiles them), so its tests build no artifacts. 282 // @embedFiles them), so its tests build no artifacts.
283 // sockpath is the sun_path bound a `--sock` tile is refused against — 283 // sockpath is the sun_path bound a `--sock` tile is refused against —
284 // the check argv used to make before the Hub owned resolution. 284 // the check argv used to make before the Hub owned resolution.
285 .{ .name = "webhub", .path = "src/client/webhub.zig", .layer = 4, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg", "sockpath" }, .quic_tests = true }, 285 .{ .name = "webhub", .path = "src/client/webhub.zig", .layer = 4, .imports = &.{ "protocol", "client", "wall", "handoff" }, .quic_tests = true },
286 // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of 286 // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of
287 // which can be ZOOMED — promoted to the terminal's size and typed 287 // which can be ZOOMED — promoted to the terminal's size and typed
288 // through. Same layer as webhub for the same reason — both sit on 288 // through. Same layer as webhub for the same reason — both sit on
src/client/webhub.zig
Old New
@@ -14,8 +14,6 @@ const proto = @import("protocol");
14 const client = @import("client"); 14 const client = @import("client");
15 const wall = @import("wall"); 15 const wall = @import("wall");
16 const handoff = @import("handoff"); 16 const handoff = @import("handoff");
17 const xdg = @import("xdg");
18 const sockpath = @import("sockpath");
19 17
20 pub const default_port: u16 = 7681; 18 pub const default_port: u16 = 7681;
21 19
@@ -78,8 +76,9 @@ const HubTile = struct {
78 pub const AddError = wall.ParseError || ResolveError || error{PersistFailed}; 76 pub const AddError = wall.ParseError || ResolveError || error{PersistFailed};
79 77
80 /// The half of AddError a spelling can fail at resolution time — shared 78 /// The half of AddError a spelling can fail at resolution time — shared
81 /// with `Hub.init`, which resolves the whole wall before serving. 79 /// with `Hub.init`, which resolves the whole wall before serving. The
82 pub const ResolveError = error{ MissingKey, SockPathTooLong, OutOfMemory }; 80 /// resolver's own set: one door, one list of ways through it to fail.
81 pub const ResolveError = client.SpecError;
83 82
84 /// Resolves as argv does at startup, so a runtime tile means what the 83 /// Resolves as argv does at startup, so a runtime tile means what the
85 /// command line means. 84 /// command line means.
@@ -98,55 +97,11 @@ fn resolveTile(
98 // page shows what was typed, not a rebuilt approximation of it. 97 // page shows what was typed, not a rebuilt approximation of it.
99 const label = try arena.dupe(u8, spelling); 98 const label = try arena.dupe(u8, spelling);
100 const session = try arena.dupe(u8, p.session); 99 const session = try arena.dupe(u8, p.session);
101 const target: client.Target = switch (p.spec) { 100 // The hub is never the ask: a tile redials for as long as the page is
102 // sun_path is a fixed array in the kernel's struct: a longer path 101 // open and nobody is sitting in front of it. Said HERE and not only
103 // cannot be dialed at all, so it is refused here rather than at a 102 // where the pump clears it — a permission cleared downstream is one a
104 // connect that fails with a truncated name nobody typed. The hub's 103 // new road can miss.
105 // argv path made this check before the Hub existed; a POSTed 104 const target = try client.Target.fromSpec(arena, p.spec, key, idle_ms, false);
106 // spelling gets the same answer.
107 .sock => |path| if (path.len > sockpath.max_sun_path)
108 return error.SockPathTooLong
109 else
110 .{ .sock = try arena.dupe(u8, path) },
111 .host => |h| blk: {
112 const hd = try arena.dupe(u8, h);
113 const r = try handoff.recipeFor(arena, hd, false);
114 break :blk .{
115 .hand = .{
116 .host = hd,
117 .ssh_argv = r.ssh_argv,
118 .start_argv = r.start_argv,
119 .cache_path = r.cache_path,
120 .idle_ms = idle_ms,
121 // The hub is never the ask: a tile redials for as long as
122 // the page is open and nobody is sitting in front of it.
123 // Said HERE and not only where the pump clears it — a
124 // permission cleared downstream is one a new road can miss.
125 .asked = false,
126 },
127 };
128 },
129 .quic => |hp| blk: {
130 const key_path = switch (xdg.resolveKeyPath(arena, key) catch |err| switch (err) {
131 // No HOME is no default key path, which is the same
132 // outcome for this caller as a default that isn't there:
133 // nothing to authenticate the dial with.
134 error.NoHome => return error.MissingKey,
135 else => |e| return e,
136 }) {
137 // `.given` borrows from argv/env, which outlives nothing
138 // in particular from the tile's point of view.
139 .given => |kp| try arena.dupe(u8, kp),
140 .default => |kp| kp,
141 .missing => return error.MissingKey,
142 };
143 break :blk .{ .quic = .{
144 .host_port = try arena.dupe(u8, hp),
145 .key_path = key_path,
146 .idle_ms = idle_ms,
147 } };
148 },
149 };
150 return .{ .target = target, .label = label, .session = session }; 105 return .{ .target = target, .label = label, .session = session };
151 } 106 }
152 107
@@ -1421,6 +1376,27 @@ test "hub: checkout copies the target into the caller's arena; release unregiste
1421 try std.testing.expectError(error.UnknownId, hub.checkoutTile(0, 7, arena.allocator())); 1376 try std.testing.expectError(error.UnknownId, hub.checkoutTile(0, 7, arena.allocator()));
1422 } 1377 }
1423 1378
1379 test "hub: a HOST tile is never the ask, whether it came from the wall file or a POST" {
1380 const alloc = std.testing.allocator;
1381 var w = wall.Wall{};
1382 _ = try w.add(alloc, "box");
1383 var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default);
1384 defer hub.deinit();
1385 // Both mouths, because the startup wall and the runtime POST are two
1386 // roads to `resolveTile` and only one of them was ever walked here.
1387 _ = try hub.addTile("gate");
1388
1389 var arena = std.heap.ArenaAllocator.init(alloc);
1390 defer arena.deinit();
1391 for ([_]u32{ 0, 1 }) |id| {
1392 const t = try hub.checkoutTile(id, 7, arena.allocator());
1393 // Nobody is sitting in front of a browser tile: it may not start a
1394 // daemon on a box whose owner just stopped one, and it may not
1395 // print an ssh-fallback line into a page that has no stderr.
1396 try std.testing.expect(!t.target.hand.asked);
1397 }
1398 }
1399
1424 test "hub: a checkout carries the tile's session name, which the pump may have to create" { 1400 test "hub: a checkout carries the tile's session name, which the pump may have to create" {
1425 const alloc = std.testing.allocator; 1401 const alloc = std.testing.allocator;
1426 var w = wall.Wall{}; 1402 var w = wall.Wall{};