a73x

60970c6c

test: a flooded stderr is a session that stops, and the drain is why it doesn't

a73x   2026-08-30 10:58

Commit message
test: a flooded stderr is a session that stops, and the drain is why it doesn't

The hazard the third poll fd exists for, asserted with a real child: a
pipe holds 64k, and ssh blocked writing past that is ssh not moving the
session's bytes either — a tile that stops with nothing on screen to say
so. The fixture floods before it serves, so an owner that never drains
cannot get past it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

src/client/client.zig
Old New
@@ -2493,6 +2493,79 @@ test "lostMsg: only a --via transport that never connected gets the new wording"
2493 // fail in a real terminal, which is why none of it was pinned at all. 2493 // fail in a real terminal, which is why none of it was pinned at all.
2494 // `openFailure` is pure, so each class is one call with a literal answer. 2494 // `openFailure` is pure, so each class is one call with a literal answer.
2495 2495
2496 test "Transport.drainErr: a session whose ssh floods stderr keeps serving" {
2497 // The reason the poll set grew a third fd rather than the reason
2498 // growing a sentence. A pipe holds 64k; past that the writer BLOCKS,
2499 // and ssh blocked on stderr is ssh not moving the session's bytes
2500 // either. Nothing on screen says so — the tile simply stops.
2501 //
2502 // The fixture floods in the FOREGROUND, before it starts serving, so
2503 // an owner that never drains cannot get past it. (Backgrounding the
2504 // flood would let `cat` serve regardless, and the test would pass with
2505 // the drain deleted — which is the shape this claim exists to catch.)
2506 // Verified by deleting the `drainErr` call below: the round trip then
2507 // never completes and the 2 s budget is what ends the test.
2508 const alloc = std.testing.allocator;
2509 var tmp = try TmpDir.make();
2510 defer tmp.cleanup();
2511 var carry: std.ArrayList(u8) = .empty;
2512 defer carry.deinit(alloc);
2513
2514 var t = try Transport.open(alloc, .{ .hand = .{
2515 .host = "flood",
2516 .ssh_argv = &.{
2517 "/bin/sh", "-c",
2518 \\printf 'endpoint none\n'
2519 \\head -c 1048576 /dev/zero >&2
2520 \\cat
2521 ,
2522 },
2523 .cache_path = null,
2524 .deadline_ms = 200,
2525 } }, &carry, -1, null);
2526 defer t.close();
2527 try std.testing.expect(t.link == .pipe);
2528 // The whole point of the fd being on the Transport: whoever owns the
2529 // link can find it without knowing how the handoff went.
2530 try std.testing.expect(t.errFd() != null);
2531
2532 // `cat` echoes, so a frame written comes back as itself once the
2533 // flood is out of the way.
2534 const blob = "z" ** 4096;
2535 try t.writeFrame(.input, blob);
2536
2537 const deadline = std.time.milliTimestamp() + 2000;
2538 var echoed = false;
2539 while (!echoed and std.time.milliTimestamp() < deadline) {
2540 var fds: [2]std.posix.pollfd = undefined;
2541 fds[0] = .{ .fd = t.pollFd(), .events = std.posix.POLL.IN, .revents = 0 };
2542 var n: usize = 1;
2543 if (t.errFd()) |efd| {
2544 fds[1] = .{ .fd = efd, .events = std.posix.POLL.IN, .revents = 0 };
2545 n = 2;
2546 }
2547 _ = std.posix.poll(fds[0..n], 50) catch break;
2548 if (n == 2 and fds[1].revents != 0) t.drainErr();
2549 if (fds[0].revents == 0) continue;
2550 switch (t.readFrame(alloc) catch break) {
2551 .incomplete => {},
2552 .closed => break,
2553 .frame => |f| {
2554 defer f.deinit(alloc);
2555 if (f.type == .input and std.mem.eql(u8, f.payload, blob)) echoed = true;
2556 },
2557 }
2558 }
2559 // The rule before the failure, since a bare `expect` names nothing:
2560 // this test's whole subject is WHY no frame came back.
2561 if (!echoed) std.debug.print(
2562 "the tile stopped serving: with stderr undrained ssh blocks at the pipe's " ++
2563 "64k and never runs the `cat` that carries the session\n",
2564 .{},
2565 );
2566 try std.testing.expect(echoed);
2567 }
2568
2496 test "openFailure: a quic:// target names the key or the address, and only an abort exits 0" { 2569 test "openFailure: a quic:// target names the key or the address, and only an abort exits 0" {
2497 var buf: [open_err_len]u8 = undefined; 2570 var buf: [open_err_len]u8 = undefined;
2498 const q: Target = .{ .quic = .{ .host_port = "box:4433", .key_path = "/etc/mux/key" } }; 2571 const q: Target = .{ .quic = .{ .host_port = "box:4433", .key_path = "/etc/mux/key" } };