4a4fbf20
feat: the dial a user asked for starts the daemon it did not find
a73x 2026-08-28 19:53
Commit message
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -359,6 +359,7 @@ pub fn main() !u8 { | |||
| 359 | return wallview.runAttach(alloc, .{ .hand = .{ | 359 | return wallview.runAttach(alloc, .{ .hand = .{ |
| 360 | .host = h.name, | 360 | .host = h.name, |
| 361 | .ssh_cmd = r.ssh_cmd, | 361 | .ssh_cmd = r.ssh_cmd, |
| 362 | .start_cmd = r.start_cmd, | ||
| 362 | .cache_path = r.cache_path, | 363 | .cache_path = r.cache_path, |
| 363 | .idle_ms = h.idle_ms, | 364 | .idle_ms = h.idle_ms, |
| 364 | } }, h.session, null, h.idle_ms, h.agent); | 365 | } }, h.session, null, h.idle_ms, h.agent); |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -189,6 +189,10 @@ pub const HandoffTarget = struct { | |||
| 189 | /// `ssh <host> muxd endpoint`, prebuilt by mux_main — it has the | 189 | /// `ssh <host> muxd endpoint`, prebuilt by mux_main — it has the |
| 190 | /// allocator, and it builds this once for the whole session. | 190 | /// allocator, and it builds this once for the whole session. |
| 191 | ssh_cmd: []const u8, | 191 | ssh_cmd: []const u8, |
| 192 | /// `ssh <host> muxd start`, from the same `handoff.recipeFor` call. | ||
| 193 | /// Empty is "nothing to start": the dial either finds a daemon or does | ||
| 194 | /// not. | ||
| 195 | start_cmd: []const u8 = "", | ||
| 192 | /// Where the last announce is remembered. Null means never cache (an | 196 | /// Where the last announce is remembered. Null means never cache (an |
| 193 | /// uncacheable host, or no resolvable cache directory): every attach is | 197 | /// uncacheable host, or no resolvable cache directory): every attach is |
| 194 | /// then cold, which costs time and stays correct. | 198 | /// then cold, which costs time and stays correct. |
| @@ -197,19 +201,26 @@ pub const HandoffTarget = struct { | |||
| 197 | /// tests can shrink it; production passes `handoff.deadline_ms`. | 201 | /// tests can shrink it; production passes `handoff.deadline_ms`. |
| 198 | deadline_ms: u32 = handoff.deadline_ms, | 202 | deadline_ms: u32 = handoff.deadline_ms, |
| 199 | idle_ms: u32 = quic_idle_ms_default, | 203 | idle_ms: u32 = quic_idle_ms_default, |
| 200 | /// Whether a fallback to ssh may say so. True on the ENTRY dial, where | 204 | /// Whether a USER asked for this dial: the entry attach and a picker |
| 201 | /// the line reports a choice the user has not seen made; false for | 205 | /// birth, never a poll, a reconnect or a restored tile. It gates the |
| 202 | /// every pump-side dial — a stripe's bar narrates state, and a | 206 | /// two things a dial may do beyond dialling. |
| 203 | /// reconnect re-runs this recipe forever against dropped UDP. | 207 | /// |
| 204 | /// One line per retry would scroll a live session's stderr into the | 208 | /// SAY SO when the session falls back to ssh. The line reports a |
| 205 | /// alternate screen and corrupt the paint, to say what the | 209 | /// choice the user has not seen made, and a reconnect re-runs this |
| 206 | /// [reconnecting] banner is already saying. | 210 | /// recipe forever against dropped UDP: one line per retry would scroll |
| 211 | /// a live session's stderr into the alternate screen and corrupt the | ||
| 212 | /// paint, to say what the [reconnecting] banner is already saying. | ||
| 213 | /// | ||
| 214 | /// START a daemon that was not there, via `start_cmd`. `muxd endpoint` | ||
| 215 | /// no longer does it: a wall polls every listed host once a second, and | ||
| 216 | /// a poll that starts daemons undoes a `muxd stop` a second after it is | ||
| 217 | /// typed. | ||
| 207 | /// | 218 | /// |
| 208 | /// The trade, stated plainly: a session that degrades to ssh mid-life | 219 | /// The trade, stated plainly: a session that degrades to ssh mid-life |
| 209 | /// says nothing at all about it. The user who wants to know can detach | 220 | /// says nothing at all about it. The user who wants to know can detach |
| 210 | /// and reattach, which reports it on the next attach; the alternative | 221 | /// and reattach, which reports it on the next attach; the alternative |
| 211 | /// is noise on every backoff cycle for as long as the link stays bad. | 222 | /// is noise on every backoff cycle for as long as the link stays bad. |
| 212 | report_fallback: bool = true, | 223 | asked: bool = true, |
| 213 | }; | 224 | }; |
| 214 | 225 | ||
| 215 | /// A union, not four nullable fields — "exactly one is set" stops being a | 226 | /// A union, not four nullable fields — "exactly one is set" stops being a |
| @@ -380,7 +391,7 @@ pub const Transport = struct { | |||
| 380 | // carry is null by policy. Unconsumed type-ahead is not lost: it | 391 | // carry is null by policy. Unconsumed type-ahead is not lost: it |
| 381 | // waits in the kernel's tty buffer for the session's first read. | 392 | // waits in the kernel's tty buffer for the session's first read. |
| 382 | const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd; | 393 | const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd; |
| 383 | const ep = (try readAnnounceAbortable(child.stdout.?.handle, alloc, null, announce_abort_fd)) orelse { | 394 | const ep = (try announceOrStart(alloc, h, &child, announce_abort_fd)) orelse { |
| 384 | // `endpoint none`: the remote said, explicitly, that ssh is the | 395 | // `endpoint none`: the remote said, explicitly, that ssh is the |
| 385 | // session. Silent by design — no coordinates were ever in play, | 396 | // session. Silent by design — no coordinates were ever in play, |
| 386 | // so there is nothing here to report as having failed. | 397 | // so there is nothing here to report as having failed. |
| @@ -420,7 +431,7 @@ pub const Transport = struct { | |||
| 420 | // no cause: the client cannot tell a blocked port from a | 431 | // no cause: the client cannot tell a blocked port from a |
| 421 | // wrong key (both are silence), and guessing would be worse | 432 | // wrong key (both are silence), and guessing would be worse |
| 422 | // than the deadline it just spent. | 433 | // than the deadline it just spent. |
| 423 | if (h.report_fallback) std.debug.print( | 434 | if (h.asked) std.debug.print( |
| 424 | "mux: quic://{s}:{d} unreachable, attaching over ssh\n", | 435 | "mux: quic://{s}:{d} unreachable, attaching over ssh\n", |
| 425 | .{ handoff.dialHost(h.host), ep.port }, | 436 | .{ handoff.dialHost(h.host), ep.port }, |
| 426 | ); | 437 | ); |
| @@ -429,6 +440,45 @@ pub const Transport = struct { | |||
| 429 | } | 440 | } |
| 430 | } | 441 | } |
| 431 | 442 | ||
| 443 | /// One announce, or — for a dial the user ASKED for — a `muxd start` | ||
| 444 | /// on the far end and one more. | ||
| 445 | fn announceOrStart( | ||
| 446 | alloc: std.mem.Allocator, | ||
| 447 | h: HandoffTarget, | ||
| 448 | child: *std.process.Child, | ||
| 449 | abort_fd: std.posix.fd_t, | ||
| 450 | ) !?handoff.Endpoint { | ||
| 451 | return readAnnounceAbortable(child.stdout.?.handle, alloc, null, abort_fd) catch |err| { | ||
| 452 | // A remote with no daemon writes nothing and exits, which | ||
| 453 | // arrives here as an announce that never terminated. | ||
| 454 | // `announceFailed` is that "ssh worked, the announce did not" | ||
| 455 | // shape: a start is worth trying only when the far end was | ||
| 456 | // reached and had nothing to say. | ||
| 457 | if (!h.asked or h.start_cmd.len == 0 or !announceFailed(err)) return err; | ||
| 458 | _ = child.kill() catch {}; | ||
| 459 | // A start that did not take is reported as the announce failure | ||
| 460 | // it began as: `muxd start` has already said its own piece on | ||
| 461 | // stderr, and a second guess over the top of it would be worse. | ||
| 462 | if (!runStart(alloc, h.start_cmd)) return err; | ||
| 463 | child.* = try spawnPipe(alloc, h.ssh_cmd); | ||
| 464 | // ONCE. A box that cannot hold a daemon costs this attach one | ||
| 465 | // extra round trip; a loop would cost it the afternoon. | ||
| 466 | return readAnnounceAbortable(child.stdout.?.handle, alloc, null, abort_fd); | ||
| 467 | }; | ||
| 468 | } | ||
| 469 | |||
| 470 | /// Waits: nothing may dial the far end again until it has a verdict. | ||
| 471 | fn runStart(alloc: std.mem.Allocator, cmd: []const u8) bool { | ||
| 472 | var c = std.process.Child.init(&.{ "/bin/sh", "-c", cmd }, alloc); | ||
| 473 | // stdout and stderr inherited, like `spawnPipe`'s ssh: the progress | ||
| 474 | // and the verdict are the user's only account of a wait they are | ||
| 475 | // sitting through. stdin is NOT — ssh would eat the keystrokes the | ||
| 476 | // session is about to want, and its prompts read /dev/tty anyway. | ||
| 477 | c.stdin_behavior = .Ignore; | ||
| 478 | const term = c.spawnAndWait() catch return false; | ||
| 479 | return term == .Exited and term.Exited == 0; | ||
| 480 | } | ||
| 481 | |||
| 432 | /// The dial's budget is the handoff deadline while the CONNECTION keeps | 482 | /// The dial's budget is the handoff deadline while the CONNECTION keeps |
| 433 | /// the ordinary `idle_ms`; `quicTransport` spells that split. | 483 | /// the ordinary `idle_ms`; `quicTransport` spells that split. |
| 434 | pub fn openQuicEndpoint( | 484 | pub fn openQuicEndpoint( |
| @@ -1426,6 +1476,121 @@ test "handoff: abort_fd -1 means no abort channel — fd 0 is never read" { | |||
| 1426 | try std.testing.expectEqualStrings("", carry.items); | 1476 | try std.testing.expectEqualStrings("", carry.items); |
| 1427 | } | 1477 | } |
| 1428 | 1478 | ||
| 1479 | /// How many times the ssh shim below ran: it appends one two-byte line per | ||
| 1480 | /// run, so the file's size is the count. | ||
| 1481 | fn shimRuns(dir: []const u8, name: []const u8) !u64 { | ||
| 1482 | var buf: [512]u8 = undefined; | ||
| 1483 | const path = try std.fmt.bufPrint(&buf, "{s}/{s}", .{ dir, name }); | ||
| 1484 | const f = std.fs.cwd().openFile(path, .{}) catch return 0; | ||
| 1485 | defer f.close(); | ||
| 1486 | return (try f.stat()).size / 2; | ||
| 1487 | } | ||
| 1488 | |||
| 1489 | fn shimMade(dir: []const u8, name: []const u8) !bool { | ||
| 1490 | var buf: [512]u8 = undefined; | ||
| 1491 | const path = try std.fmt.bufPrint(&buf, "{s}/{s}", .{ dir, name }); | ||
| 1492 | std.fs.cwd().access(path, .{}) catch return false; | ||
| 1493 | return true; | ||
| 1494 | } | ||
| 1495 | |||
| 1496 | test "openHandoff: the dial a user ASKED for starts the daemon it did not find, and re-runs the ssh line exactly once" { | ||
| 1497 | // `muxd endpoint` on a box with no daemon exits 1 having written | ||
| 1498 | // nothing, which arrives here as an announce that never terminated. | ||
| 1499 | // The shim is that box: the first run finds no `started` flag and | ||
| 1500 | // fails the same way; the start command drops the flag; the second run | ||
| 1501 | // announces. Asked of the FILESYSTEM, never of the client, because | ||
| 1502 | // "was a daemon started" is a question about the world. | ||
| 1503 | const alloc = std.testing.allocator; | ||
| 1504 | var carry: std.ArrayList(u8) = .empty; | ||
| 1505 | defer carry.deinit(alloc); | ||
| 1506 | var stdin = try FakeStdin.install(""); | ||
| 1507 | defer stdin.deinit(); | ||
| 1508 | var tmp = try TmpDir.make(); | ||
| 1509 | defer tmp.cleanup(); | ||
| 1510 | |||
| 1511 | var ssh_buf: [512]u8 = undefined; | ||
| 1512 | const ssh_cmd = try std.fmt.bufPrint( | ||
| 1513 | &ssh_buf, | ||
| 1514 | "echo x >>{s}/runs; test -e {s}/started || exit 1; printf 'endpoint none\n'; cat >/dev/null", | ||
| 1515 | .{ tmp.path(), tmp.path() }, | ||
| 1516 | ); | ||
| 1517 | var start_buf: [512]u8 = undefined; | ||
| 1518 | const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()}); | ||
| 1519 | |||
| 1520 | var t = try Transport.open(alloc, .{ .hand = .{ | ||
| 1521 | .host = "fake", | ||
| 1522 | .ssh_cmd = ssh_cmd, | ||
| 1523 | .start_cmd = start_cmd, | ||
| 1524 | .cache_path = null, | ||
| 1525 | .deadline_ms = 200, | ||
| 1526 | } }, &carry, std.posix.STDIN_FILENO); | ||
| 1527 | defer t.close(); | ||
| 1528 | |||
| 1529 | try std.testing.expect(t.link == .pipe); | ||
| 1530 | try std.testing.expect(try shimMade(tmp.path(), "started")); | ||
| 1531 | try std.testing.expectEqual(@as(u64, 2), try shimRuns(tmp.path(), "runs")); | ||
| 1532 | } | ||
| 1533 | |||
| 1534 | test "openHandoff: a dial nobody asked for reports the failure and starts nothing — the wall polls every listed host once a second" { | ||
| 1535 | // The regression this exists for: a poll that starts a daemon gives a | ||
| 1536 | // listed box one (and a shell in session 0) from a READ, and undoes a | ||
| 1537 | // `muxd stop` on the far end a second after it is typed. | ||
| 1538 | const alloc = std.testing.allocator; | ||
| 1539 | var carry: std.ArrayList(u8) = .empty; | ||
| 1540 | defer carry.deinit(alloc); | ||
| 1541 | var stdin = try FakeStdin.install(""); | ||
| 1542 | defer stdin.deinit(); | ||
| 1543 | var tmp = try TmpDir.make(); | ||
| 1544 | defer tmp.cleanup(); | ||
| 1545 | |||
| 1546 | var ssh_buf: [512]u8 = undefined; | ||
| 1547 | const ssh_cmd = try std.fmt.bufPrint(&ssh_buf, "echo x >>{s}/runs; exit 1", .{tmp.path()}); | ||
| 1548 | var start_buf: [512]u8 = undefined; | ||
| 1549 | const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()}); | ||
| 1550 | |||
| 1551 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ | ||
| 1552 | .host = "fake", | ||
| 1553 | .ssh_cmd = ssh_cmd, | ||
| 1554 | .start_cmd = start_cmd, | ||
| 1555 | .cache_path = null, | ||
| 1556 | .deadline_ms = 200, | ||
| 1557 | .asked = false, | ||
| 1558 | } }, &carry, std.posix.STDIN_FILENO)); | ||
| 1559 | |||
| 1560 | try std.testing.expect(!try shimMade(tmp.path(), "started")); | ||
| 1561 | try std.testing.expectEqual(@as(u64, 1), try shimRuns(tmp.path(), "runs")); | ||
| 1562 | } | ||
| 1563 | |||
| 1564 | test "openHandoff: a start that does not help is tried once — a second announce, never a third" { | ||
| 1565 | // A box that refuses to hold a daemon (a bad shell, a full disk) must | ||
| 1566 | // cost the attach one extra round trip, not a loop of them: the ask | ||
| 1567 | // buys ONE start and ONE retry, and the original announce error is | ||
| 1568 | // what the user is told about. | ||
| 1569 | const alloc = std.testing.allocator; | ||
| 1570 | var carry: std.ArrayList(u8) = .empty; | ||
| 1571 | defer carry.deinit(alloc); | ||
| 1572 | var stdin = try FakeStdin.install(""); | ||
| 1573 | defer stdin.deinit(); | ||
| 1574 | var tmp = try TmpDir.make(); | ||
| 1575 | defer tmp.cleanup(); | ||
| 1576 | |||
| 1577 | var ssh_buf: [512]u8 = undefined; | ||
| 1578 | const ssh_cmd = try std.fmt.bufPrint(&ssh_buf, "echo x >>{s}/runs; exit 1", .{tmp.path()}); | ||
| 1579 | var start_buf: [512]u8 = undefined; | ||
| 1580 | const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()}); | ||
| 1581 | |||
| 1582 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ | ||
| 1583 | .host = "fake", | ||
| 1584 | .ssh_cmd = ssh_cmd, | ||
| 1585 | .start_cmd = start_cmd, | ||
| 1586 | .cache_path = null, | ||
| 1587 | .deadline_ms = 200, | ||
| 1588 | } }, &carry, std.posix.STDIN_FILENO)); | ||
| 1589 | |||
| 1590 | try std.testing.expect(try shimMade(tmp.path(), "started")); | ||
| 1591 | try std.testing.expectEqual(@as(u64, 2), try shimRuns(tmp.path(), "runs")); | ||
| 1592 | } | ||
| 1593 | |||
| 1429 | test "lostMsg: only a --via transport that never connected gets the new wording" { | 1594 | test "lostMsg: only a --via transport that never connected gets the new wording" { |
| 1430 | // The case the message exists for: a command that failed to start. It | 1595 | // The case the message exists for: a command that failed to start. It |
| 1431 | // names what happened and guesses no cause — ssh's own stderr passes | 1596 | // names what happened and guesses no cause — ssh's own stderr passes |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -77,13 +77,15 @@ pub fn resolveHost( | |||
| 77 | .hand = .{ | 77 | .hand = .{ |
| 78 | .host = h, | 78 | .host = h, |
| 79 | .ssh_cmd = r.ssh_cmd, | 79 | .ssh_cmd = r.ssh_cmd, |
| 80 | .start_cmd = r.start_cmd, | ||
| 80 | .cache_path = r.cache_path, | 81 | .cache_path = r.cache_path, |
| 81 | .idle_ms = idle_ms, | 82 | .idle_ms = idle_ms, |
| 82 | // No door off this spec is the attach that made the choice: | 83 | // A host line is a listing, not an attach anyone waited |
| 83 | // the pump clears its own copy, and the POLLER's would | 84 | // for. The POLLER runs off this spec once a second: an |
| 84 | // otherwise print the fallback line onto the wall's | 85 | // asked copy would print the fallback line onto the |
| 85 | // alternate screen once per cycle, forever. | 86 | // wall's alternate screen every cycle, and would start |
| 86 | .report_fallback = false, | 87 | // a daemon on a box whose owner just stopped one. |
| 88 | .asked = false, | ||
| 87 | }, | 89 | }, |
| 88 | }; | 90 | }; |
| 89 | }, | 91 | }, |
| @@ -121,9 +123,10 @@ fn pollTargetFor(alloc: std.mem.Allocator, target: client.Target) !client.Target | |||
| 121 | return .{ .hand = .{ | 123 | return .{ .hand = .{ |
| 122 | .host = h.host, | 124 | .host = h.host, |
| 123 | .ssh_cmd = r.ssh_cmd, | 125 | .ssh_cmd = r.ssh_cmd, |
| 126 | .start_cmd = r.start_cmd, | ||
| 124 | .cache_path = r.cache_path, | 127 | .cache_path = r.cache_path, |
| 125 | .idle_ms = h.idle_ms, | 128 | .idle_ms = h.idle_ms, |
| 126 | .report_fallback = false, | 129 | .asked = false, |
| 127 | } }; | 130 | } }; |
| 128 | } | 131 | } |
| 129 | 132 | ||
| @@ -1302,7 +1305,7 @@ fn pumpTile(t: *Tile) void { | |||
| 1302 | var target = t.r.target; | 1305 | var target = t.r.target; |
| 1303 | // The stripe's label already narrates state; a fallback line on | 1306 | // The stripe's label already narrates state; a fallback line on |
| 1304 | // stderr would corrupt the alternate screen. Same as the hub's pump. | 1307 | // stderr would corrupt the alternate screen. Same as the hub's pump. |
| 1305 | if (target == .hand) target.hand.report_fallback = false; | 1308 | if (target == .hand) target.hand.asked = false; |
| 1306 | 1309 | ||
| 1307 | // ONE Core per tile, from birth. It owns this tile's replica, its | 1310 | // ONE Core per tile, from birth. It owns this tile's replica, its |
| 1308 | // prediction overlay and its drag for the tile's whole life: the tile | 1311 | // prediction overlay and its drag for the tile's whole life: the tile |
| @@ -6182,8 +6185,8 @@ test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cann | |||
| 6182 | try std.testing.expect(std.mem.indexOf(u8, spec.target.hand.ssh_cmd, "BatchMode") == null); | 6185 | try std.testing.expect(std.mem.indexOf(u8, spec.target.hand.ssh_cmd, "BatchMode") == null); |
| 6183 | // Neither door narrates a fallback: both are dialled under a wall | 6186 | // Neither door narrates a fallback: both are dialled under a wall |
| 6184 | // that owns the alternate screen. | 6187 | // that owns the alternate screen. |
| 6185 | try std.testing.expect(!spec.target.hand.report_fallback); | 6188 | try std.testing.expect(!spec.target.hand.asked); |
| 6186 | try std.testing.expect(!spec.poll_target.hand.report_fallback); | 6189 | try std.testing.expect(!spec.poll_target.hand.asked); |
| 6187 | } | 6190 | } |
| 6188 | 6191 | ||
| 6189 | // A transport that cannot prompt has one target, not a copy of one. | 6192 | // A transport that cannot prompt has one target, not a copy of one. |
src/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -114,6 +114,7 @@ fn resolveTile( | |||
| 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, |
| 117 | .start_cmd = r.start_cmd, | ||
| 117 | .cache_path = r.cache_path, | 118 | .cache_path = r.cache_path, |
| 118 | .idle_ms = idle_ms, | 119 | .idle_ms = idle_ms, |
| 119 | } }; | 120 | } }; |
| @@ -154,7 +155,7 @@ fn copyTarget(arena: std.mem.Allocator, t: client.Target) !client.Target { | |||
| 154 | comptime { | 155 | comptime { |
| 155 | std.debug.assert(@typeInfo(client.Target).@"union".fields.len == 4); | 156 | std.debug.assert(@typeInfo(client.Target).@"union".fields.len == 4); |
| 156 | std.debug.assert(@typeInfo(client.QuicTarget).@"struct".fields.len == 4); | 157 | std.debug.assert(@typeInfo(client.QuicTarget).@"struct".fields.len == 4); |
| 157 | std.debug.assert(@typeInfo(client.HandoffTarget).@"struct".fields.len == 6); | 158 | std.debug.assert(@typeInfo(client.HandoffTarget).@"struct".fields.len == 7); |
| 158 | } | 159 | } |
| 159 | return switch (t) { | 160 | return switch (t) { |
| 160 | .sock => |s| .{ .sock = try arena.dupe(u8, s) }, | 161 | .sock => |s| .{ .sock = try arena.dupe(u8, s) }, |
| @@ -168,10 +169,11 @@ fn copyTarget(arena: std.mem.Allocator, t: client.Target) !client.Target { | |||
| 168 | .hand => |h| .{ .hand = .{ | 169 | .hand => |h| .{ .hand = .{ |
| 169 | .host = try arena.dupe(u8, h.host), | 170 | .host = try arena.dupe(u8, h.host), |
| 170 | .ssh_cmd = try arena.dupe(u8, h.ssh_cmd), | 171 | .ssh_cmd = try arena.dupe(u8, h.ssh_cmd), |
| 172 | .start_cmd = try arena.dupe(u8, h.start_cmd), | ||
| 171 | .cache_path = if (h.cache_path) |c| try arena.dupe(u8, c) else null, | 173 | .cache_path = if (h.cache_path) |c| try arena.dupe(u8, c) else null, |
| 172 | .deadline_ms = h.deadline_ms, | 174 | .deadline_ms = h.deadline_ms, |
| 173 | .idle_ms = h.idle_ms, | 175 | .idle_ms = h.idle_ms, |
| 174 | .report_fallback = h.report_fallback, | 176 | .asked = h.asked, |
| 175 | } }, | 177 | } }, |
| 176 | }; | 178 | }; |
| 177 | } | 179 | } |
| @@ -799,8 +801,10 @@ pub fn pumpTile( | |||
| 799 | var target = target_in; | 801 | var target = target_in; |
| 800 | // No terminal to spam and a control channel that already narrates: | 802 | // No terminal to spam and a control channel that already narrates: |
| 801 | // the one fallback line the CLI allows itself is quieted here, the | 803 | // the one fallback line the CLI allows itself is quieted here, the |
| 802 | // same way reconnect() quiets retries. | 804 | // same way reconnect() quiets retries. It is also what keeps a browser |
| 803 | if (target == .hand) target.hand.report_fallback = false; | 805 | // from starting daemons: a hub tile redials for as long as the page is |
| 806 | // open, and nobody is watching it do so. | ||
| 807 | if (target == .hand) target.hand.asked = false; | ||
| 804 | 808 | ||
| 805 | var restore: Restore = .{}; | 809 | var restore: Restore = .{}; |
| 806 | 810 | ||