afe13cc9
feat: mux opens the wall of hosts; mux hosts add/rm/list
a73x 2026-08-28 19:53
Commit message
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -4,12 +4,13 @@ | |||
| 4 | //! `mux HOST` runs the ssh→QUIC handoff — ssh fetches the daemon's QUIC | 4 | //! `mux HOST` runs the ssh→QUIC handoff — ssh fetches the daemon's QUIC |
| 5 | //! coordinates and carries the session only if the QUIC dial does not. | 5 | //! coordinates and carries the session only if the QUIC dial does not. |
| 6 | //! | 6 | //! |
| 7 | //! Every one of those is a WALL of one tile whose rect is the whole terminal | 7 | //! Bare `mux` is the WALL: every session every daemon on |
| 8 | //! (`wallview.runAttach`); `mux wall` is the same program entered on the | 8 | //! `$XDG_STATE_HOME/mux/hosts` has live. Naming a transport is the same |
| 9 | //! wall itself. What lives up here is argv, the refusals that must happen | 9 | //! wall, entered zoomed on that daemon's session (`wallview.runAttach`). |
| 10 | //! before a dial (self-attach, an unbindable socket path), the auto-start, | 10 | //! What lives up here is argv, the refusals that must happen before a dial |
| 11 | //! and the wall-file subcommands — everything, in other words, that is | 11 | //! (self-attach, an unbindable socket path), the auto-start, and the |
| 12 | //! about the command line rather than about a session. | 12 | //! `hosts` subcommand — everything about the command line rather than |
| 13 | //! about a session. | ||
| 13 | const std = @import("std"); | 14 | const std = @import("std"); |
| 14 | const client = @import("client"); | 15 | const client = @import("client"); |
| 15 | const proto = @import("protocol"); | 16 | const proto = @import("protocol"); |
| @@ -39,20 +40,21 @@ const usage = | |||
| 39 | \\ whoever typed last is whose agent signs, and only while attached | 40 | \\ whoever typed last is whose agent signs, and only while attached |
| 40 | \\ --version prints the version, --help prints this | 41 | \\ --version prints the version, --help prints this |
| 41 | \\ | 42 | \\ |
| 42 | \\ mux wall [SPELLING...] shows several sessions at once, one stripe | 43 | \\ mux the wall: every session every listed daemon has live |
| 43 | \\ each; `Ctrl-\ 1-9` focuses a tile and types into it, `Ctrl-\ h/j/k/l` | 44 | \\ mux -A the same wall, entered on the local session with the |
| 44 | \\ moves between panes, `Ctrl-\ |/-` split right/below, `Ctrl-\ f` | 45 | \\ agent armed — bare `mux` carries no agent |
| 45 | \\ fullscreen, `Ctrl-\ r` resize mode, `Ctrl-\ :` adds a tile by | 46 | \\ mux hosts list the daemons on the wall, with their live session counts |
| 46 | \\ spelling (Enter adds, Esc cancels), `Ctrl-\ d` leaves. SPELLING is | 47 | \\ mux hosts add SPELLING put a daemon on the wall without opening it |
| 47 | \\ the wall grammar | 48 | \\ mux hosts rm SPELLING take one off (its sessions keep running) |
| 48 | \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION], | ||
| 49 | \\ one argument per tile, but `--sock PATH` may also be two arguments | ||
| 50 | \\ as in muxweb); with none, the saved wall is shown. | ||
| 51 | \\ | 49 | \\ |
| 52 | \\ mux wall add SPELLING... / mux wall rm SPELLING... edit the saved | 50 | \\ SPELLING names a DAEMON — HOST, quic://HOST[:PORT], or --sock PATH |
| 53 | \\ wall without dialling anything. The wall is attach history: `mux` | 51 | \\ (one argument, or two as in muxweb) — and never a session: `#NAME` |
| 54 | \\ itself adds the tile it attaches to, `Ctrl-\ x` on the wall forgets one, | 52 | \\ is refused, because the wall shows every session a daemon has. |
| 55 | \\ and neither ever kills a session. | 53 | \\ |
| 54 | \\ On the wall, `Ctrl-\ 1-9` focuses a tile and types into it, | ||
| 55 | \\ `Ctrl-\ h/j/k/l` moves between panes, `Ctrl-\ |/-` split right/below, | ||
| 56 | \\ `Ctrl-\ f` fullscreen, `Ctrl-\ r` resize mode, `Ctrl-\ :` adds a tile | ||
| 57 | \\ by spelling (Enter adds, Esc cancels), `Ctrl-\ d` leaves. | ||
| 56 | \\ | 58 | \\ |
| 57 | ; | 59 | ; |
| 58 | 60 | ||
| @@ -259,10 +261,16 @@ pub fn main() !u8 { | |||
| 259 | const args = try std.process.argsAlloc(alloc); | 261 | const args = try std.process.argsAlloc(alloc); |
| 260 | defer std.process.argsFree(alloc, args); | 262 | defer std.process.argsFree(alloc, args); |
| 261 | 263 | ||
| 262 | // A subcommand, checked before the flag parse: `wall` is a different | 264 | // A subcommand, checked before the flag parse: `hosts` edits or reads a |
| 263 | // program (N passive tiles), not a transport spelling for one attach. | 265 | // file and dials no session, so it is not a transport spelling. |
| 264 | if (args.len > 1 and std.mem.eql(u8, args[1], "wall")) | 266 | if (args.len > 1 and std.mem.eql(u8, args[1], "hosts")) |
| 265 | return wallMain(alloc, args[2..]); | 267 | return hostsMain(alloc, args[2..], null); |
| 268 | |||
| 269 | // Read off argv rather than off the parse: `mux` and `mux --sock | ||
| 270 | // <default>` produce the same ParseResult, and only one of them is the | ||
| 271 | // wall. Anything else typed is a transport the user named, and an | ||
| 272 | // explicitly named transport is an attach. | ||
| 273 | if (args.len == 1) return wallOfHosts(alloc); | ||
| 266 | 274 | ||
| 267 | const parsed = parseArgs(args, std.posix.getenv(xdg.key_env)) catch |e| switch (e) { | 275 | const parsed = parseArgs(args, std.posix.getenv(xdg.key_env)) catch |e| switch (e) { |
| 268 | error.Version => return cliflags.version("mux", build_options.version), | 276 | error.Version => return cliflags.version("mux", build_options.version), |
| @@ -360,276 +368,350 @@ pub fn main() !u8 { | |||
| 360 | const sock_path = if (t.sock) |s| | 368 | const sock_path = if (t.sock) |s| |
| 361 | try alloc.dupe(u8, s) | 369 | try alloc.dupe(u8, s) |
| 362 | else | 370 | else |
| 363 | sockpath.defaultSockPath(alloc) catch |err| switch (err) { | 371 | try defaultSock(alloc) orelse return 1; |
| 364 | error.NoRuntimeDir => { | ||
| 365 | std.debug.print( | ||
| 366 | "mux: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n", | ||
| 367 | .{}, | ||
| 368 | ); | ||
| 369 | return 1; | ||
| 370 | }, | ||
| 371 | else => |e| return e, | ||
| 372 | }; | ||
| 373 | defer alloc.free(sock_path); | 372 | defer alloc.free(sock_path); |
| 373 | return attachLocal(alloc, sock_path, t.session, t.agent); | ||
| 374 | }, | ||
| 375 | } | ||
| 376 | } | ||
| 374 | 377 | ||
| 375 | // Before the PATH search, before auto-start, before the dial: | 378 | /// The default socket path, or the one refusal that names `--sock` as the |
| 376 | // the refusal is about where this process is standing, and none | 379 | /// way out. |
| 377 | // of those three change the answer. Placed here rather than in | 380 | fn defaultSock(alloc: std.mem.Allocator) !?[]const u8 { |
| 378 | // parseArgs because the default socket path is resolved here, | 381 | return sockpath.defaultSockPath(alloc) catch |err| switch (err) { |
| 379 | // and a bare `mux` typed in a session shell is exactly the | 382 | error.NoRuntimeDir => { |
| 380 | // mistake this catches. It sits on the USER's attach only — | 383 | std.debug.print( |
| 381 | // the Ctrl-\ chords grow their tiles from inside the wall and | 384 | "mux: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n", |
| 382 | // never come back through this switch, so focusing from session | 385 | .{}, |
| 383 | // 0 to session 1 keeps working. `Ctrl-\ :` is the one chord | ||
| 384 | // that takes a spelling, and it runs `wallview.showsSelf` | ||
| 385 | // itself. | ||
| 386 | if (insideThisSession( | ||
| 387 | std.posix.getenv(proto.sock_env), | ||
| 388 | std.posix.getenv(proto.session_env), | ||
| 389 | sock_path, | ||
| 390 | t.session, | ||
| 391 | )) { | ||
| 392 | std.debug.print("{s}", .{self_attach_refusal}); | ||
| 393 | return 2; | ||
| 394 | } | ||
| 395 | |||
| 396 | // The same `sun_path` guard muxd applies (main.zig), for the | ||
| 397 | // same reason and off the same constant. It sits before the | ||
| 398 | // PATH search rather than at the connect because auto-start would | ||
| 399 | // otherwise reach it first: mux finds muxd, spawns a child that | ||
| 400 | // refuses the path instantly, and polls the full 2s into "daemon | ||
| 401 | // did not answer" — a timeout story about a path that was doomed | ||
| 402 | // at parse. Refusing here costs nothing and says the real thing. | ||
| 403 | if (sock_path.len > sockpath.max_sun_path) { | ||
| 404 | std.debug.print( | ||
| 405 | "mux: socket path too long ({d} bytes, max {d}): {s}\n", | ||
| 406 | .{ sock_path.len, sockpath.max_sun_path, sock_path }, | ||
| 407 | ); | ||
| 408 | return 1; | ||
| 409 | } | ||
| 410 | |||
| 411 | // Attach auto-start: give the attach a daemon to land on. | ||
| 412 | // Unix-socket transport only — quic:// has nothing local to | ||
| 413 | // spawn, and --via's auto-starter is the remote proxy. | ||
| 414 | const muxd_path = try spawn.findInPath( | ||
| 415 | alloc, | ||
| 416 | std.posix.getenv("PATH") orelse "", | ||
| 417 | "muxd", | ||
| 418 | ); | ||
| 419 | defer if (muxd_path) |p| alloc.free(p); | ||
| 420 | if (muxd_path) |exe| { | ||
| 421 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "mux")) return 1; | ||
| 422 | } else if (!spawn.probe(sock_path)) { | ||
| 423 | // No muxd anywhere AND nothing serving: only now is the | ||
| 424 | // missing binary the user's problem, and both facts fit in | ||
| 425 | // one honest line. A live daemon needs no binary on PATH. | ||
| 426 | std.debug.print("mux: no daemon on {s} and no muxd in PATH to start one\n", .{sock_path}); | ||
| 427 | return 1; | ||
| 428 | } | ||
| 429 | return wallview.runAttach( | ||
| 430 | alloc, | ||
| 431 | .{ .sock = sock_path }, | ||
| 432 | t.session, | ||
| 433 | null, | ||
| 434 | client.quic_idle_ms_default, | ||
| 435 | t.agent, | ||
| 436 | ); | 386 | ); |
| 387 | return null; | ||
| 437 | }, | 388 | }, |
| 389 | else => |e| return e, | ||
| 390 | }; | ||
| 391 | } | ||
| 392 | |||
| 393 | /// The local socket's one door: `mux --sock PATH` and an empty hosts file. | ||
| 394 | fn attachLocal( | ||
| 395 | alloc: std.mem.Allocator, | ||
| 396 | sock_path: []const u8, | ||
| 397 | session: []const u8, | ||
| 398 | agent: bool, | ||
| 399 | ) !u8 { | ||
| 400 | // Before the PATH search, before auto-start, before the dial: the | ||
| 401 | // refusal is about where this process is standing, and none of those | ||
| 402 | // three change the answer. Placed here rather than in parseArgs because | ||
| 403 | // the default socket path is resolved here, and a bare `mux` typed in a | ||
| 404 | // session shell is exactly the mistake this catches. It sits on the | ||
| 405 | // USER's attach only — the Ctrl-\ chords grow their tiles from inside | ||
| 406 | // the wall and never come back through here, so focusing from session 0 | ||
| 407 | // to session 1 keeps working. `Ctrl-\ :` is the one chord that takes a | ||
| 408 | // spelling, and it runs `wallview.showsSelf` itself. | ||
| 409 | if (insideThisSession( | ||
| 410 | std.posix.getenv(proto.sock_env), | ||
| 411 | std.posix.getenv(proto.session_env), | ||
| 412 | sock_path, | ||
| 413 | session, | ||
| 414 | )) { | ||
| 415 | std.debug.print("{s}", .{self_attach_refusal}); | ||
| 416 | return 2; | ||
| 438 | } | 417 | } |
| 418 | |||
| 419 | // The same `sun_path` guard muxd applies (main.zig), for the same | ||
| 420 | // reason and off the same constant. It sits before the PATH search | ||
| 421 | // rather than at the connect because auto-start would otherwise reach | ||
| 422 | // it first: mux finds muxd, spawns a child that refuses the path | ||
| 423 | // instantly, and polls the full 2s into "daemon did not answer" — a | ||
| 424 | // timeout story about a path that was doomed at parse. Refusing here | ||
| 425 | // costs nothing and says the real thing. | ||
| 426 | if (sock_path.len > sockpath.max_sun_path) { | ||
| 427 | std.debug.print( | ||
| 428 | "mux: socket path too long ({d} bytes, max {d}): {s}\n", | ||
| 429 | .{ sock_path.len, sockpath.max_sun_path, sock_path }, | ||
| 430 | ); | ||
| 431 | return 1; | ||
| 432 | } | ||
| 433 | |||
| 434 | // Attach auto-start: give the attach a daemon to land on. Unix-socket | ||
| 435 | // transport only — quic:// has nothing local to spawn, and --via's | ||
| 436 | // auto-starter is the remote proxy. | ||
| 437 | const muxd_path = try spawn.findInPath( | ||
| 438 | alloc, | ||
| 439 | std.posix.getenv("PATH") orelse "", | ||
| 440 | "muxd", | ||
| 441 | ); | ||
| 442 | defer if (muxd_path) |p| alloc.free(p); | ||
| 443 | if (muxd_path) |exe| { | ||
| 444 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "mux")) return 1; | ||
| 445 | } else if (!spawn.probe(sock_path)) { | ||
| 446 | // No muxd anywhere AND nothing serving: only now is the missing | ||
| 447 | // binary the user's problem, and both facts fit in one honest line. | ||
| 448 | // A live daemon needs no binary on PATH. | ||
| 449 | std.debug.print("mux: no daemon on {s} and no muxd in PATH to start one\n", .{sock_path}); | ||
| 450 | return 1; | ||
| 451 | } | ||
| 452 | return wallview.runAttach( | ||
| 453 | alloc, | ||
| 454 | .{ .sock = sock_path }, | ||
| 455 | session, | ||
| 456 | null, | ||
| 457 | client.quic_idle_ms_default, | ||
| 458 | agent, | ||
| 459 | ); | ||
| 439 | } | 460 | } |
| 440 | 461 | ||
| 441 | /// `mux wall`'s own line: two flags, and every other word a tile. Shares | 462 | /// `mux hosts add`'s own line: no flags of its own, every word a host |
| 442 | /// `usage` with the attach line, so the prose that documents `--key` there | 463 | /// spelling. It shares `usage` so a flag added here has to be documented |
| 443 | /// documents it here. | 464 | /// there like any other. |
| 444 | const WallOpts = struct { | 465 | const HostsOpts = struct { |
| 445 | key: ?[]const u8 = null, | 466 | _argv: hosts.Argv, |
| 446 | quic_idle_ms: client.IdleMs = .{}, | ||
| 447 | _argv: wall.Argv, | ||
| 448 | 467 | ||
| 449 | pub fn positional(self: *WallOpts, w: []const u8) bool { | 468 | pub fn positional(self: *HostsOpts, w: []const u8) bool { |
| 450 | return self._argv.positional(w); | 469 | return self._argv.positional(w); |
| 451 | } | 470 | } |
| 452 | pub fn extra(self: *WallOpts, rest: []const [:0]const u8) usize { | 471 | pub fn extra(self: *HostsOpts, rest: []const [:0]const u8) usize { |
| 453 | return self._argv.extra(rest); | 472 | return self._argv.extra(rest); |
| 454 | } | 473 | } |
| 455 | }; | 474 | }; |
| 456 | 475 | ||
| 457 | comptime { | 476 | comptime { |
| 458 | cliflags.assertDocumented(WallOpts, usage, &.{}); | 477 | cliflags.assertDocumented(HostsOpts, usage, &.{}); |
| 459 | } | 478 | } |
| 460 | 479 | ||
| 461 | /// `mux wall`: gather spellings (argv, or with none the saved wall — the | 480 | /// `mux`: the wall of daemons. An empty file is the local one, so a first |
| 462 | /// attach history mux itself writes, muxweb's too via argv and POST | 481 | /// run is still just a shell. |
| 463 | /// /tiles), resolve each through the one grammar, hand the lot to | 482 | /// |
| 464 | /// wallview.run. Resolution allocates into an arena because run() never | 483 | /// Resolution allocates into an arena because `wallview.run` never returns |
| 465 | /// returns on the success path (it exits the process — see wallview.run); | 484 | /// on the success path; only the early refusals come back through the |
| 466 | /// only the early usage-error paths come back through the defers here. | 485 | /// defers here. |
| 467 | fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { | 486 | fn wallOfHosts(alloc: std.mem.Allocator) !u8 { |
| 468 | var arena_state = std.heap.ArenaAllocator.init(alloc); | 487 | var arena_state = std.heap.ArenaAllocator.init(alloc); |
| 469 | defer arena_state.deinit(); | 488 | defer arena_state.deinit(); |
| 470 | const arena = arena_state.allocator(); | 489 | const arena = arena_state.allocator(); |
| 471 | 490 | ||
| 472 | // `add`/`rm` before the tile parse, exactly as `wall` itself sits | 491 | const path = try hosts.statePath(arena); |
| 473 | // before the flag parse: they are file edits, not a wall to show. The | 492 | const h = hosts.load(arena, path) catch |err| { |
| 474 | // cost is that a tile spelled literally `add` can no longer be the | 493 | std.debug.print("mux: {s}: {s}\n", .{ path, hosts.reason(err) }); |
| 475 | // first argument of `mux wall` — a hostname the wall grammar would | ||
| 476 | // otherwise accept — which is the ordinary price of a subcommand. | ||
| 477 | if (args.len > 0 and | ||
| 478 | (std.mem.eql(u8, args[0], "add") or std.mem.eql(u8, args[0], "rm"))) | ||
| 479 | return wallEdit(arena, args[0], args[1..]); | ||
| 480 | |||
| 481 | var w_opts = WallOpts{ ._argv = .{ .alloc = arena } }; | ||
| 482 | const outcome = cliflags.parse(WallOpts, &w_opts, args); | ||
| 483 | // Read before the outcome: a hook that refused for a REASON has already | ||
| 484 | // named it, and that reason outranks the bare "unknown word" cliflags | ||
| 485 | // saw when the hook said no. | ||
| 486 | if (w_opts._argv.err) |e| { | ||
| 487 | if (e.err == error.OutOfMemory) return e.err; | ||
| 488 | std.debug.print("mux: wall target '{s}': {s}\n", .{ e.word, wall.reason(e.err) }); | ||
| 489 | return 2; | 494 | return 2; |
| 495 | }; | ||
| 496 | if (h.lines.items.len == 0) { | ||
| 497 | // The local daemon is a host like any other; what is special about | ||
| 498 | // it is only that `mux` reaches for it when nothing is listed, and | ||
| 499 | // auto-starts it. The line itself is written once the attach | ||
| 500 | // answers, by `wallview.runAttach`. | ||
| 501 | const sock_path = try defaultSock(arena) orelse return 1; | ||
| 502 | return attachLocal(alloc, sock_path, "", false); | ||
| 490 | } | 503 | } |
| 491 | switch (outcome) { | 504 | |
| 492 | .ok => {}, | 505 | const key = std.posix.getenv(xdg.key_env); |
| 493 | .help => return cliflags.help(usage), | 506 | const specs = try arena.alloc(wallview.HostSpec, h.lines.items.len); |
| 494 | .version => return cliflags.version("mux", build_options.version), | 507 | for (specs, h.lines.items) |*s, line| { |
| 495 | .missing_value, .bad_value => { | 508 | // Refused outright, unlike the same bad line under `mux HOST`: here |
| 496 | std.debug.print("{s}", .{usage}); | 509 | // the wall IS what was asked for, and a wall silently missing one of |
| 497 | return 2; | 510 | // the machines the user wrote down is the lie this whole file exists |
| 498 | }, | 511 | // to stop telling. |
| 499 | .unknown_arg => |a| { | 512 | s.* = wallview.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| { |
| 500 | std.debug.print("mux: wall takes targets, not flags: '{s}'\n{s}", .{ a, usage }); | 513 | std.debug.print("mux: bad host '{s}': {s}\n", .{ line, hosts.reason(err) }); |
| 501 | return 2; | 514 | return 2; |
| 502 | }, | 515 | }; |
| 503 | } | 516 | } |
| 504 | const key = w_opts.key; | 517 | return wallview.run(arena, specs, .{ .key = key }); |
| 505 | const idle_ms = w_opts.quic_idle_ms.ms; | 518 | } |
| 506 | var spellings = w_opts._argv.tiles; | ||
| 507 | 519 | ||
| 508 | if (spellings.items.len == 0) { | 520 | /// What a count is worth waiting for. It bounds the DAEMON's answer, not |
| 509 | const path = try wall.statePath(arena); | 521 | /// an ssh that stops to ask the user something: a HOST spelling whose ssh |
| 510 | const w = try wall.load(arena, path); | 522 | /// prompts still holds the listing there. |
| 511 | for (w.targets.items) |t| try spellings.append(arena, t); | 523 | const hosts_list_ms = 2000; |
| 512 | if (spellings.items.len == 0) { | 524 | |
| 513 | std.debug.print("mux: wall is empty: name targets, or add tiles in muxweb\n", .{}); | 525 | /// `mux hosts [add|rm SPELLING...]`; a null `state_path` is the real file. |
| 514 | return 2; | 526 | fn hostsMain( |
| 515 | } | 527 | alloc: std.mem.Allocator, |
| 528 | args: []const [:0]const u8, | ||
| 529 | state_path: ?[]const u8, | ||
| 530 | ) !u8 { | ||
| 531 | var arena_state = std.heap.ArenaAllocator.init(alloc); | ||
| 532 | defer arena_state.deinit(); | ||
| 533 | const arena = arena_state.allocator(); | ||
| 534 | const path = state_path orelse try hosts.statePath(arena); | ||
| 535 | |||
| 536 | if (args.len == 0) return hostsList(arena, path); | ||
| 537 | const adding = std.mem.eql(u8, args[0], "add"); | ||
| 538 | if (!adding and !std.mem.eql(u8, args[0], "rm")) { | ||
| 539 | std.debug.print("mux hosts: add or rm, not '{s}'\n{s}", .{ args[0], usage }); | ||
| 540 | return 2; | ||
| 516 | } | 541 | } |
| 542 | return hostsEdit(arena, adding, args[1..], path); | ||
| 543 | } | ||
| 517 | 544 | ||
| 518 | // A wall line names a DAEMON now: no `#SESSION`, and no self-attach | 545 | /// One line per host, and beside it what that daemon has live RIGHT NOW — |
| 519 | // check here — the session this shell is inside is one name on a | 546 | /// asked of the daemon, never counted out of the file. The counts are the |
| 520 | // host's list, and the wall's own diff is what refuses to tile it. | 547 | /// whole point of the verb: the file knows nothing about sessions. |
| 521 | const host_specs = try arena.alloc(wallview.HostSpec, spellings.items.len); | 548 | fn hostsList(arena: std.mem.Allocator, path: []const u8) !u8 { |
| 522 | for (host_specs, spellings.items) |*h, s| { | 549 | const h = hosts.load(arena, path) catch |err| { |
| 523 | h.* = wallview.resolveHost(arena, s, xdg.pickKey(key, std.posix.getenv(xdg.key_env)), idle_ms) catch |err| { | 550 | std.debug.print("mux hosts: {s}: {s}\n", .{ path, hosts.reason(err) }); |
| 524 | std.debug.print("mux: bad wall target '{s}': {s}\n", .{ s, hosts.reason(err) }); | 551 | return 1; |
| 525 | return 2; | 552 | }; |
| 553 | const key = std.posix.getenv(xdg.key_env); | ||
| 554 | for (h.lines.items) |line| { | ||
| 555 | const spec = wallview.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| { | ||
| 556 | printOut("{s}\t[{s}]\n", .{ line, hosts.reason(err) }); | ||
| 557 | continue; | ||
| 526 | }; | 558 | }; |
| 559 | var out: [proto.sessions_text_max]u8 = undefined; | ||
| 560 | const list = client.listSessions(arena, spec.target, &out, hosts_list_ms) catch { | ||
| 561 | printOut("{s}\t[unreachable]\n", .{line}); | ||
| 562 | continue; | ||
| 563 | }; | ||
| 564 | printOut("{s}\t{d}\n", .{ line, countSessions(list) }); | ||
| 527 | } | 565 | } |
| 528 | return wallview.run(arena, host_specs, .{}); | 566 | return 0; |
| 567 | } | ||
| 568 | |||
| 569 | /// A listing someone ASKED for is output, like `--help`; every refusal | ||
| 570 | /// above and below stays on stderr. | ||
| 571 | fn printOut(comptime fmt: []const u8, args: anytype) void { | ||
| 572 | var buf: [512]u8 = undefined; | ||
| 573 | const s = std.fmt.bufPrint(&buf, fmt, args) catch return; | ||
| 574 | proto.writeAllFd(std.posix.STDOUT_FILENO, s) catch {}; | ||
| 529 | } | 575 | } |
| 530 | 576 | ||
| 531 | /// `mux wall add|rm SPELLING...`: file operations only, neither verb dials. | 577 | /// Sessions in a `sessions_reply` body: one name per line, however the |
| 532 | fn wallEdit( | 578 | /// daemon punctuated the end of it. |
| 579 | fn countSessions(list: []const u8) usize { | ||
| 580 | var n: usize = 0; | ||
| 581 | var it = std.mem.tokenizeScalar(u8, list, '\n'); | ||
| 582 | while (it.next()) |_| n += 1; | ||
| 583 | return n; | ||
| 584 | } | ||
| 585 | |||
| 586 | /// `mux hosts add|rm SPELLING...`: file operations only, neither verb dials. | ||
| 587 | fn hostsEdit( | ||
| 533 | arena: std.mem.Allocator, | 588 | arena: std.mem.Allocator, |
| 534 | verb: []const u8, | 589 | adding: bool, |
| 535 | args: []const [:0]const u8, | 590 | args: []const [:0]const u8, |
| 591 | path: []const u8, | ||
| 536 | ) !u8 { | 592 | ) !u8 { |
| 537 | const adding = std.mem.eql(u8, verb, "add"); | 593 | const verb = if (adding) "add" else "rm"; |
| 538 | |||
| 539 | var spellings: std.ArrayList([]const u8) = .empty; | 594 | var spellings: std.ArrayList([]const u8) = .empty; |
| 540 | var i: usize = 0; | 595 | |
| 541 | while (i < args.len) : (i += 1) { | 596 | if (adding) { |
| 542 | const n = wall.spellingFromArgv(arena, args, i) catch |err| switch (err) { | 597 | // Judged at usage altitude: `add` is authored intent, and the |
| 543 | error.MissingSockPath => { | 598 | // moment the user is still looking at what they typed is the only |
| 544 | std.debug.print("mux: wall {s}: '--sock' names no path\n", .{verb}); | 599 | // one where naming the rule helps. |
| 600 | var o = HostsOpts{ ._argv = .{ .alloc = arena } }; | ||
| 601 | const outcome = cliflags.parse(HostsOpts, &o, args); | ||
| 602 | // Read before the outcome: a hook that refused for a REASON has | ||
| 603 | // already named it, and that reason outranks the bare "unknown | ||
| 604 | // word" cliflags saw when the hook said no. | ||
| 605 | if (o._argv.err) |e| { | ||
| 606 | if (e.err == error.OutOfMemory) return e.err; | ||
| 607 | std.debug.print("mux hosts add: '{s}': {s}\n", .{ e.word, hosts.reason(e.err) }); | ||
| 608 | return 2; | ||
| 609 | } | ||
| 610 | switch (outcome) { | ||
| 611 | .ok => {}, | ||
| 612 | .help => return cliflags.help(usage), | ||
| 613 | .version => return cliflags.version("mux", build_options.version), | ||
| 614 | .missing_value, .bad_value => { | ||
| 615 | std.debug.print("{s}", .{usage}); | ||
| 545 | return 2; | 616 | return 2; |
| 546 | }, | 617 | }, |
| 547 | error.FlagLikeTarget => { | 618 | .unknown_arg => |a| { |
| 548 | std.debug.print("mux: wall {s}: '{s}' is a flag, not a target\n", .{ verb, args[i] }); | 619 | std.debug.print("mux hosts add: takes hosts, not flags: '{s}'\n{s}", .{ a, usage }); |
| 549 | return 2; | 620 | return 2; |
| 550 | }, | 621 | }, |
| 551 | else => |e| return e, | 622 | } |
| 552 | }; | 623 | spellings = o._argv.list; |
| 553 | i += n.consumed - 1; | 624 | } else { |
| 554 | try spellings.append(arena, n.spelling); | 625 | // Verbatim, NOT through the grammar: `rm` is the one command whose |
| 626 | // job is removing a line, so it has to reach a hand-edited line the | ||
| 627 | // grammar refuses — which is why `hosts.forget` reads the file that | ||
| 628 | // way too. | ||
| 629 | var i: usize = 0; | ||
| 630 | while (i < args.len) : (i += 1) { | ||
| 631 | const n = wall.spellingFromArgv(arena, args, i) catch |err| switch (err) { | ||
| 632 | error.MissingSockPath => { | ||
| 633 | std.debug.print("mux hosts rm: '--sock' names no path\n", .{}); | ||
| 634 | return 2; | ||
| 635 | }, | ||
| 636 | error.FlagLikeTarget => { | ||
| 637 | std.debug.print("mux hosts rm: '{s}' is a flag, not a host\n", .{args[i]}); | ||
| 638 | return 2; | ||
| 639 | }, | ||
| 640 | else => |e| return e, | ||
| 641 | }; | ||
| 642 | i += n.consumed - 1; | ||
| 643 | try spellings.append(arena, n.spelling); | ||
| 644 | } | ||
| 555 | } | 645 | } |
| 556 | if (spellings.items.len == 0) { | 646 | if (spellings.items.len == 0) { |
| 557 | std.debug.print("mux: wall {s}: name at least one target\n", .{verb}); | 647 | std.debug.print("mux hosts {s}: name at least one host\n", .{verb}); |
| 558 | return 2; | 648 | return 2; |
| 559 | } | 649 | } |
| 560 | 650 | ||
| 561 | // Every spelling is validated BEFORE any of them is written, and the | 651 | if (adding) return hostsAdd(arena, spellings.items, path); |
| 562 | // file is written ONCE below: an IO error on the third of four must not | ||
| 563 | // leave the first two applied and the rest not. | ||
| 564 | if (adding) for (spellings.items) |s| { | ||
| 565 | // The grammar's own refusals, plus the one refusal that belongs to | ||
| 566 | // the transport rather than the grammar: a sun_path that cannot be | ||
| 567 | // bound is a tile that could never dial, and ADD time is the only | ||
| 568 | // moment the user is still looking at what they typed. | ||
| 569 | const p = wall.parseSpelling(s) catch |err| { | ||
| 570 | std.debug.print("mux: wall add: {s}: {s}\n", .{ s, wall.reason(err) }); | ||
| 571 | return 2; | ||
| 572 | }; | ||
| 573 | if (p.spec == .sock and p.spec.sock.len > sockpath.max_sun_path) { | ||
| 574 | std.debug.print("mux: wall add: {s}: {s}\n", .{ s, wall.reason(error.SockPathTooLong) }); | ||
| 575 | return 2; | ||
| 576 | } | ||
| 577 | }; | ||
| 578 | 652 | ||
| 579 | const path = try wall.statePath(arena); | ||
| 580 | var rc: u8 = 0; | 653 | var rc: u8 = 0; |
| 581 | 654 | for (spellings.items) |s| { | |
| 582 | if (adding) { | 655 | const gone = hosts.forget(arena, path, s) catch |err| { |
| 583 | // Strict: growing a wall whose existing content is not understood | 656 | std.debug.print("mux hosts rm: {s}: {s}\n", .{ path, hosts.reason(err) }); |
| 584 | // would re-save garbage as if it had been read. | ||
| 585 | var w = wall.load(arena, path) catch |err| { | ||
| 586 | std.debug.print("mux: wall add: {s}: {s}\n", .{ path, @errorName(err) }); | ||
| 587 | return 1; | 657 | return 1; |
| 588 | }; | 658 | }; |
| 589 | for (spellings.items) |s| { | 659 | // Removing what is not there is reported and non-zero — a script |
| 590 | // Already there is not a failure: `add` states what the wall | 660 | // that thinks it dropped a host should learn it was spelled |
| 591 | // should contain, and afterwards it does. | 661 | // differently. The rest of the line still applies. |
| 592 | var present = false; | 662 | if (!gone) { |
| 593 | for (w.targets.items) |t| { | 663 | std.debug.print("mux hosts rm: not on the wall: {s}\n", .{s}); |
| 594 | if (std.mem.eql(u8, t, s)) present = true; | 664 | rc = 1; |
| 595 | } | ||
| 596 | if (!present) _ = try w.add(arena, s); | ||
| 597 | } | 665 | } |
| 598 | wall.save(&w, path) catch |err| { | ||
| 599 | std.debug.print("mux: wall add: {s}: {s}\n", .{ path, @errorName(err) }); | ||
| 600 | return 1; | ||
| 601 | }; | ||
| 602 | return 0; | ||
| 603 | } | 666 | } |
| 667 | // `forget` matches on the EXACT line, so a hand-edited one can only be | ||
| 668 | // removed by typing it byte for byte. Showing the file is what makes | ||
| 669 | // that possible without opening an editor. | ||
| 670 | if (rc != 0) showFile(arena, path); | ||
| 671 | return rc; | ||
| 672 | } | ||
| 604 | 673 | ||
| 605 | // Lenient, so a hand-edited line the grammar cannot parse can still be | 674 | /// The file verbatim, when the next thing the user must do is name one of |
| 606 | // removed and the others survive it verbatim. | 675 | /// its lines back at the program. |
| 607 | var lines = wall.loadLines(arena, path) catch |err| { | 676 | fn showFile(arena: std.mem.Allocator, path: []const u8) void { |
| 608 | std.debug.print("mux: wall rm: {s}: {s}\n", .{ path, @errorName(err) }); | 677 | const lines = wall.loadLines(arena, path) catch return; |
| 609 | return 1; | 678 | for (lines.items) |l| std.debug.print(" {s}\n", .{l}); |
| 610 | }; | 679 | } |
| 611 | for (spellings.items) |s| { | 680 | |
| 612 | var found = false; | 681 | /// Every spelling is checked BEFORE any of them is written, and the file is |
| 613 | for (lines.items, 0..) |t, at| { | 682 | /// written ONCE: an IO error on the third of four must not leave the first |
| 614 | if (!std.mem.eql(u8, t, s)) continue; | 683 | /// two applied and the rest not. |
| 615 | _ = lines.orderedRemove(at); | 684 | fn hostsAdd(arena: std.mem.Allocator, spellings: []const []const u8, path: []const u8) !u8 { |
| 616 | found = true; | 685 | for (spellings) |s| { |
| 617 | break; | 686 | const spec = hosts.parse(s) catch |err| { |
| 618 | } | 687 | std.debug.print("mux hosts add: {s}: {s}\n", .{ s, hosts.reason(err) }); |
| 619 | // Removing what is not there is reported and non-zero — a script | 688 | return 2; |
| 620 | // that thinks it cleaned up a tile should learn it was spelled | 689 | }; |
| 621 | // differently. The rest of the line still applies: the names that | 690 | // The one refusal that belongs to the transport rather than the |
| 622 | // WERE on the wall come off it. | 691 | // grammar: a sun_path that cannot be bound is a host that could |
| 623 | if (!found) { | 692 | // never dial, and add time is the only moment the user is still |
| 624 | std.debug.print("mux: wall rm: not on the wall: {s}\n", .{s}); | 693 | // looking at what they typed. |
| 625 | rc = 1; | 694 | if (spec == .sock and spec.sock.len > sockpath.max_sun_path) { |
| 695 | std.debug.print("mux hosts add: {s}: {s}\n", .{ s, hosts.reason(error.SockPathTooLong) }); | ||
| 696 | return 2; | ||
| 626 | } | 697 | } |
| 627 | } | 698 | } |
| 628 | wall.saveLines(lines.items, path) catch |err| { | 699 | // Strict: growing a file whose existing content is not understood would |
| 629 | std.debug.print("mux: wall rm: {s}: {s}\n", .{ path, @errorName(err) }); | 700 | // re-save garbage as if it had been read. |
| 701 | var h = hosts.load(arena, path) catch |err| { | ||
| 702 | std.debug.print("mux hosts add: {s}: {s}\n", .{ path, hosts.reason(err) }); | ||
| 703 | // Which line is the user's next question, and `rm` takes it verbatim. | ||
| 704 | showFile(arena, path); | ||
| 630 | return 1; | 705 | return 1; |
| 631 | }; | 706 | }; |
| 632 | return rc; | 707 | // Already listed is not a failure: `add` states what the wall should |
| 708 | // contain, and afterwards it does. | ||
| 709 | for (spellings) |s| _ = try h.add(arena, s); | ||
| 710 | hosts.save(&h, path) catch |err| { | ||
| 711 | std.debug.print("mux hosts add: {s}: {s}\n", .{ path, hosts.reason(err) }); | ||
| 712 | return 1; | ||
| 713 | }; | ||
| 714 | return 0; | ||
| 633 | } | 715 | } |
| 634 | 716 | ||
| 635 | /// parseArgs takes what argsAlloc produces; the tests must match the type. | 717 | /// parseArgs takes what argsAlloc produces; the tests must match the type. |
| @@ -694,21 +776,49 @@ test "parseArgs: naming two transports is a conflict, however it is spelled" { | |||
| 694 | try std.testing.expectEqualStrings("ssh b", v2.attach.via.?); | 776 | try std.testing.expectEqualStrings("ssh b", v2.attach.via.?); |
| 695 | } | 777 | } |
| 696 | 778 | ||
| 697 | test "wall: a bad spelling is refused at parse, before any tile is dialed" { | 779 | test "hosts: add refuses a session by name, rm reports an unlisted host, the file keeps add order" { |
| 698 | // It used to survive the parse and die in the resolve loop, one | 780 | const alloc = std.testing.allocator; |
| 699 | // spelling among many, after the wall file had already been read. The | 781 | var tmp = try TmpDir.make(); |
| 700 | // hub refused the same word at usage altitude; now both mouths do, | 782 | defer tmp.cleanup(); |
| 701 | // through wall.Argv, in wall.reason's words. | 783 | var buf: [256]u8 = undefined; |
| 702 | try std.testing.expectEqual( | 784 | // Under `mux/` like the real one: `add` must make the directory it |
| 703 | @as(u8, 2), | 785 | // writes into, because a first run has no state at all. |
| 704 | try wallMain(std.testing.allocator, &[_][:0]const u8{"h#bad name"}), | 786 | const path = try std.fmt.bufPrint(&buf, "{s}/mux/hosts", .{tmp.path()}); |
| 705 | ); | 787 | |
| 706 | // A flag-shaped word is still the unknown flag cliflags names, not a | 788 | // A `#SESSION` tail is refused at usage altitude, in the words that name |
| 707 | // tile this refused: the two refusals have different owners. | 789 | // the new rule, and nothing is written. |
| 708 | try std.testing.expectEqual( | 790 | try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "box#build" }, path)); |
| 709 | @as(u8, 2), | 791 | // A flag-shaped word is not a host, and `--sock PATH`'s two words are |
| 710 | try wallMain(std.testing.allocator, &[_][:0]const u8{ "-A", "host" }), | 792 | // one spelling. |
| 711 | ); | 793 | try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "-A" }, path)); |
| 794 | try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "box" }, path)); | ||
| 795 | try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "--sock", "/tmp/x.sock" }, path)); | ||
| 796 | // Removing what is not there is reported and non-zero: a script that | ||
| 797 | // thinks it dropped a host should learn it was spelled differently. | ||
| 798 | try std.testing.expectEqual(@as(u8, 1), try hostsMain(alloc, &[_][:0]const u8{ "rm", "nowhere" }, path)); | ||
| 799 | |||
| 800 | var h = try hosts.load(alloc, path); | ||
| 801 | defer h.deinit(alloc); | ||
| 802 | try std.testing.expectEqual(@as(usize, 2), h.lines.items.len); | ||
| 803 | try std.testing.expectEqualStrings("box", h.lines.items[0]); | ||
| 804 | try std.testing.expectEqualStrings("--sock /tmp/x.sock", h.lines.items[1]); | ||
| 805 | |||
| 806 | try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "rm", "box" }, path)); | ||
| 807 | var after = try hosts.load(alloc, path); | ||
| 808 | defer after.deinit(alloc); | ||
| 809 | try std.testing.expectEqual(@as(usize, 1), after.lines.items.len); | ||
| 810 | try std.testing.expectEqualStrings("--sock /tmp/x.sock", after.lines.items[0]); | ||
| 811 | |||
| 812 | // Neither verb is a subcommand this program has. | ||
| 813 | try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{"list"}, path)); | ||
| 814 | } | ||
| 815 | |||
| 816 | test "hosts: a session count is the daemon's lines, not its bytes" { | ||
| 817 | try std.testing.expectEqual(@as(usize, 0), countSessions("")); | ||
| 818 | try std.testing.expectEqual(@as(usize, 1), countSessions("0\n")); | ||
| 819 | // No trailing newline, and a blank line, are the same two sessions. | ||
| 820 | try std.testing.expectEqual(@as(usize, 2), countSessions("0\nwork")); | ||
| 821 | try std.testing.expectEqual(@as(usize, 2), countSessions("0\n\nwork\n")); | ||
| 712 | } | 822 | } |
| 713 | 823 | ||
| 714 | test "parseArgs: unknown flags and valueless flags are usage errors" { | 824 | test "parseArgs: unknown flags and valueless flags are usage errors" { |
| @@ -741,6 +851,15 @@ test "-A rides every transport spelling" { | |||
| 741 | // `-A` is an alias, not a flag of its own, so the field's own spelling | 851 | // `-A` is an alias, not a flag of its own, so the field's own spelling |
| 742 | // has to work too. | 852 | // has to work too. |
| 743 | try std.testing.expect((try parse(&.{ "mux", "--agent", "--sock", "/tmp/x.sock" })).attach.agent); | 853 | try std.testing.expect((try parse(&.{ "mux", "--agent", "--sock", "/tmp/x.sock" })).attach.agent); |
| 854 | |||
| 855 | // The usage says `mux -A` is how the wall gets an agent, and this is | ||
| 856 | // why: `main` sends a BARE `mux` (argv of one) to `wallOfHosts`, which | ||
| 857 | // takes no agent, while `-A` makes argv two words and lands here — an | ||
| 858 | // attach that names no transport, so the default local socket, armed. | ||
| 859 | const armed = try parse(&.{ "mux", "-A" }); | ||
| 860 | try std.testing.expect(armed.attach.agent); | ||
| 861 | try std.testing.expect(armed.attach.sock == null); | ||
| 862 | try std.testing.expect(armed.attach.via == null); | ||
| 744 | } | 863 | } |
| 745 | 864 | ||
| 746 | test "parseArgs: quic:// is a transport like any other" { | 865 | test "parseArgs: quic:// is a transport like any other" { |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -2983,6 +2983,44 @@ fn hostSpelling(alloc: std.mem.Allocator, target: client.Target) ![]const u8 { | |||
| 2983 | }; | 2983 | }; |
| 2984 | } | 2984 | } |
| 2985 | 2985 | ||
| 2986 | /// A host joins the wall once its daemon has ANSWERED, never on a dial: a | ||
| 2987 | /// refused attach must not strand a line nobody can see to remove. | ||
| 2988 | fn recordHost(alloc: std.mem.Allocator, target: client.Target, spelling: []const u8) void { | ||
| 2989 | // `--via` has no form in the host grammar — an arbitrary command is not | ||
| 2990 | // an address — so an attach over one records nothing, silently, as the | ||
| 2991 | // wall file always did. | ||
| 2992 | if (target == .via) return; | ||
| 2993 | const path = hosts.statePath(alloc) catch return; | ||
| 2994 | _ = hosts.record(alloc, path, spelling) catch |err| | ||
| 2995 | std.debug.print("mux: hosts file not updated ({s}): {s}\n", .{ path, hosts.reason(err) }); | ||
| 2996 | } | ||
| 2997 | |||
| 2998 | /// The rest of the file, after the host the user named. | ||
| 2999 | fn otherHosts( | ||
| 3000 | alloc: std.mem.Allocator, | ||
| 3001 | specs: *std.ArrayList(HostSpec), | ||
| 3002 | first: []const u8, | ||
| 3003 | key: ?[]const u8, | ||
| 3004 | idle_ms: u32, | ||
| 3005 | ) void { | ||
| 3006 | const path = hosts.statePath(alloc) catch return; | ||
| 3007 | const h = hosts.load(alloc, path) catch |err| { | ||
| 3008 | std.debug.print("mux: hosts file ignored ({s}): {s}\n", .{ path, hosts.reason(err) }); | ||
| 3009 | return; | ||
| 3010 | }; | ||
| 3011 | for (h.lines.items) |line| { | ||
| 3012 | if (std.mem.eql(u8, line, first)) continue; | ||
| 3013 | const spec = resolveHost(alloc, line, key, idle_ms) catch |err| { | ||
| 3014 | // Said and skipped, not refused: what was asked for here is a | ||
| 3015 | // session, and it is already open. Only bare `mux`, where the | ||
| 3016 | // wall itself is the ask, turns a bad line into an exit code. | ||
| 3017 | std.debug.print("mux: bad host '{s}': {s}\n", .{ line, hosts.reason(err) }); | ||
| 3018 | continue; | ||
| 3019 | }; | ||
| 3020 | specs.append(alloc, spec) catch return; | ||
| 3021 | } | ||
| 3022 | } | ||
| 3023 | |||
| 2986 | /// The DIAL is on the main thread, before any wall: ssh can want the tty. | 3024 | /// The DIAL is on the main thread, before any wall: ssh can want the tty. |
| 2987 | /// The pump ADOPTS a link that is already up. | 3025 | /// The pump ADOPTS a link that is already up. |
| 2988 | pub fn runAttach( | 3026 | pub fn runAttach( |
| @@ -3004,13 +3042,19 @@ pub fn runAttach( | |||
| 3004 | std.debug.print("{s}", .{f.msg}); | 3042 | std.debug.print("{s}", .{f.msg}); |
| 3005 | return f.exit; | 3043 | return f.exit; |
| 3006 | }; | 3044 | }; |
| 3007 | // One host, the one that was just dialled: its other sessions arrive | 3045 | // The wall this attach lands on is the WHOLE wall: the host just |
| 3008 | // as tiles from its own list, like any host's. | 3046 | // dialled first, so it is tile 0 and the sidecar's anchor, and every |
| 3009 | const host_specs = [_]HostSpec{.{ | 3047 | // other listed host after it. `mux HOST` is the wall zoomed on HOST, |
| 3010 | .spelling = try hostSpelling(alloc, target), | 3048 | // not a wall of one. |
| 3011 | .target = target, | 3049 | var arena_state = std.heap.ArenaAllocator.init(alloc); |
| 3012 | }}; | 3050 | defer arena_state.deinit(); |
| 3013 | return run(alloc, &host_specs, .{ | 3051 | const arena = arena_state.allocator(); |
| 3052 | const spelling = try hostSpelling(arena, target); | ||
| 3053 | var specs: std.ArrayList(HostSpec) = .empty; | ||
| 3054 | try specs.append(arena, .{ .spelling = spelling, .target = target }); | ||
| 3055 | recordHost(arena, target, spelling); | ||
| 3056 | otherHosts(arena, &specs, spelling, key, idle_ms); | ||
| 3057 | return run(alloc, specs.items, .{ | ||
| 3014 | .focus0 = true, | 3058 | .focus0 = true, |
| 3015 | .pre = transport, | 3059 | .pre = transport, |
| 3016 | .carry = carry.items, | 3060 | .carry = carry.items, |