b17f8620
fix: the wall starts a listed local daemon, and one bad line is one refusal
a73x 2026-08-28 19:53
Commit message
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 | } | ||