b69ec3c6
fix: an ssh-only host is polled quietly, and rarely
a73x 2026-08-28 19:53
Commit message
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, |