test(security-scanner-matrix): wait for pty EOF before reading the TTY prompt output

Closed
RO
1mo ago
+35-26

Problem

  • bun-security-scanner-matrix-without-node-modules.test.ts fails on main, on every retry of build 110964 (alpine 3.23 aarch64), with Expected to contain: "Installation cancelled." and output that ends at Continue anyway? [y/N] n\r\n. The sibling with-node-modules file fails the same way (build 110943, debian 13 aarch64). The exit code is correct. Only the last line of the child's output is missing.
  • The TTY cases in bun-security-scanner-matrix-runner.ts pass a pre-built new Bun.Terminal() to Bun.spawn and dispose it right after await proc.exited. Subprocess::on_process_exit drains the pty only for inline terminals (OWNS_TERMINAL, src/runtime/api/bun/subprocess.rs:972). When the kernel reports the pidfd before the pty master becomes readable, exited resolves, the await using closes the terminal, and Terminal::close_internal drops the unread bytes. The test has this race since #25587 added it.

Fix

  • The runner spawns with an inline terminal and waits for its exit (EOF) callback after proc.exited. For an inline terminal bun reads the pty to EOF in on_process_exit before it resolves exited, so the last line is in errAndOut before the assertions run. This is the pattern test/js/bun/terminal/terminal.test.ts uses for a fast-exiting child.
  • The test still drives the same prompt: it writes the answer when the prompt text arrives, and asserts on the same messages, exit code, and files.
  • Verified: USE_SYSTEM_BUN=1 bun test on both matrix files (688 pass, 32 skip, 0 fail each) and bun bd test -t "TTY:" on without-node-modules. A standalone probe (Notes) loses the last line in 505 of 8000 runs with a pre-built terminal and in 0 of 8000 with an inline terminal.

Background

  • Bun.Terminal wraps a pty. Bun.spawn({ terminal }) accepts an options object (an inline terminal the subprocess owns) or an existing Terminal (the caller owns it and can reuse it). Only the owned one is drained and closed on child exit.
  • The data callback runs inside the epoll callback for the master fd. exited resolves inside the pidfd callback, and microtasks run before the next ready poll is dispatched. So the order of the two callbacks in one epoll batch decides whether the last bytes reach data.

Related open PRs: #35851 fixes the same race on the bun side (drain a pre-built terminal on child exit). #39956 makes the same change to the runner as part of a larger rewrite of the matrix. This PR is the minimal test-only fix so main is green again.

Notes

Probe, run with the released bun 1.4.3 on linux x64. The child is sh -c 'printf "PROMPT? [y/N] "; read x; printf "\nCANCELLED\n"'. The parent writes n\n when it sees the prompt, awaits proc.exited, then checks whether CANCELLED arrived. With a bun child the loss did not reproduce on x64 in 12000 runs. With the fast-exiting sh child, 8 parallel runs of 1000:

existing lost 59 of 1000   (pre-built Bun.Terminal, await using)
existing lost 60 of 1000
existing lost 54 of 1000
existing lost 61 of 1000
existing lost 75 of 1000
existing lost 60 of 1000
existing lost 65 of 1000
existing lost 72 of 1000
inline lost 0 of 1000      (terminal: {...}, await exit callback)  x8

Why a synchronous read after exit always sees the data on Linux: pty_write pushes the bytes into the master's flip buffer and queues the flush on a workqueue, so the master becomes readable a little after the write. n_tty_read calls tty_buffer_flush_work before it reports EAGAIN or EIO, so the read that drain_and_close_slave_fd does in on_process_exit observes the bytes even when the workqueue has not run yet. An epoll readiness event does not have that guarantee.

The 5 s per-test timeouts I saw in the local ASAN debug build (host load average above 150) occur on main too (2 of 288 on main, 7 of 288 on this branch, different cases each time). Each one is a child killed by the test timeout (exit code 143), not an EOF hang.

test/cli/bun.test.ts (a tty stdout still gets colors) works around the same race with a polling loop and a comment. test/cli/init/init.test.ts and test/regression/issue/26286.test.ts also use pre-built terminals but wait for specific output before the child exits.


no test proof · iteration 0 · no src or test change; test-proof not applicable