a73x

31bb70a8

refactor: delete the four decls no product path calls

a73x   2026-08-29 10:01

Commit message
refactor: delete the four decls no product path calls

`handoff.readLine`/`readAnnounce`, `wall.forget` and `wall.saveLayout` had
no caller outside their own tests, and `Pty.spawn`/`SpawnOptions` had none
outside pty.zig's.

- `handoff.readLine`/`readAnnounce`: the product reader is the superset
  `client.readAnnounceAbortable`, which re-spells the byte-at-a-time loop
  with a poll on the abort fd. The claims the deleted tests pinned — the
  byte after the newline is left for the frame stream, EOF is
  `UnterminatedLine`, a full buffer is `LineTooLong` before the next wait —
  move onto that reader as two new tests in client.zig, where they now pin
  live code instead of a twin. `handoff.ReadLineError` stays:
  `client.announceFailed` reflects over it.
- `wall.forget`: `hosts.forget`/`forgetMany` replaced it, and hosts.zig's
  own tests already pin every claim wall's did — order kept, absent is
  false not an error, a hand-edited line is removable while `record` still
  refuses to grow that file. Deleted with its two tests.
- `wall.saveLayout` was `return saveBytes(path, bytes)` with one caller;
  `wall_layout.saveLayoutTo` calls `wall.saveBytes` directly.
- `Pty.spawn`/`SpawnOptions` come off the struct — public surface nothing
  ships — and become the private test helper `spawnShell` in pty.zig. The
  daemon has only ever used `spawnArgv` (folder rule 5: argv, never a
  shell line).

Comments that named a deleted symbol are re-pointed at the surviving
reader (client.zig, main.zig, handoff.zig, server.zig).

make check rc 0, 983 tests.

src/cli/main.zig
Old New
@@ -985,8 +985,8 @@ fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 {
985 /// usable key, no listener — announces `endpoint none` and pumps anyway: 985 /// usable key, no listener — announces `endpoint none` and pumps anyway:
986 /// the ssh session is real and carries the whole session. A HARD one — no 986 /// the ssh session is real and carries the whole session. A HARD one — no
987 /// daemon to pump to, a stdout that will not take the announce — exits, 987 /// daemon to pump to, a stdout that will not take the announce — exits,
988 /// and the client reads EOF on the pipe, which `handoff.readLine` already 988 /// and the client reads EOF on the pipe, which the client's announce reader
989 /// tells apart from a line (`UnterminatedLine`). Announcing none and THEN 989 /// already tells apart from a line (`handoff.ReadLineError`). Announcing none and THEN
990 /// exiting is the one dishonest option available: it would tell the client 990 /// exiting is the one dishonest option available: it would tell the client
991 /// it has a working ssh session at the moment that session goes away. 991 /// it has a working ssh session at the moment that session goes away.
992 /// 992 ///
src/client/client.zig
Old New
@@ -806,10 +806,9 @@ fn readAnnounceAbortable(
806 // side. -1 (no abort channel) starts unwatched and stays that way. 806 // side. -1 (no abort channel) starts unwatched and stays that way.
807 var watch_stdin = abort_fd >= 0; 807 var watch_stdin = abort_fd >= 0;
808 while (true) { 808 while (true) {
809 // Before the wait, not after it, which is where handoff.readLine 809 // Before the wait, not after it: a line that has already filled the
810 // checks it too: a line that has already filled the buffer is 810 // buffer is over-long whether or not another byte ever arrives, and
811 // over-long whether or not another byte ever arrives, and waiting 811 // waiting for one that may never come would turn an error into a hang.
812 // for one that may never come would turn an error into a hang.
813 if (n == buf.len) return error.LineTooLong; 812 if (n == buf.len) return error.LineTooLong;
814 813
815 var fds = [_]std.posix.pollfd{ 814 var fds = [_]std.posix.pollfd{
@@ -1439,6 +1438,75 @@ test "spawnPipe: the child is exec'd from a copy — an argv freed after spawn s
1439 _ = try child.kill(); 1438 _ = try child.kill();
1440 } 1439 }
1441 1440
1441 test "the announce reader consumes the newline and NOT the byte after it" {
1442 // The property the announce-then-frames protocol stands on, pinned on
1443 // the one reader the product still has. A buffered read here would take
1444 // 'X' — the frame stream's first byte — into a buffer that is then
1445 // thrown away, and the session would hang or desynchronise with nothing
1446 // to point at.
1447 const alloc = std.testing.allocator;
1448 const fds = try std.posix.pipe();
1449 defer std.posix.close(fds[0]);
1450
1451 var buf: [handoff.announce_max_len]u8 = undefined;
1452 const ep: handoff.Endpoint = .{ .port = 4433, .key = [_]u8{0xAB} ** 32 };
1453 _ = try std.posix.write(fds[1], try handoff.formatAnnounce(&buf, ep));
1454 _ = try std.posix.write(fds[1], "X");
1455 // Closed before the read, deliberately: with the write end open, a
1456 // reader that had already swallowed the 'X' would leave the assertion
1457 // below blocking forever, and this test's whole job is to catch exactly
1458 // that implementation. A hung suite names nothing; EOF here turns the
1459 // catch into a printed "expected 1, found 0". The bytes are already in
1460 // the pipe, so the close costs the correct implementation nothing.
1461 std.posix.close(fds[1]);
1462
1463 const got = (try readAnnounceAbortable(fds[0], alloc, null, -1)).?;
1464 try std.testing.expectEqual(ep.port, got.port);
1465 try std.testing.expectEqualSlices(u8, &ep.key, &got.key);
1466
1467 var one: [1]u8 = undefined;
1468 try std.testing.expectEqual(@as(usize, 1), try std.posix.read(fds[0], &one));
1469 try std.testing.expectEqual(@as(u8, 'X'), one[0]);
1470 }
1471
1472 test "the announce reader: `endpoint none` is null, EOF and an over-long line are handoff's errors" {
1473 const alloc = std.testing.allocator;
1474 {
1475 const fds = try std.posix.pipe();
1476 defer std.posix.close(fds[0]);
1477 _ = try std.posix.write(fds[1], handoff.announce_none);
1478 std.posix.close(fds[1]);
1479 try std.testing.expectEqual(
1480 @as(?handoff.Endpoint, null),
1481 try readAnnounceAbortable(fds[0], alloc, null, -1),
1482 );
1483 }
1484 {
1485 const fds = try std.posix.pipe();
1486 defer std.posix.close(fds[0]);
1487 _ = try std.posix.write(fds[1], "endpoi");
1488 std.posix.close(fds[1]);
1489 try std.testing.expectError(
1490 handoff.ReadLineError.UnterminatedLine,
1491 readAnnounceAbortable(fds[0], alloc, null, -1),
1492 );
1493 }
1494 {
1495 const fds = try std.posix.pipe();
1496 defer std.posix.close(fds[0]);
1497 // Write end stays OPEN: the over-long line is an error the moment
1498 // the buffer is full, not something the reader waits on a newline
1499 // to discover. A reader that checked after the poll would hang here.
1500 defer std.posix.close(fds[1]);
1501 const long = [_]u8{'a'} ** handoff.announce_max_len;
1502 _ = try std.posix.write(fds[1], &long);
1503 try std.testing.expectError(
1504 handoff.ReadLineError.LineTooLong,
1505 readAnnounceAbortable(fds[0], alloc, null, -1),
1506 );
1507 }
1508 }
1509
1442 test "handoff: endpoint-none rides the open pipe with no deadline paid" { 1510 test "handoff: endpoint-none rides the open pipe with no deadline paid" {
1443 // A fake `ssh HOST mux d endpoint` that announces `none`. What this pins 1511 // A fake `ssh HOST mux d endpoint` that announces `none`. What this pins
1444 // is Transport.open's DECISION — the announce-less remote gets a 1512 // is Transport.open's DECISION — the announce-less remote gets a
src/client/handoff.zig
Old New
@@ -111,8 +111,8 @@ pub fn formatAnnounce(buf: []u8, ep: Endpoint) ![]const u8 {
111 111
112 /// The announce line back into an `Endpoint`, or null for `endpoint none`. 112 /// The announce line back into an `Endpoint`, or null for `endpoint none`.
113 /// 113 ///
114 /// Accepts the line with or without its trailing newline: `readLine` hands 114 /// Accepts the line with or without its trailing newline: the client's
115 /// back the line stripped, a cache file still has it on. 115 /// announce reader hands back the line stripped, a cache file still has it on.
116 /// 116 ///
117 /// Deliberately no stricter than its parts. The port token is whatever 117 /// Deliberately no stricter than its parts. The port token is whatever
118 /// `std.fmt.parseInt` accepts, so `+443`, `00443` and `4_433` all parse; 118 /// `std.fmt.parseInt` accepts, so `+443`, `00443` and `4_433` all parse;
@@ -276,33 +276,6 @@ pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8, batch: bool) !Recip
276 }; 276 };
277 } 277 }
278 278
279 /// One newline-terminated line from `fd`, returned WITHOUT the newline.
280 ///
281 /// Read a byte at a time, deliberately. The frame stream begins at the
282 /// very next byte after the newline, so a buffered read would swallow its
283 /// first bytes into a buffer nobody looks at again — and the whole
284 /// announce-then-frames protocol rests on that not happening.
285 pub fn readLine(fd: std.posix.fd_t, buf: []u8) ![]const u8 {
286 var n: usize = 0;
287 while (true) {
288 if (n == buf.len) return error.LineTooLong;
289 var one: [1]u8 = undefined;
290 // std.posix.read retries EINTR itself, so a short read here is a
291 // real short read.
292 if (try std.posix.read(fd, &one) == 0) return error.UnterminatedLine;
293 if (one[0] == '\n') return buf[0..n];
294 buf[n] = one[0];
295 n += 1;
296 }
297 }
298
299 /// Null is `endpoint none`: no coordinates, session stays on ssh.
300 /// The interactive client's reader is `readAnnounceAbortable`.
301 pub fn readAnnounce(fd: std.posix.fd_t) !?Endpoint {
302 var buf: [announce_max_len]u8 = undefined;
303 return parseAnnounce(try readLine(fd, &buf));
304 }
305
306 /// The announce line for `ep` at `path`: mode 0600, parent directories 279 /// The announce line for `ep` at `path`: mode 0600, parent directories
307 /// created, immediate parent tightened to 0700 — the key travels in this 280 /// created, immediate parent tightened to 0700 — the key travels in this
308 /// file. 281 /// file.
@@ -383,8 +356,8 @@ test "announce: format → parse round-trip, with and without the newline" {
383 try std.testing.expectEqual(ep.port, back.port); 356 try std.testing.expectEqual(ep.port, back.port);
384 try std.testing.expectEqualSlices(u8, &ep.key, &back.key); 357 try std.testing.expectEqualSlices(u8, &ep.key, &back.key);
385 358
386 // The same line with the newline already stripped, which is what 359 // The same line with the newline already stripped, which is what the
387 // readLine hands back. 360 // client's announce reader hands back.
388 const stripped = (try parseAnnounce(line[0 .. line.len - 1])).?; 361 const stripped = (try parseAnnounce(line[0 .. line.len - 1])).?;
389 try std.testing.expectEqual(ep.port, stripped.port); 362 try std.testing.expectEqual(ep.port, stripped.port);
390 try std.testing.expectEqualSlices(u8, &ep.key, &stripped.key); 363 try std.testing.expectEqualSlices(u8, &ep.key, &stripped.key);
@@ -562,82 +535,6 @@ test "dialHost: the LAST @ wins, which is where ssh splits" {
562 try std.testing.expectEqualStrings("c", dialHost("a@b@c")); 535 try std.testing.expectEqualStrings("c", dialHost("a@b@c"));
563 } 536 }
564 537
565 test "readLine: consumes the newline and NOT the byte after it" {
566 // The property the announce-then-frames protocol stands on. A buffered
567 // read here would take 'X' — the frame stream's first byte — into a
568 // buffer that is then thrown away, and the session would hang or
569 // desynchronise with nothing to point at.
570 const fds = try std.posix.pipe();
571 defer std.posix.close(fds[0]);
572
573 const written = "endpoint none\nX";
574 try std.testing.expectEqual(written.len, try std.posix.write(fds[1], written));
575 // Closed before the read, deliberately: with the write end open, a
576 // readLine that had already swallowed the 'X' would leave the assertion
577 // below blocking forever, and this test's whole job is to catch exactly
578 // that implementation. A hung suite names nothing; EOF here turns the
579 // catch into a printed "expected 1, found 0". The bytes are already in
580 // the pipe, so the close costs the correct implementation nothing.
581 std.posix.close(fds[1]);
582
583 var buf: [64]u8 = undefined;
584 try std.testing.expectEqualStrings("endpoint none", try readLine(fds[0], &buf));
585
586 var one: [1]u8 = undefined;
587 try std.testing.expectEqual(@as(usize, 1), try std.posix.read(fds[0], &one));
588 try std.testing.expectEqual(@as(u8, 'X'), one[0]);
589 }
590
591 test "readAnnounce: one call reads the line, and it leaves the frames alone" {
592 const fds = try std.posix.pipe();
593 defer std.posix.close(fds[0]);
594
595 var buf: [announce_max_len]u8 = undefined;
596 const ep: Endpoint = .{ .port = 4433, .key = [_]u8{0xAB} ** 32 };
597 const line = try formatAnnounce(&buf, ep);
598 _ = try std.posix.write(fds[1], line);
599 _ = try std.posix.write(fds[1], "X");
600 // Closed before the read for the same reason as the readLine test: a
601 // reader that swallowed the 'X' would otherwise hang here instead of
602 // failing with a printed assertion.
603 std.posix.close(fds[1]);
604
605 const got = (try readAnnounce(fds[0])).?;
606 try std.testing.expectEqual(ep.port, got.port);
607 try std.testing.expectEqualSlices(u8, &ep.key, &got.key);
608
609 var one: [1]u8 = undefined;
610 try std.testing.expectEqual(@as(usize, 1), try std.posix.read(fds[0], &one));
611 try std.testing.expectEqual(@as(u8, 'X'), one[0]);
612 }
613
614 test "readAnnounce: `endpoint none` off a pipe is null, not an error" {
615 const fds = try std.posix.pipe();
616 defer std.posix.close(fds[0]);
617 _ = try std.posix.write(fds[1], announce_none);
618 std.posix.close(fds[1]);
619 try std.testing.expectEqual(@as(?Endpoint, null), try readAnnounce(fds[0]));
620 }
621
622 test "readLine: EOF before a newline, and a line longer than the buffer" {
623 {
624 const fds = try std.posix.pipe();
625 defer std.posix.close(fds[0]);
626 _ = try std.posix.write(fds[1], "endpoi");
627 std.posix.close(fds[1]);
628 var buf: [64]u8 = undefined;
629 try std.testing.expectError(ReadLineError.UnterminatedLine, readLine(fds[0], &buf));
630 }
631 {
632 const fds = try std.posix.pipe();
633 defer std.posix.close(fds[0]);
634 defer std.posix.close(fds[1]);
635 _ = try std.posix.write(fds[1], "0123456789\n");
636 var buf: [4]u8 = undefined;
637 try std.testing.expectError(ReadLineError.LineTooLong, readLine(fds[0], &buf));
638 }
639 }
640
641 test "cache: round-trips, 0600 in a 0700 directory, and overwrites" { 538 test "cache: round-trips, 0600 in a 0700 directory, and overwrites" {
642 const testtmp = @import("testtmp"); 539 const testtmp = @import("testtmp");
643 var tmp = try testtmp.TmpDir.make(); 540 var tmp = try testtmp.TmpDir.make();
src/client/hosts.zig
Old New
@@ -344,17 +344,21 @@ test "hosts.forget removes EVERY copy, so a rm cannot report success and change
344 try std.testing.expect(!try forget(alloc, path, "box")); 344 try std.testing.expect(!try forget(alloc, path, "box"));
345 } 345 }
346 346
347 test "hosts.forgetMany: several names leave in ONE read-modify-write" { 347 test "hosts.forgetMany: several names leave in ONE read-modify-write, and the survivors keep file order" {
348 // `hostsAdd`'s rule, which the `rm` loop broke by calling `forget` once 348 // `hostsAdd`'s rule, which the `rm` loop broke by calling `forget` once
349 // per spelling: an IO error on the third of four must not leave the 349 // per spelling: an IO error on the third of four must not leave the
350 // first two applied. One load, one save, and every name's verdict comes 350 // first two applied. One load, one save, and every name's verdict comes
351 // back from the same pass. 351 // back from the same pass.
352 //
353 // The removed `a` sits BETWEEN the two survivors, so an unordered
354 // remove would swap the tail line into its slot and the wall would
355 // re-order itself under a `rm` of some other host.
352 const alloc = std.testing.allocator; 356 const alloc = std.testing.allocator;
353 var tmp = try TmpDir.make(); 357 var tmp = try TmpDir.make();
354 defer tmp.cleanup(); 358 defer tmp.cleanup();
355 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); 359 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
356 defer alloc.free(path); 360 defer alloc.free(path);
357 try wall.saveBytes(path, "a\nb\nkeep\na\n"); 361 try wall.saveBytes(path, "a\nb\nkeep\na\nlast\n");
358 362
359 var gone = [_]bool{ false, false, false }; 363 var gone = [_]bool{ false, false, false };
360 try forgetMany(alloc, path, &.{ "a", "absent", "b" }, &gone); 364 try forgetMany(alloc, path, &.{ "a", "absent", "b" }, &gone);
@@ -362,8 +366,9 @@ test "hosts.forgetMany: several names leave in ONE read-modify-write" {
362 366
363 var lines = try wall.loadLines(alloc, path); 367 var lines = try wall.loadLines(alloc, path);
364 defer wall.freeLines(alloc, &lines); 368 defer wall.freeLines(alloc, &lines);
365 try std.testing.expectEqual(@as(usize, 1), lines.items.len); 369 try std.testing.expectEqual(@as(usize, 2), lines.items.len);
366 try std.testing.expectEqualStrings("keep", lines.items[0]); 370 try std.testing.expectEqualStrings("keep", lines.items[0]);
371 try std.testing.expectEqualStrings("last", lines.items[1]);
367 } 372 }
368 373
369 test "hosts.statePathFrom: XDG_STATE_HOME wins, HOME falls back, file is mux/hosts" { 374 test "hosts.statePathFrom: XDG_STATE_HOME wins, HOME falls back, file is mux/hosts" {
src/client/wall.zig
Old New
@@ -302,25 +302,6 @@ pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8)
302 return true; 302 return true;
303 } 303 }
304 304
305 /// Absent is a fact for the caller to report, not an error here.
306 ///
307 /// `orderedRemove`, so the lines that stay keep their order: the wall is a
308 /// list the user reads, and jumps into with `1`-`9`, by position.
309 ///
310 /// Reads with `loadLines`, not `load`, so a hand-edited line can be
311 /// removed at all.
312 pub fn forget(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool {
313 var lines = try loadLines(alloc, path);
314 defer freeLines(alloc, &lines);
315 for (lines.items, 0..) |t, i| {
316 if (!std.mem.eql(u8, t, spelling)) continue;
317 alloc.free(lines.orderedRemove(i));
318 try saveLines(lines.items, path);
319 return true;
320 }
321 return false;
322 }
323
324 /// The *From split is xdg.zig's pattern for the same reason: setenv is 305 /// The *From split is xdg.zig's pattern for the same reason: setenv is
325 /// unsafe in-process for Zig tests. 306 /// unsafe in-process for Zig tests.
326 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { 307 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 {
@@ -356,9 +337,8 @@ pub fn layoutPathFrom(
356 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/layout", .{h}); 337 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/layout", .{h});
357 } 338 }
358 339
359 /// One atomic writer for state files in this module: `saveLines` and 340 /// One atomic writer for state files: `saveLines` and the layout sidecar
360 /// `saveLayout` both go through it, so there is one temp+rename idiom and 341 /// both go through it, so there is one temp+rename idiom and not two.
361 /// not two.
362 pub fn saveBytes(path: []const u8, bytes: []const u8) !void { 342 pub fn saveBytes(path: []const u8, bytes: []const u8) !void {
363 var write_buf: [4096]u8 = undefined; 343 var write_buf: [4096]u8 = undefined;
364 var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf }); 344 var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf });
@@ -373,10 +353,6 @@ pub fn loadLayout(alloc: std.mem.Allocator, path: []const u8) ?[]u8 {
373 return std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch null; 353 return std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch null;
374 } 354 }
375 355
376 pub fn saveLayout(path: []const u8, bytes: []const u8) !void {
377 return saveBytes(path, bytes);
378 }
379
380 test "Argv: bare words and both --sock dialects all become owned spellings" { 356 test "Argv: bare words and both --sock dialects all become owned spellings" {
381 var a = Argv{ .alloc = std.testing.allocator }; 357 var a = Argv{ .alloc = std.testing.allocator };
382 defer a.deinit(); 358 defer a.deinit();
@@ -612,68 +588,6 @@ test "record: appends once per spelling, dedups byte-exactly, keeps order" {
612 try std.testing.expectEqualStrings("quic://box:4433#build", w.targets.items[2]); 588 try std.testing.expectEqualStrings("quic://box:4433#build", w.targets.items[2]);
613 } 589 }
614 590
615 test "forget: removes one line, leaves the rest in order, absent says so" {
616 const testtmp = @import("testtmp");
617 const alloc = std.testing.allocator;
618 var tmp = try testtmp.TmpDir.make();
619 defer tmp.cleanup();
620 const path = try std.fmt.allocPrint(alloc, "{s}/wall", .{tmp.path()});
621 defer alloc.free(path);
622
623 inline for (.{ "a#0", "b#0", "c#0" }) |s| try std.testing.expect(try record(alloc, path, s));
624
625 try std.testing.expect(try forget(alloc, path, "b#0"));
626 // Absent is false, not an error — `mux wall rm` turns it into a
627 // message and an exit code, and `x` on an argv-only tile ignores it.
628 try std.testing.expect(!try forget(alloc, path, "b#0"));
629 try std.testing.expect(!try forget(alloc, path, "nothing#0"));
630
631 var w = try load(alloc, path);
632 defer w.deinit(alloc);
633 try std.testing.expectEqual(@as(usize, 2), w.targets.items.len);
634 try std.testing.expectEqualStrings("a#0", w.targets.items[0]);
635 try std.testing.expectEqualStrings("c#0", w.targets.items[1]);
636 }
637
638 test "forget: a hand-edited file is repairable; add still refuses to build on it" {
639 const testtmp = @import("testtmp");
640 const alloc = std.testing.allocator;
641 var tmp = try testtmp.TmpDir.make();
642 defer tmp.cleanup();
643 const path = try std.fmt.allocPrint(alloc, "{s}/wall", .{tmp.path()});
644 defer alloc.free(path);
645
646 // A line no grammar reads, between two that parse. Before removal read
647 // leniently, EVERY path went through `load` — so one such line made the
648 // file unfixable with the tool that owns it.
649 try tmp.dir.writeFile(.{ .sub_path = "wall", .data = "a#0\nbad name#x y\nb#1\n" });
650
651 // Growing the wall still refuses: `record` must not re-save content it
652 // could not read as though it had.
653 try std.testing.expectError(error.BadSession, record(alloc, path, "c#2"));
654
655 // Removing the broken line works, and is the escape hatch.
656 try std.testing.expect(try forget(alloc, path, "bad name#x y"));
657 {
658 var w = try load(alloc, path);
659 defer w.deinit(alloc);
660 try std.testing.expectEqual(@as(usize, 2), w.targets.items.len);
661 try std.testing.expectEqualStrings("a#0", w.targets.items[0]);
662 try std.testing.expectEqualStrings("b#1", w.targets.items[1]);
663 }
664 // ...and now that the file parses again, growing it does too.
665 try std.testing.expect(try record(alloc, path, "c#2"));
666
667 // The other half of lenient: removing a GOOD line out of a file that
668 // still holds a bad one leaves the bad one byte for byte, rather than
669 // dropping what it could not read.
670 try tmp.dir.writeFile(.{ .sub_path = "wall", .data = "a#0\nbad name#x y\nb#1\n" });
671 try std.testing.expect(try forget(alloc, path, "a#0"));
672 const back = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
673 defer alloc.free(back);
674 try std.testing.expectEqualStrings("bad name#x y\nb#1\n", back);
675 }
676
677 test "record: an unwritable wall file is an error the caller may swallow" { 591 test "record: an unwritable wall file is an error the caller may swallow" {
678 const testtmp = @import("testtmp"); 592 const testtmp = @import("testtmp");
679 const alloc = std.testing.allocator; 593 const alloc = std.testing.allocator;
@@ -711,7 +625,7 @@ test "layout sidecar: save round-trips through load; a missing file is null" {
711 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()}); 625 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
712 defer alloc.free(path); 626 defer alloc.free(path);
713 try std.testing.expect(loadLayout(alloc, path) == null); 627 try std.testing.expect(loadLayout(alloc, path) == null);
714 try saveLayout(path, "mux-layout 1\nleaf 0 x\n"); 628 try saveBytes(path, "mux-layout 1\nleaf 0 x\n");
715 const got = loadLayout(alloc, path) orelse return error.TestUnexpectedResult; 629 const got = loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
716 defer alloc.free(got); 630 defer alloc.free(got);
717 try std.testing.expectEqualStrings("mux-layout 1\nleaf 0 x\n", got); 631 try std.testing.expectEqualStrings("mux-layout 1\nleaf 0 x\n", got);
src/server/pty.zig
Old New
@@ -13,17 +13,6 @@ pub const Pty = struct {
13 child: std.posix.pid_t, 13 child: std.posix.pid_t,
14 exit_status: ?u32 = null, 14 exit_status: ?u32 = null,
15 15
16 pub const SpawnOptions = struct {
17 cols: u16,
18 rows: u16,
19 shell: [:0]const u8,
20 };
21
22 pub fn spawn(opts: SpawnOptions) !Pty {
23 var argv = [_:null]?[*:0]const u8{opts.shell.ptr};
24 return spawnArgv(.{ .cols = opts.cols, .rows = opts.rows, .argv = &argv });
25 }
26
27 /// One variable to set in the child. Spelled here rather than imported 16 /// One variable to set in the child. Spelled here rather than imported
28 /// so this module stays a leaf: a pty knows how to hand a child an 17 /// so this module stays a leaf: a pty knows how to hand a child an
29 /// environment, and deliberately does not know that shell integration 18 /// environment, and deliberately does not know that shell integration
@@ -325,8 +314,14 @@ test "Pty: deinit is bounded even when the child ignores HUP and TERM" {
325 try std.testing.expect(pty.exit_status != null); 314 try std.testing.expect(pty.exit_status != null);
326 } 315 }
327 316
317 /// Tests only: the product spawns an argv, never a shell word (rule 5).
318 fn spawnShell(cols: u16, rows: u16, shell: [:0]const u8) !Pty {
319 var argv = [_:null]?[*:0]const u8{shell.ptr};
320 return Pty.spawnArgv(.{ .cols = cols, .rows = rows, .argv = &argv });
321 }
322
328 test "Pty: spawn /bin/sh, echo round trip" { 323 test "Pty: spawn /bin/sh, echo round trip" {
329 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" }); 324 var pty = try spawnShell(80, 24, "/bin/sh");
330 defer pty.deinit(); 325 defer pty.deinit();
331 326
332 _ = try std.posix.write(pty.master, "echo m1-pty-ok\n"); 327 _ = try std.posix.write(pty.master, "echo m1-pty-ok\n");
@@ -397,7 +392,7 @@ test "Pty: the session shell does not inherit an ignored SIGINT" {
397 std.posix.sigaction(std.posix.SIG.INT, &ign, &prev); 392 std.posix.sigaction(std.posix.SIG.INT, &ign, &prev);
398 defer std.posix.sigaction(std.posix.SIG.INT, &prev, null); 393 defer std.posix.sigaction(std.posix.SIG.INT, &prev, null);
399 394
400 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" }); 395 var pty = try spawnShell(80, 24, "/bin/sh");
401 defer pty.deinit(); 396 defer pty.deinit();
402 397
403 // Absence proves nothing unless the shell was demonstrably alive and 398 // Absence proves nothing unless the shell was demonstrably alive and
@@ -425,7 +420,7 @@ test "Pty: the session shell does not inherit an ignored SIGINT" {
425 } 420 }
426 421
427 test "Pty: resize is visible via TIOCGWINSZ" { 422 test "Pty: resize is visible via TIOCGWINSZ" {
428 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" }); 423 var pty = try spawnShell(80, 24, "/bin/sh");
429 defer pty.deinit(); 424 defer pty.deinit();
430 425
431 try pty.resize(120, 40); 426 try pty.resize(120, 40);
@@ -445,7 +440,7 @@ test "Pty: mode reads the line discipline off the master" {
445 // back to run a command), so what the bits say would depend on where in 440 // back to run a command), so what the bits say would depend on where in
446 // that cycle the read landed. cat sets nothing, which leaves the pty 441 // that cycle the read landed. cat sets nothing, which leaves the pty
447 // saying exactly what this test put there. 442 // saying exactly what this test put there.
448 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/cat" }); 443 var pty = try spawnShell(80, 24, "/bin/cat");
449 defer pty.deinit(); 444 defer pty.deinit();
450 445
451 // What forkpty hands a new session: canonical input, echoed by the 446 // What forkpty hands a new session: canonical input, echoed by the
@@ -477,7 +472,7 @@ test "Pty: mode reads the line discipline off the master" {
477 } 472 }
478 473
479 test "Pty: checkExited reports shell exit" { 474 test "Pty: checkExited reports shell exit" {
480 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" }); 475 var pty = try spawnShell(80, 24, "/bin/sh");
481 defer pty.deinit(); 476 defer pty.deinit();
482 477
483 try std.testing.expect(pty.checkExited() == null); 478 try std.testing.expect(pty.checkExited() == null);
@@ -508,7 +503,7 @@ test "Pty: a later spawn does not inherit an earlier session's master" {
508 // daemon wedged on shutdown with every session's shell still alive. 503 // daemon wedged on shutdown with every session's shell still alive.
509 // Invisible at one session per daemon, which is why it arrived with the 504 // Invisible at one session per daemon, which is why it arrived with the
510 // second one. 505 // second one.
511 var p1 = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" }); 506 var p1 = try spawnShell(80, 24, "/bin/sh");
512 // No `defer p1.deinit()`: this test does p1's close itself, and deinit 507 // No `defer p1.deinit()`: this test does p1's close itself, and deinit
513 // would be a second close of that same fd. The cleanup is deinit's job 508 // would be a second close of that same fd. The cleanup is deinit's job
514 // by hand and with SIGKILL, which nothing can ignore — so a run that 509 // by hand and with SIGKILL, which nothing can ignore — so a run that
@@ -518,7 +513,7 @@ test "Pty: a later spawn does not inherit an earlier session's master" {
518 _ = std.posix.waitpid(p1.child, 0); 513 _ = std.posix.waitpid(p1.child, 0);
519 }; 514 };
520 515
521 var p2 = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" }); 516 var p2 = try spawnShell(80, 24, "/bin/sh");
522 defer p2.deinit(); 517 defer p2.deinit();
523 518
524 std.posix.close(p1.master); 519 std.posix.close(p1.master);
@@ -633,7 +628,7 @@ test "Pty: spawnArgv redirects stderr off the pty when asked" {
633 628
634 test "Pty: fgPgid tracks the foreground job" { 629 test "Pty: fgPgid tracks the foreground job" {
635 const alloc = std.testing.allocator; 630 const alloc = std.testing.allocator;
636 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" }); 631 var pty = try spawnShell(80, 24, "/bin/sh");
637 defer pty.deinit(); 632 defer pty.deinit();
638 633
639 // Prove the shell is up before asking anything of the pgid. 634 // Prove the shell is up before asking anything of the pgid.
@@ -676,7 +671,7 @@ test "Pty: fgPgid tracks the foreground job" {
676 } 671 }
677 672
678 test "Pty.adopt: an adopted pair still reports the child's real exit code" { 673 test "Pty.adopt: an adopted pair still reports the child's real exit code" {
679 const p = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" }); 674 const p = try spawnShell(80, 24, "/bin/sh");
680 // No defer p.deinit(): the adopted struct owns the master fd and the 675 // No defer p.deinit(): the adopted struct owns the master fd and the
681 // child now, and deinit would close both. We deinit the adopted copy. 676 // child now, and deinit would close both. We deinit the adopted copy.
682 var adopted = Pty.adopt(p.master, p.child); 677 var adopted = Pty.adopt(p.master, p.child);
src/server/server.zig
Old New
@@ -914,9 +914,9 @@ pub const Server = struct {
914 env[env.len - 1] = .{ .key = proto.sock_env, .value = try a.dupeZ(u8, opts.sock_path) }; 914 env[env.len - 1] = .{ .key = proto.sock_env, .value = try a.dupeZ(u8, opts.sock_path) };
915 915
916 // argv is the shell plus whatever the injection adds, null-terminated 916 // argv is the shell plus whatever the injection adds, null-terminated
917 // for execve. With no extra argv and no env this is byte-identical to 917 // for execve. With no extra argv and no env this is the bare
918 // the old `Pty.spawn` call, which is what keeps a /bin/sh session 918 // one-word argv, which is what keeps a /bin/sh session exactly the
919 // exactly the session it was before shell integration existed. 919 // session it was before shell integration existed.
920 const argv = try a.allocSentinel(?[*:0]const u8, 1 + injection.extra_argv.len, null); 920 const argv = try a.allocSentinel(?[*:0]const u8, 1 + injection.extra_argv.len, null);
921 argv[0] = opts.shell.ptr; 921 argv[0] = opts.shell.ptr;
922 for (injection.extra_argv, argv[1..]) |src, *dst| dst.* = src.ptr; 922 for (injection.extra_argv, argv[1..]) |src, *dst| dst.* = src.ptr;
src/tui/wall_layout.zig
Old New
@@ -320,7 +320,7 @@ pub fn saveLayoutTo(
320 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); 320 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)});
321 return; 321 return;
322 }; 322 };
323 wall.saveLayout(path, buf.items) catch |err| { 323 wall.saveBytes(path, buf.items) catch |err| {
324 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); 324 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)});
325 }; 325 };
326 } 326 }