a73x

b69ec3c6

fix: an ssh-only host is polled quietly, and rarely

a73x   2026-08-28 19:53

Commit message
fix: an ssh-only host is polled quietly, and rarely

A `HOST` line whose QUIC endpoint is blocked answers every poll over the
ssh pipe: one sshd login per second, a fallback line on stderr per cycle,
and a password prompt on /dev/tty under the panes when key auth fails.

Three halves of the same bill:

  * `resolveHost`'s hand target clears `report_fallback`. The pump cleared
    its own copy; the POLLER's copy was never cleared, so the line landed
    on the alternate screen once a cycle, forever.
  * `listSessions` reports which link answered, and `pollDelayMs` stretches
    a pipe-answered host to ten seconds. A poke still jumps the wait, so a
    chord that births costs nothing.
  * a second recipe, `pollTargetFor`, carries `-o BatchMode=yes`. Only the
    poll and `mux hosts` use it; the interactive attach must keep
    prompting, and does.

The LAN/WAN half of this cannot be asserted here — no e2e host is an ssh
host, and the batch flag's effect is sshd's. What the tests pin is the
argv, the interval and the two cleared flags.

src/cli/mux_main.zig
Old New
@@ -350,7 +350,7 @@ pub fn main() !u8 {
350 // warm attach dials from the cache and never spawns ssh at all. 350 // warm attach dials from the cache and never spawns ssh at all.
351 // handoff.recipeFor owns both pieces; muxweb builds its HOST 351 // handoff.recipeFor owns both pieces; muxweb builds its HOST
352 // tiles from the same call. 352 // tiles from the same call.
353 const r = try handoff.recipeFor(alloc, h.name); 353 const r = try handoff.recipeFor(alloc, h.name, false);
354 defer r.deinit(alloc); 354 defer r.deinit(alloc);
355 return wallview.runAttach(alloc, .{ .hand = .{ 355 return wallview.runAttach(alloc, .{ .hand = .{
356 .host = h.name, 356 .host = h.name,
@@ -579,7 +579,7 @@ fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t)
579 continue; 579 continue;
580 }; 580 };
581 var out: [proto.sessions_text_max]u8 = undefined; 581 var out: [proto.sessions_text_max]u8 = undefined;
582 const list = client.listSessions(arena, spec.target, &out, hosts_list_ms) catch { 582 const list = client.listSessions(arena, spec.poll_target, &out, hosts_list_ms, null) catch {
583 printOut(out_fd, "{s}\t[unreachable]\n", .{line}); 583 printOut(out_fd, "{s}\t[unreachable]\n", .{line});
584 continue; 584 continue;
585 }; 585 };
src/client.zig
Old New
@@ -1014,13 +1014,26 @@ pub fn listSessions(
1014 target: Target, 1014 target: Target,
1015 out: *[proto.sessions_text_max]u8, 1015 out: *[proto.sessions_text_max]u8,
1016 budget_ms: i64, 1016 budget_ms: i64,
1017 answered: ?*std.meta.Tag(Link),
1017 ) ![]const u8 { 1018 ) ![]const u8 {
1018 // A fresh connection per poll: the observer idle deadline and the 1019 // A fresh connection per poll: the observer idle deadline and the
1019 // redial backoff stay the pump's problem, and a `--via` host — an ssh 1020 // redial backoff stay the pump's problem, and a `--via` host — an ssh
1020 // per open — is the case that will make a kept side connection worth 1021 // per open — is the case that will make a kept side connection worth
1021 // its state. Measure before adding one. 1022 // its state. Measure before adding one.
1023 // BEFORE the open, because what a `hand` target COST is decided by the
1024 // target, not by the reply — an ssh login is spent either way, and the
1025 // caller's backoff is the only thing that can stop paying. A failed open
1026 // left this untouched, so a host that refuses the login was re-dialled at
1027 // the reachable interval forever.
1028 if (answered) |a| a.* = switch (target) {
1029 .hand, .via => .pipe,
1030 .quic => .quic,
1031 .sock => .fd,
1032 };
1022 var tr = Transport.open(alloc, target, null, -1) catch return error.Transport; 1033 var tr = Transport.open(alloc, target, null, -1) catch return error.Transport;
1023 defer tr.close(); 1034 defer tr.close();
1035 // The handoff picks its own link, so only the success case knows it.
1036 if (answered) |a| a.* = tr.link;
1024 try tr.writeFrame(.sessions_req, ""); 1037 try tr.writeFrame(.sessions_req, "");
1025 const deadline = std.time.milliTimestamp() + budget_ms; 1038 const deadline = std.time.milliTimestamp() + budget_ms;
1026 while (true) { 1039 while (true) {
@@ -1897,13 +1910,42 @@ test "listSessions: answers with the daemon's whole list, and a socket nobody li
1897 const th = try std.Thread.spawn(.{}, ListFake.serve, .{&fake}); 1910 const th = try std.Thread.spawn(.{}, ListFake.serve, .{&fake});
1898 1911
1899 var out: [proto.sessions_text_max]u8 = undefined; 1912 var out: [proto.sessions_text_max]u8 = undefined;
1900 const list = try listSessions(alloc, .{ .sock = sp }, &out, 2000); 1913 var link: std.meta.Tag(Link) = .quic;
1914 const list = try listSessions(alloc, .{ .sock = sp }, &out, 2000, &link);
1901 th.join(); 1915 th.join();
1916 // Which link answered is what the wall's poll interval is chosen from,
1917 // so a socket must say `fd` and not merely "not an error".
1918 try std.testing.expectEqual(std.meta.Tag(Link).fd, link);
1902 // Every name, not the first: the poll diffs the whole list against the 1919 // Every name, not the first: the poll diffs the whole list against the
1903 // wall, so a reply read short would vanish tiles the daemon still has. 1920 // wall, so a reply read short would vanish tiles the daemon still has.
1904 try std.testing.expectEqualStrings("p\nq\n", list); 1921 try std.testing.expectEqualStrings("p\nq\n", list);
1905 1922
1906 const dead = try std.fmt.allocPrint(alloc, "{s}/nobody.sock", .{tmp.path()}); 1923 const dead = try std.fmt.allocPrint(alloc, "{s}/nobody.sock", .{tmp.path()});
1907 defer alloc.free(dead); 1924 defer alloc.free(dead);
1908 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .sock = dead }, &out, 200)); 1925 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .sock = dead }, &out, 200, null));
1926 }
1927
1928 test "listSessions: a poll that failed still reports the login it paid for" {
1929 const alloc = std.testing.allocator;
1930 var out: [proto.sessions_text_max]u8 = undefined;
1931
1932 // An ssh that dies without an announce: the login is spent, the poll has
1933 // nothing. Left at `.fd` the wall would ask again in a second, forever —
1934 // one sshd auth line per second per dead host, which is the whole reason
1935 // `wallview.pollDelayMs` stretches a `.pipe` answer tenfold.
1936 var link: std.meta.Tag(Link) = .quic;
1937 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .hand = .{
1938 .host = "nowhere",
1939 .ssh_cmd = "exit 255",
1940 .cache_path = null,
1941 .asked = false,
1942 } }, &out, 200, &link));
1943 try std.testing.expectEqual(std.meta.Tag(Link).pipe, link);
1944
1945 // A `--sock` open that fails cost a connect(2) and nothing else, so the
1946 // backoff must not follow it: the local daemon a user just stopped is
1947 // back a second later, not ten.
1948 link = .quic;
1949 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .sock = "/nonexistent/mux.sock" }, &out, 200, &link));
1950 try std.testing.expectEqual(std.meta.Tag(Link).fd, link);
1909 } 1951 }
src/handoff.zig
Old New
@@ -185,7 +185,13 @@ pub const Recipe = struct {
185 /// ssh line would quietly point the two binaries at different remote 185 /// ssh line would quietly point the two binaries at different remote
186 /// commands. Building it here (rather than in client.zig) is what keeps 186 /// commands. Building it here (rather than in client.zig) is what keeps
187 /// the client free of XDG and of allocating a command line. 187 /// the client free of XDG and of allocating a command line.
188 pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8) !Recipe { 188 pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8, batch: bool) !Recipe {
189 // BatchMode is for the recipes NOBODY is sitting in front of — the
190 // wall's per-host poll, and `mux hosts`. ssh asks for a password or a
191 // host-key confirmation on /dev/tty, and a poll that runs every second
192 // under a full-screen wall would ask forever, over the panes. An
193 // interactive attach is the opposite case and must keep prompting.
194 const batch_opt: []const u8 = if (batch) "-o BatchMode=yes " else "";
189 // The PATH suffix, single-quoted so the REMOTE shell expands it: sshd 195 // The PATH suffix, single-quoted so the REMOTE shell expands it: sshd
190 // runs this through a non-login, non-interactive shell that never 196 // runs this through a non-login, non-interactive shell that never
191 // sources the profile putting ~/.local/bin (make install's target) on 197 // sources the profile putting ~/.local/bin (make install's target) on
@@ -195,8 +201,8 @@ pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8) !Recipe {
195 // shim, over the binary under test). 201 // shim, over the binary under test).
196 const cmd = try std.fmt.allocPrint( 202 const cmd = try std.fmt.allocPrint(
197 alloc, 203 alloc,
198 "ssh {s} 'PATH=\"$PATH:$HOME/.local/bin\" muxd endpoint'", 204 "ssh {s}{s} 'PATH=\"$PATH:$HOME/.local/bin\" muxd endpoint'",
199 .{host}, 205 .{ batch_opt, host },
200 ); 206 );
201 errdefer alloc.free(cmd); 207 errdefer alloc.free(cmd);
202 return .{ .ssh_cmd = cmd, .cache_path = xdg.hostCachePath(alloc, host) catch null }; 208 return .{ .ssh_cmd = cmd, .cache_path = xdg.hostCachePath(alloc, host) catch null };
@@ -395,7 +401,7 @@ test "announce: every shape of junk is a named error" {
395 } 401 }
396 402
397 test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-login shell never sources the profile that would" { 403 test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-login shell never sources the profile that would" {
398 const r = try recipeFor(std.testing.allocator, "user@box"); 404 const r = try recipeFor(std.testing.allocator, "user@box", false);
399 defer r.deinit(std.testing.allocator); 405 defer r.deinit(std.testing.allocator);
400 // APPENDED, not prepended: this adds a place to look when muxd is 406 // APPENDED, not prepended: this adds a place to look when muxd is
401 // nowhere on the remote PATH; it must never let a stale ~/.local/bin 407 // nowhere on the remote PATH; it must never let a stale ~/.local/bin
@@ -406,6 +412,25 @@ test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-l
406 ); 412 );
407 } 413 }
408 414
415 test "recipeFor: a batch recipe cannot prompt, an interactive one still can" {
416 const alloc = std.testing.allocator;
417 // The poller's and `mux hosts`'s recipe. Two hosts, because the flag
418 // travels with the recipe and not with the process.
419 for ([_][]const u8{ "user@box", "gate" }) |h| {
420 const quiet = try recipeFor(alloc, h, true);
421 defer quiet.deinit(alloc);
422 try std.testing.expect(std.mem.indexOf(u8, quiet.ssh_cmd, "-o BatchMode=yes") != null);
423 // Before the host word, where ssh reads its options.
424 try std.testing.expect(
425 std.mem.indexOf(u8, quiet.ssh_cmd, "-o BatchMode=yes").? <
426 std.mem.indexOf(u8, quiet.ssh_cmd, h).?,
427 );
428 const asking = try recipeFor(alloc, h, false);
429 defer asking.deinit(alloc);
430 try std.testing.expect(std.mem.indexOf(u8, asking.ssh_cmd, "BatchMode") == null);
431 }
432 }
433
409 test "dialHost: the LAST @ wins, which is where ssh splits" { 434 test "dialHost: the LAST @ wins, which is where ssh splits" {
410 try std.testing.expectEqualStrings("box", dialHost("ubuntu@box")); 435 try std.testing.expectEqualStrings("box", dialHost("ubuntu@box"));
411 try std.testing.expectEqualStrings("box", dialHost("box")); 436 try std.testing.expectEqualStrings("box", dialHost("box"));
src/wallview.zig
Old New
@@ -55,7 +55,7 @@ pub const ResolveError = hosts.ParseError || error{ MissingKey, SockPathTooLong,
55 /// A daemon on the wall: what to dial, and the line that named it. The 55 /// A daemon on the wall: what to dial, and the line that named it. The
56 /// spelling is the stripe's label and the sidecar's key, so it is kept 56 /// spelling is the stripe's label and the sidecar's key, so it is kept
57 /// verbatim rather than rebuilt. 57 /// verbatim rather than rebuilt.
58 pub const HostSpec = struct { spelling: []const u8, target: client.Target }; 58 pub const HostSpec = struct { spelling: []const u8, target: client.Target, poll_target: client.Target };
59 59
60 /// A host line names a daemon, not a session; this is its target. 60 /// A host line names a daemon, not a session; this is its target.
61 pub fn resolveHost( 61 pub fn resolveHost(
@@ -72,13 +72,20 @@ pub fn resolveHost(
72 else 72 else
73 .{ .sock = path }, 73 .{ .sock = path },
74 .host => |h| blk: { 74 .host => |h| blk: {
75 const r = try handoff.recipeFor(alloc, h); 75 const r = try handoff.recipeFor(alloc, h, false);
76 break :blk .{ .hand = .{ 76 break :blk .{
77 .host = h, 77 .hand = .{
78 .ssh_cmd = r.ssh_cmd, 78 .host = h,
79 .cache_path = r.cache_path, 79 .ssh_cmd = r.ssh_cmd,
80 .idle_ms = idle_ms, 80 .cache_path = r.cache_path,
81 } }; 81 .idle_ms = idle_ms,
82 // No door off this spec is the attach that made the choice:
83 // the pump clears its own copy, and the POLLER's would
84 // otherwise print the fallback line onto the wall's
85 // alternate screen once per cycle, forever.
86 .report_fallback = false,
87 },
88 };
82 }, 89 },
83 .quic => |hp| blk: { 90 .quic => |hp| blk: {
84 const key_path = switch (xdg.resolveKeyPath(alloc, key) catch |err| switch (err) { 91 const key_path = switch (xdg.resolveKeyPath(alloc, key) catch |err| switch (err) {
@@ -96,7 +103,28 @@ pub fn resolveHost(
96 } }; 103 } };
97 }, 104 },
98 }; 105 };
99 return .{ .spelling = spelling, .target = target }; 106 return .{ .spelling = spelling, .target = target, .poll_target = try pollTargetFor(alloc, target) };
107 }
108
109 /// What `HostSpec.poll_target` is: the same daemon, dialled by a recipe
110 /// nobody is sitting in front of.
111 fn pollTargetFor(alloc: std.mem.Allocator, target: client.Target) !client.Target {
112 // Only `hand` can ask a terminal for anything, so only `hand` needs a
113 // second recipe: a poll runs under a wall that owns the screen, where
114 // an ssh password prompt goes to /dev/tty under the panes and a
115 // fallback line goes onto the alternate screen.
116 const h = switch (target) {
117 .hand => |hd| hd,
118 else => return target,
119 };
120 const r = try handoff.recipeFor(alloc, h.host, true);
121 return .{ .hand = .{
122 .host = h.host,
123 .ssh_cmd = r.ssh_cmd,
124 .cache_path = r.cache_path,
125 .idle_ms = h.idle_ms,
126 .report_fallback = false,
127 } };
100 } 128 }
101 129
102 /// Whether this process has a screen to cut stripes on. 130 /// Whether this process has a screen to cut stripes on.
@@ -2820,13 +2848,23 @@ const Host = struct {
2820 /// not a cost worth designing around. 2848 /// not a cost worth designing around.
2821 const host_poll_ms: u64 = 1000; 2849 const host_poll_ms: u64 = 1000;
2822 2850
2851 /// How long before this host is asked again, given the link that answered.
2852 fn pollDelayMs(link: std.meta.Tag(client.Link)) u64 {
2853 // A pipe link cost a whole sshd login: the cached QUIC coordinates were
2854 // dead or blocked, so this cycle spawned `ssh`, read the announce and
2855 // killed it. A second of that, forever, is a remote auth log the wall
2856 // wrote — the list is worth a tenth of the freshness.
2857 return if (link == .pipe) host_poll_ms * 10 else host_poll_ms;
2858 }
2859
2823 fn pollHost(h: *Host) void { 2860 fn pollHost(h: *Host) void {
2824 var out: [proto.sessions_text_max]u8 = undefined; 2861 var out: [proto.sessions_text_max]u8 = undefined;
2825 while (h.shared.running.load(.acquire)) { 2862 while (h.shared.running.load(.acquire)) {
2826 // A connection of its own per poll: the observer idle deadline and 2863 // A connection of its own per poll: the observer idle deadline and
2827 // the redial backoff stay the pump's problem, and this thread owns 2864 // the redial backoff stay the pump's problem, and this thread owns
2828 // no transport between polls that a teardown would have to reach. 2865 // no transport between polls that a teardown would have to reach.
2829 const got = client.listSessions(std.heap.page_allocator, h.spec.target, &out, 2000) catch null; 2866 var link: std.meta.Tag(client.Link) = .fd;
2867 const got = client.listSessions(std.heap.page_allocator, h.spec.poll_target, &out, 2000, &link) catch null;
2830 if (got) |list| { 2868 if (got) |list| {
2831 h.list_mu.lock(); 2869 h.list_mu.lock();
2832 @memcpy(h.list[0..list.len], list); 2870 @memcpy(h.list[0..list.len], list);
@@ -2837,7 +2875,10 @@ fn pollHost(h: *Host) void {
2837 h.list_ready.store(true, .release); 2875 h.list_ready.store(true, .release);
2838 ringKeyboard(h.shared); 2876 ringKeyboard(h.shared);
2839 var slept: u64 = 0; 2877 var slept: u64 = 0;
2840 while (slept < host_poll_ms and 2878 const wait = pollDelayMs(link);
2879 // A chord that births still pokes through it, so the stretched wait
2880 // costs a user's own action nothing.
2881 while (slept < wait and
2841 !h.poke.swap(false, .acq_rel) and 2882 !h.poke.swap(false, .acq_rel) and
2842 h.shared.running.load(.acquire)) : (slept += 50) 2883 h.shared.running.load(.acquire)) : (slept += 50)
2843 std.Thread.sleep(50 * std.time.ns_per_ms); 2884 std.Thread.sleep(50 * std.time.ns_per_ms);
@@ -3102,7 +3143,14 @@ pub fn runAttach(
3102 const arena = arena_state.allocator(); 3143 const arena = arena_state.allocator();
3103 const spelling = try hostSpelling(arena, target); 3144 const spelling = try hostSpelling(arena, target);
3104 var specs: std.ArrayList(HostSpec) = .empty; 3145 var specs: std.ArrayList(HostSpec) = .empty;
3105 try specs.append(arena, .{ .spelling = spelling, .target = target }); 3146 try specs.append(arena, .{
3147 .spelling = spelling,
3148 .target = target,
3149 // A failure here leaves the poll on the recipe the ATTACH used: it
3150 // can prompt, which is worse, but a wall that refuses to open over
3151 // one allocation is worse still.
3152 .poll_target = pollTargetFor(arena, target) catch target,
3153 });
3106 if (hosts.statePath(arena) catch null) |path| { 3154 if (hosts.statePath(arena) catch null) |path| {
3107 // stderr, not a notice: this runs before `run` takes the screen. 3155 // stderr, not a notice: this runs before `run` takes the screen.
3108 if (recordHost(arena, target, spelling, path)) |err| 3156 if (recordHost(arena, target, spelling, path)) |err|
@@ -4001,7 +4049,7 @@ fn endPumps(tiles: []Tile) void {
4001 4049
4002 fn testHost(shared: *Shared, spelling: []const u8, sock: []const u8) Host { 4050 fn testHost(shared: *Shared, spelling: []const u8, sock: []const u8) Host {
4003 return .{ 4051 return .{
4004 .spec = .{ .spelling = spelling, .target = .{ .sock = sock } }, 4052 .spec = .{ .spelling = spelling, .target = .{ .sock = sock }, .poll_target = .{ .sock = sock } },
4005 .shared = shared, 4053 .shared = shared,
4006 .idx = 0, 4054 .idx = 0,
4007 }; 4055 };
@@ -4377,8 +4425,8 @@ test "addHost: the prompt adds a DAEMON — a flag, a session and a host already
4377 // Two hosts on the wall before the prompt is ever opened: a table that 4425 // Two hosts on the wall before the prompt is ever opened: a table that
4378 // only ever held one cannot see an append landing on the wrong row. 4426 // only ever held one cannot see an append landing on the wrong row.
4379 var table: [4]Host = undefined; 4427 var table: [4]Host = undefined;
4380 table[0] = .{ .spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" } }, .shared = &shared, .idx = 0 }; 4428 table[0] = .{ .spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } }, .shared = &shared, .idx = 0 };
4381 table[1] = .{ .spec = .{ .spelling = "--sock /b", .target = .{ .sock = "/b" } }, .shared = &shared, .idx = 1 }; 4429 table[1] = .{ .spec = .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }, .shared = &shared, .idx = 1 };
4382 var n: usize = 2; 4430 var n: usize = 2;
4383 var buf: [96]u8 = undefined; 4431 var buf: [96]u8 = undefined;
4384 4432
@@ -4709,6 +4757,38 @@ test "resolveHost refuses a sun_path-overflowing sock path" {
4709 try std.testing.expectError(error.SockPathTooLong, resolveHost(alloc, long, null, 30_000)); 4757 try std.testing.expectError(error.SockPathTooLong, resolveHost(alloc, long, null, 30_000));
4710 } 4758 }
4711 4759
4760 test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cannot narrate" {
4761 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
4762 defer arena.deinit();
4763 const alloc = arena.allocator();
4764
4765 // Two spellings, because the poll target is built per host and a shared
4766 // one would point every stripe at the first host's ssh line.
4767 for ([_][]const u8{ "box", "user@gate" }) |spelling| {
4768 const spec = try resolveHost(alloc, spelling, null, 30_000);
4769 try std.testing.expect(std.mem.indexOf(u8, spec.poll_target.hand.ssh_cmd, "BatchMode=yes") != null);
4770 try std.testing.expectEqualStrings(spelling, spec.poll_target.hand.host);
4771 // The attach the user sees must still be able to ask for a password.
4772 try std.testing.expect(std.mem.indexOf(u8, spec.target.hand.ssh_cmd, "BatchMode") == null);
4773 // Neither door narrates a fallback: both are dialled under a wall
4774 // that owns the alternate screen.
4775 try std.testing.expect(!spec.target.hand.report_fallback);
4776 try std.testing.expect(!spec.poll_target.hand.report_fallback);
4777 }
4778
4779 // A transport that cannot prompt has one target, not a copy of one.
4780 const s = try resolveHost(alloc, "--sock /tmp/a.sock", null, 30_000);
4781 try std.testing.expectEqualStrings(s.target.sock, s.poll_target.sock);
4782 }
4783
4784 test "pollDelayMs: a poll that cost an ssh login is asked ten times less often" {
4785 // The whole point of the number: a pipe-answered poll spawned `ssh`,
4786 // read the announce and killed it — a remote auth log line per cycle.
4787 try std.testing.expectEqual(@as(u64, 10_000), pollDelayMs(.pipe));
4788 try std.testing.expectEqual(@as(u64, 1_000), pollDelayMs(.fd));
4789 try std.testing.expectEqual(@as(u64, 1_000), pollDelayMs(.quic));
4790 }
4791
4712 test "endAction: the only tile's ending is mux's, and so is any ending nobody can leave" { 4792 test "endAction: the only tile's ending is mux's, and so is any ending nobody can leave" {
4713 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 4793 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4714 var tiles: [3]Tile = undefined; 4794 var tiles: [3]Tile = undefined;
@@ -6539,7 +6619,7 @@ test "otherHosts: the dialled host is not tiled twice, and the rest follow in fi
6539 defer arena_state.deinit(); 6619 defer arena_state.deinit();
6540 const arena = arena_state.allocator(); 6620 const arena = arena_state.allocator();
6541 var specs: std.ArrayList(HostSpec) = .empty; 6621 var specs: std.ArrayList(HostSpec) = .empty;
6542 try specs.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" } }); 6622 try specs.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } });
6543 otherHosts(arena, &specs, "--sock /b", path, null, 0); 6623 otherHosts(arena, &specs, "--sock /b", path, null, 0);
6544 6624
6545 try std.testing.expectEqual(@as(usize, 2), specs.items.len); 6625 try std.testing.expectEqual(@as(usize, 2), specs.items.len);
@@ -6550,7 +6630,7 @@ test "otherHosts: the dialled host is not tiled twice, and the rest follow in fi
6550 // they asked for: the entry host stands alone and the attach goes on. 6630 // they asked for: the entry host stands alone and the attach goes on.
6551 try wall.saveBytes(path, "--sock /a\nbox#old\n"); 6631 try wall.saveBytes(path, "--sock /a\nbox#old\n");
6552 var one: std.ArrayList(HostSpec) = .empty; 6632 var one: std.ArrayList(HostSpec) = .empty;
6553 try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" } }); 6633 try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } });
6554 otherHosts(arena, &one, "--sock /b", path, null, 0); 6634 otherHosts(arena, &one, "--sock /b", path, null, 0);
6555 try std.testing.expectEqual(@as(usize, 1), one.items.len); 6635 try std.testing.expectEqual(@as(usize, 1), one.items.len);
6556 } 6636 }
src/webhub.zig
Old New
@@ -110,7 +110,7 @@ fn resolveTile(
110 .{ .sock = try arena.dupe(u8, path) }, 110 .{ .sock = try arena.dupe(u8, path) },
111 .host => |h| blk: { 111 .host => |h| blk: {
112 const hd = try arena.dupe(u8, h); 112 const hd = try arena.dupe(u8, h);
113 const r = try handoff.recipeFor(arena, hd); 113 const r = try handoff.recipeFor(arena, hd, false);
114 break :blk .{ .hand = .{ 114 break :blk .{ .hand = .{
115 .host = hd, 115 .host = hd,
116 .ssh_cmd = r.ssh_cmd, 116 .ssh_cmd = r.ssh_cmd,