a73x

b17f8620

fix: the wall starts a listed local daemon, and one bad line is one refusal

a73x   2026-08-28 19:53

Commit message
fix: the wall starts a listed local daemon, and one bad line is one refusal

A reboot leaves the hosts file naming a daemon that is gone, and the
empty-file auto-start could not see it: bare `mux` painted the user's own
machine `[unreachable]` forever. `localNeedsStart` asks the OS, and the
preflight `attachLocal` already ran is now `ensureLocalDaemon`, one owner
for both doors. Failure there is not a refusal — the wall opens, that
host is one stripe, and the stripe keeps redialling.

A line the grammar will not hold now reads the same wherever the file is
read: the reason, the offending line, exit 2 (`refuseFile`); a file that
could not be read at all is exit 1, because that is not the user's
spelling. `mux hosts` — the command someone runs to SEE that line —
reads verbatim and prints `LINE [bad host: reason]` beside the counts
instead of refusing to run.

`otherHosts` takes its path as a parameter and has the test it was
missing: the dialled host is not tiled twice, the rest follow in file
order, and a file the grammar refuses costs the wall, never the session.

src/cli/mux_main.zig
Old New
@@ -390,6 +390,34 @@ fn defaultSock(alloc: std.mem.Allocator) !?[]const u8 {
390 }; 390 };
391 } 391 }
392 392
393 /// Auto-start: give a local attach a daemon to land on. False when it
394 /// cannot — the line saying why is already printed. Unix-socket transport
395 /// only: quic:// has nothing local to spawn, and `--via`'s auto-starter is
396 /// the remote proxy.
397 fn ensureLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !bool {
398 const muxd_path = try spawn.findInPath(
399 alloc,
400 std.posix.getenv("PATH") orelse "",
401 "muxd",
402 );
403 defer if (muxd_path) |p| alloc.free(p);
404 if (muxd_path) |exe| return spawn.ensureForAttach(alloc, exe, sock_path, "mux");
405 // No muxd anywhere AND nothing serving: only now is the missing binary
406 // the user's problem, and both facts fit in one honest line. A live
407 // daemon needs no binary on PATH.
408 if (spawn.probe(sock_path)) return true;
409 std.debug.print("mux: no daemon on {s} and no muxd in PATH to start one\n", .{sock_path});
410 return false;
411 }
412
413 /// Whether the wall has to start the local daemon itself. Asked of the OS,
414 /// not of the file: the daemon dies on every reboot while its line lives on.
415 fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool {
416 var buf: [sockpath.max_sun_path + "--sock ".len]u8 = undefined;
417 const line = std.fmt.bufPrint(&buf, "--sock {s}", .{sock}) catch return false;
418 return h.has(line) and !spawn.probe(sock);
419 }
420
393 /// The local socket's one door: `mux --sock PATH` and an empty hosts file. 421 /// The local socket's one door: `mux --sock PATH` and an empty hosts file.
394 fn attachLocal( 422 fn attachLocal(
395 alloc: std.mem.Allocator, 423 alloc: std.mem.Allocator,
@@ -431,24 +459,7 @@ fn attachLocal(
431 return 1; 459 return 1;
432 } 460 }
433 461
434 // Attach auto-start: give the attach a daemon to land on. Unix-socket 462 if (!try ensureLocalDaemon(alloc, sock_path)) return 1;
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( 463 return wallview.runAttach(
453 alloc, 464 alloc,
454 .{ .sock = sock_path }, 465 .{ .sock = sock_path },
@@ -489,19 +500,26 @@ fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
489 const arena = arena_state.allocator(); 500 const arena = arena_state.allocator();
490 501
491 const path = try hosts.statePath(arena); 502 const path = try hosts.statePath(arena);
492 const h = hosts.load(arena, path) catch |err| { 503 const h = hosts.load(arena, path) catch |err| return refuseFile(arena, "mux", path, err);
493 std.debug.print("mux: {s}: {s}\n", .{ path, hosts.reason(err) });
494 return 2;
495 };
496 if (h.lines.items.len == 0) { 504 if (h.lines.items.len == 0) {
497 // The local daemon is a host like any other; what is special about 505 // 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 506 // it is only that `mux` reaches for it when nothing is listed. The
499 // auto-starts it. The line itself is written once the attach 507 // line itself is written once the attach answers, by
500 // answers, by `wallview.runAttach`. 508 // `wallview.runAttach`.
501 const sock_path = try defaultSock(arena) orelse return 1; 509 const sock_path = try defaultSock(arena) orelse return 1;
502 return attachLocal(alloc, sock_path, "", false); 510 return attachLocal(alloc, sock_path, "", false);
503 } 511 }
504 512
513 // A LISTED local daemon is auto-started too. It dies on every reboot
514 // while its line lives on, and a wall that paints the user's own
515 // machine `[unreachable]` until they find some other shell to start a
516 // daemon in is the empty-file case with one line in front of it.
517 // Failure is not a refusal: the wall still opens, that host is one
518 // stripe, and the stripe keeps redialling.
519 if (sockpath.defaultSockPath(arena) catch null) |sock| {
520 if (localNeedsStart(&h, sock)) _ = try ensureLocalDaemon(alloc, sock);
521 }
522
505 const key = std.posix.getenv(xdg.key_env); 523 const key = std.posix.getenv(xdg.key_env);
506 const specs = try arena.alloc(wallview.HostSpec, h.lines.items.len); 524 const specs = try arena.alloc(wallview.HostSpec, h.lines.items.len);
507 for (specs, h.lines.items) |*s, line| { 525 for (specs, h.lines.items) |*s, line| {
@@ -533,7 +551,7 @@ fn hostsMain(
533 const arena = arena_state.allocator(); 551 const arena = arena_state.allocator();
534 const path = state_path orelse try hosts.statePath(arena); 552 const path = state_path orelse try hosts.statePath(arena);
535 553
536 if (args.len == 0) return hostsList(arena, path); 554 if (args.len == 0) return hostsList(arena, path, std.posix.STDOUT_FILENO);
537 const adding = std.mem.eql(u8, args[0], "add"); 555 const adding = std.mem.eql(u8, args[0], "add");
538 if (!adding and !std.mem.eql(u8, args[0], "rm")) { 556 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 }); 557 std.debug.print("mux hosts: add or rm, not '{s}'\n{s}", .{ args[0], usage });
@@ -545,33 +563,53 @@ fn hostsMain(
545 /// One line per host, and beside it what that daemon has live RIGHT NOW — 563 /// One line per host, and beside it what that daemon has live RIGHT NOW —
546 /// asked of the daemon, never counted out of the file. The counts are the 564 /// asked of the daemon, never counted out of the file. The counts are the
547 /// whole point of the verb: the file knows nothing about sessions. 565 /// whole point of the verb: the file knows nothing about sessions.
548 fn hostsList(arena: std.mem.Allocator, path: []const u8) !u8 { 566 fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t) !u8 {
549 const h = hosts.load(arena, path) catch |err| { 567 // Verbatim, not through `hosts.load`: this is the command a user runs
550 std.debug.print("mux hosts: {s}: {s}\n", .{ path, hosts.reason(err) }); 568 // to SEE a line they have to fix, and a strict read would refuse to
551 return 1; 569 // show it to them. Nothing here writes the file, so there is no
552 }; 570 // half-understood content to protect.
571 const lines = wall.loadLines(arena, path) catch |err| return refuseFile(arena, "mux hosts", path, err);
553 const key = std.posix.getenv(xdg.key_env); 572 const key = std.posix.getenv(xdg.key_env);
554 for (h.lines.items) |line| { 573 for (lines.items) |line| {
555 const spec = wallview.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| { 574 const spec = wallview.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| {
556 printOut("{s}\t[{s}]\n", .{ line, hosts.reason(err) }); 575 printOut(out_fd, "{s}\t[bad host: {s}]\n", .{ line, hosts.reason(err) });
557 continue; 576 continue;
558 }; 577 };
559 var out: [proto.sessions_text_max]u8 = undefined; 578 var out: [proto.sessions_text_max]u8 = undefined;
560 const list = client.listSessions(arena, spec.target, &out, hosts_list_ms) catch { 579 const list = client.listSessions(arena, spec.target, &out, hosts_list_ms) catch {
561 printOut("{s}\t[unreachable]\n", .{line}); 580 printOut(out_fd, "{s}\t[unreachable]\n", .{line});
562 continue; 581 continue;
563 }; 582 };
564 printOut("{s}\t{d}\n", .{ line, countSessions(list) }); 583 printOut(out_fd, "{s}\t{d}\n", .{ line, countSessions(list) });
565 } 584 }
566 return 0; 585 return 0;
567 } 586 }
568 587
569 /// A listing someone ASKED for is output, like `--help`; every refusal 588 /// A listing someone ASKED for is output, like `--help`; every refusal
570 /// above and below stays on stderr. 589 /// above and below stays on stderr.
571 fn printOut(comptime fmt: []const u8, args: anytype) void { 590 fn printOut(fd: std.posix.fd_t, comptime fmt: []const u8, args: anytype) void {
591 // The fd is a parameter so a test can assert the format without writing
592 // over the test runner's own stdout.
572 var buf: [512]u8 = undefined; 593 var buf: [512]u8 = undefined;
573 const s = std.fmt.bufPrint(&buf, fmt, args) catch return; 594 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
574 proto.writeAllFd(std.posix.STDOUT_FILENO, s) catch {}; 595 proto.writeAllFd(fd, s) catch {};
596 }
597
598 /// One refusal for the one thing that can be wrong with this file, wherever
599 /// it is read: the reason, then the line that has it. A line the grammar
600 /// will not hold is the user's to fix (2); a file that could not be read at
601 /// all is not their spelling (1).
602 fn refuseFile(arena: std.mem.Allocator, who: []const u8, path: []const u8, err: anyerror) u8 {
603 std.debug.print("{s}: {s}: {s}\n", .{ who, path, hosts.reason(err) });
604 const lines = wall.loadLines(arena, path) catch return 1;
605 var found = false;
606 for (lines.items) |l| {
607 _ = hosts.parse(l) catch {
608 std.debug.print(" {s}\n", .{l});
609 found = true;
610 };
611 }
612 return if (found) 2 else 1;
575 } 613 }
576 614
577 /// Sessions in a `sessions_reply` body: one name per line, however the 615 /// Sessions in a `sessions_reply` body: one name per line, however the
@@ -698,12 +736,8 @@ fn hostsAdd(arena: std.mem.Allocator, spellings: []const []const u8, path: []con
698 } 736 }
699 // Strict: growing a file whose existing content is not understood would 737 // Strict: growing a file whose existing content is not understood would
700 // re-save garbage as if it had been read. 738 // re-save garbage as if it had been read.
701 var h = hosts.load(arena, path) catch |err| { 739 var h = hosts.load(arena, path) catch |err|
702 std.debug.print("mux hosts add: {s}: {s}\n", .{ path, hosts.reason(err) }); 740 return refuseFile(arena, "mux hosts add", path, err);
703 // Which line is the user's next question, and `rm` takes it verbatim.
704 showFile(arena, path);
705 return 1;
706 };
707 // Already listed is not a failure: `add` states what the wall should 741 // Already listed is not a failure: `add` states what the wall should
708 // contain, and afterwards it does. 742 // contain, and afterwards it does.
709 for (spellings) |s| _ = try h.add(arena, s); 743 for (spellings) |s| _ = try h.add(arena, s);
@@ -813,6 +847,83 @@ test "hosts: add refuses a session by name, rm reports an unlisted host, the fil
813 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{"list"}, path)); 847 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{"list"}, path));
814 } 848 }
815 849
850 test "hosts: a line the file cannot hold exits 2 wherever the file is read" {
851 const alloc = std.testing.allocator;
852 var tmp = try TmpDir.make();
853 defer tmp.cleanup();
854 var buf: [256]u8 = undefined;
855 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
856 // Two hosts, one of them hand-edited into a spelling the grammar
857 // refuses — the state a user is actually in when they reach for `rm`.
858 try wall.saveBytes(path, "--sock /tmp/x.sock\nbox#old\n");
859
860 // The write path and the wall agree: 2, the user's file to fix. `rm`
861 // reads verbatim and repairs it, and only then does `add` grow it.
862 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path));
863 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "rm", "box#old" }, path));
864 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path));
865 }
866
867 test "hosts list: a line the grammar refuses is named, not a refusal of the listing" {
868 const alloc = std.testing.allocator;
869 var tmp = try TmpDir.make();
870 defer tmp.cleanup();
871 var buf: [512]u8 = undefined;
872 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
873 var buf2: [512]u8 = undefined;
874 const dead = try std.fmt.bufPrint(&buf2, "{s}/absent.sock", .{tmp.path()});
875 var line_buf: [1024]u8 = undefined;
876 try wall.saveBytes(path, try std.fmt.bufPrint(&line_buf, "box#old\n--sock {s}\n", .{dead}));
877
878 var arena_state = std.heap.ArenaAllocator.init(alloc);
879 defer arena_state.deinit();
880 const arena = arena_state.allocator();
881 var out_buf: [512]u8 = undefined;
882 const out_path = try std.fmt.bufPrint(&out_buf, "{s}/out", .{tmp.path()});
883 const out = try std.fs.createFileAbsolute(out_path, .{});
884 const rc = try hostsList(arena, path, out.handle);
885 out.close();
886 // The one command a user runs to SEE the bad line must not refuse to run.
887 try std.testing.expectEqual(@as(u8, 0), rc);
888
889 const text = try std.fs.cwd().readFileAlloc(alloc, out_path, 4096);
890 defer alloc.free(text);
891 try std.testing.expect(std.mem.indexOf(u8, text, "box#old\t[bad host: names a session") != null);
892 // And the host after it is still asked and still answered for.
893 try std.testing.expect(std.mem.indexOf(u8, text, "\t[unreachable]") != null);
894 }
895
896 test "wall: a LISTED local daemon that nothing answers on is one this wall starts" {
897 const alloc = std.testing.allocator;
898 var tmp = try TmpDir.make();
899 defer tmp.cleanup();
900 var buf: [256]u8 = undefined;
901 const sock = try std.fmt.bufPrintZ(&buf, "{s}/muxd.sock", .{tmp.path()});
902 var line_buf: [512]u8 = undefined;
903 const line = try std.fmt.bufPrint(&line_buf, "--sock {s}", .{sock});
904
905 var h: hosts.Hosts = .{};
906 defer h.deinit(alloc);
907 // Plural, and the local line is not the first: a wall is hosts, and the
908 // local one is not privileged in the file.
909 try std.testing.expect(try h.add(alloc, "box"));
910 try std.testing.expect(try h.add(alloc, line));
911
912 // Asked of the OS, not of the file: nothing is bound yet.
913 try std.testing.expect(localNeedsStart(&h, sock));
914
915 const addr = try std.net.Address.initUnix(sock);
916 var listener = try addr.listen(.{});
917 try std.testing.expect(!localNeedsStart(&h, sock));
918 listener.deinit();
919
920 // A wall of only remote hosts starts nothing, however dead they are.
921 var remote: hosts.Hosts = .{};
922 defer remote.deinit(alloc);
923 try std.testing.expect(try remote.add(alloc, "box"));
924 try std.testing.expect(!localNeedsStart(&remote, sock));
925 }
926
816 test "hosts: a session count is the daemon's lines, not its bytes" { 927 test "hosts: a session count is the daemon's lines, not its bytes" {
817 try std.testing.expectEqual(@as(usize, 0), countSessions("")); 928 try std.testing.expectEqual(@as(usize, 0), countSessions(""));
818 try std.testing.expectEqual(@as(usize, 1), countSessions("0\n")); 929 try std.testing.expectEqual(@as(usize, 1), countSessions("0\n"));
src/wallview.zig
Old New
@@ -2985,12 +2985,11 @@ fn hostSpelling(alloc: std.mem.Allocator, target: client.Target) ![]const u8 {
2985 2985
2986 /// A host joins the wall once its daemon has ANSWERED, never on a dial: a 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. 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 { 2988 fn recordHost(alloc: std.mem.Allocator, target: client.Target, spelling: []const u8, path: []const u8) void {
2989 // `--via` has no form in the host grammar — an arbitrary command is not 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 2990 // an address — so an attach over one records nothing, silently, as the
2991 // wall file always did. 2991 // wall file always did.
2992 if (target == .via) return; 2992 if (target == .via) return;
2993 const path = hosts.statePath(alloc) catch return;
2994 _ = hosts.record(alloc, path, spelling) catch |err| 2993 _ = hosts.record(alloc, path, spelling) catch |err|
2995 std.debug.print("mux: hosts file not updated ({s}): {s}\n", .{ path, hosts.reason(err) }); 2994 std.debug.print("mux: hosts file not updated ({s}): {s}\n", .{ path, hosts.reason(err) });
2996 } 2995 }
@@ -3000,10 +2999,10 @@ fn otherHosts(
3000 alloc: std.mem.Allocator, 2999 alloc: std.mem.Allocator,
3001 specs: *std.ArrayList(HostSpec), 3000 specs: *std.ArrayList(HostSpec),
3002 first: []const u8, 3001 first: []const u8,
3002 path: []const u8,
3003 key: ?[]const u8, 3003 key: ?[]const u8,
3004 idle_ms: u32, 3004 idle_ms: u32,
3005 ) void { 3005 ) void {
3006 const path = hosts.statePath(alloc) catch return;
3007 const h = hosts.load(alloc, path) catch |err| { 3006 const h = hosts.load(alloc, path) catch |err| {
3008 std.debug.print("mux: hosts file ignored ({s}): {s}\n", .{ path, hosts.reason(err) }); 3007 std.debug.print("mux: hosts file ignored ({s}): {s}\n", .{ path, hosts.reason(err) });
3009 return; 3008 return;
@@ -3052,8 +3051,10 @@ pub fn runAttach(
3052 const spelling = try hostSpelling(arena, target); 3051 const spelling = try hostSpelling(arena, target);
3053 var specs: std.ArrayList(HostSpec) = .empty; 3052 var specs: std.ArrayList(HostSpec) = .empty;
3054 try specs.append(arena, .{ .spelling = spelling, .target = target }); 3053 try specs.append(arena, .{ .spelling = spelling, .target = target });
3055 recordHost(arena, target, spelling); 3054 if (hosts.statePath(arena) catch null) |path| {
3056 otherHosts(arena, &specs, spelling, key, idle_ms); 3055 recordHost(arena, target, spelling, path);
3056 otherHosts(arena, &specs, spelling, path, key, idle_ms);
3057 }
3057 return run(alloc, specs.items, .{ 3058 return run(alloc, specs.items, .{
3058 .focus0 = true, 3059 .focus0 = true,
3059 .pre = transport, 3060 .pre = transport,
@@ -6340,3 +6341,33 @@ test "tileBanner: a prompt wider than its pane shows the tail inside the pane" {
6340 const to = std.mem.indexOf(u8, got, "\x1b[0m").?; 6341 const to = std.mem.indexOf(u8, got, "\x1b[0m").?;
6341 try std.testing.expectEqualStrings(text[text.len - 10 ..], got[from..to]); 6342 try std.testing.expectEqualStrings(text[text.len - 10 ..], got[from..to]);
6342 } 6343 }
6344
6345 test "otherHosts: the dialled host is not tiled twice, and the rest follow in file order" {
6346 const alloc = std.testing.allocator;
6347 var tmp = try TmpDir.make();
6348 defer tmp.cleanup();
6349 var buf: [256]u8 = undefined;
6350 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
6351 // Off-origin: the host the user dialled is the file's SECOND line, so a
6352 // skip that only ever worked on line 0 is caught here.
6353 try wall.saveBytes(path, "--sock /a\n--sock /b\n");
6354
6355 var arena_state = std.heap.ArenaAllocator.init(alloc);
6356 defer arena_state.deinit();
6357 const arena = arena_state.allocator();
6358 var specs: std.ArrayList(HostSpec) = .empty;
6359 try specs.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" } });
6360 otherHosts(arena, &specs, "--sock /b", path, null, 0);
6361
6362 try std.testing.expectEqual(@as(usize, 2), specs.items.len);
6363 try std.testing.expectEqualStrings("--sock /b", specs.items[0].spelling);
6364 try std.testing.expectEqualStrings("--sock /a", specs.items[1].spelling);
6365
6366 // A file the grammar refuses costs the user the WALL, never the session
6367 // they asked for: the entry host stands alone and the attach goes on.
6368 try wall.saveBytes(path, "--sock /a\nbox#old\n");
6369 var one: std.ArrayList(HostSpec) = .empty;
6370 try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" } });
6371 otherHosts(arena, &one, "--sock /b", path, null, 0);
6372 try std.testing.expectEqual(@as(usize, 1), one.items.len);
6373 }