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
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" } }; |