a73x

afe13cc9

feat: mux opens the wall of hosts; mux hosts add/rm/list

a73x   2026-08-28 19:53

Commit message
feat: mux opens the wall of hosts; mux hosts add/rm/list

Bare `mux` is the wall now: every session every daemon on the hosts file
has live. An empty file is the local daemon, auto-started and recorded
once its attach answers, so a first run is still just a shell. Naming a
transport is the same wall, entered zoomed on that daemon's session —
`runAttach` puts the rest of the file on the wall beside it.

`mux wall` and its add/rm go with the session-list wall they edited;
`mux hosts` replaces them, and prints each daemon's live session count by
asking the daemon, never by counting lines in the file. `add` is judged
at usage altitude (a `#SESSION` tail names the new rule); `rm` reads the
file verbatim, so a hand-edited line that strict load refuses can still
be removed, and a miss prints the lines to copy one from.

`--sock` and the empty-file wall share one local door (attachLocal), so
the self-attach refusal, the sun_path guard and the auto-start cannot
drift apart.

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,