Skip to content

fix(cli): read stdin on a dedicated OS thread so TTY exec exits without a stray ENTER#626

Open
G4614 wants to merge 1 commit into
boxlite-ai:mainfrom
G4614:test/cli-cp-roundtrip
Open

fix(cli): read stdin on a dedicated OS thread so TTY exec exits without a stray ENTER#626
G4614 wants to merge 1 commit into
boxlite-ai:mainfrom
G4614:test/cli-cp-roundtrip

Conversation

@G4614
Copy link
Copy Markdown
Contributor

@G4614 G4614 commented May 29, 2026

Move CLI stdin reads off tokio::io::stdin() onto a dedicated std::thread (not joined at runtime shutdown) so exec -ti returns to the host the moment the in-box shell exits, instead of hanging until a stray ENTER.

Test plan

Two-sided (reverted vs applied) against a real box under a pexpect PTY harness — type exit↵ once, then wait for the host process to return.

  • exit↵ once → does the host prompt come back? A correct CLI returns immediately; the buggy CLI hangs until a spurious 2nd ENTER unblocks the parked read.
  • Confirmed a real session (not an early error): in-box prompt / # shown, echo $((6*7))MARKER_42 echoed back from inside the box, clean exit 0.
observed (after a single exit↵) pre-fix (tokio::io::stdin()) post-fix (dedicated std::thread)
host process returns HANG ≥8 s — only after a 2nd ENTER (dt2 = 8.06 s) 0.04 s, no extra keystroke
exit code surfaced 0 (after the manual ENTER) 0

Both sides ran on main + #625, because TTY exec is dead on bare main (#625 / libcontainer-0.6 check_terminalinvalid runtime spec) and the hang path is otherwise unreachable: the fix is orthogonal in code (CLI terminal/mod.rs vs #625's guest spec) but its user-visible benefit only lands once #625 does. No automated regression test is bundled — a faithful reproducer needs a live PTY + running box + #625; the manual two-sided PTY probe above is the verification.

@G4614 G4614 closed this May 29, 2026
@G4614 G4614 force-pushed the test/cli-cp-roundtrip branch from e01f03f to 6048e91 Compare May 29, 2026 10:07
…ut a stray ENTER

tokio::io::stdin() parks its uncancellable read(2) on a tokio blocking-pool
thread, which the runtime joins on shutdown. When the remote shell exits,
aborting the async task cannot interrupt that parked read, so process exit
hung until the user pressed ENTER. Move the blocking read onto a plain
std::thread (not joined on shutdown) and forward bytes over a channel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@G4614 G4614 changed the title test(cli): cp host↔box round-trip with in-box content verification fix(cli): read stdin on a dedicated OS thread so TTY exec exits without a stray ENTER May 29, 2026
@G4614 G4614 reopened this May 29, 2026
@G4614 G4614 marked this pull request as ready for review May 29, 2026 13:15
@G4614
Copy link
Copy Markdown
Contributor Author

G4614 commented May 29, 2026

The crux — the one-line cause and the change

The interactive stdin reader used tokio::io::stdin(), whose blocking read(2) runs on a tokio blocking-pool thread that the runtime joins on shutdown. When the remote shell exits, the select loop aborts the stdin task (src/cli/src/terminal/mod.rs:191):

if let Some(h) = stdin_handle.as_ref() {
    h.abort();   // cancels the async task — NOT the OS thread already parked in read(2)
}

abort() only cancels the async task; it cannot interrupt a thread already parked in read(2). So runtime shutdown blocked on that pool thread until the user pressed ENTER to unblock the read — the stray-ENTER hang.

Fixstream_stdin (src/cli/src/terminal/mod.rs:239) moves the blocking read onto a plain std::thread (NOT the tokio blocking pool, so the runtime does not join it on shutdown) and forwards bytes over a channel:

// before
let mut stdin = tokio::io::stdin();
loop { match stdin.read(&mut buf).await { /* … */ } }

// after
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(16);
std::thread::spawn(move || {
    let mut stdin = std::io::stdin();
    loop {
        match stdin.read(&mut buf) {
            Ok(n) => { if tx.blocking_send(buf[..n].to_vec()).is_err() { break } }
            /* … */
        }
    }
});
while let Some(chunk) = rx.recv().await { /* forward to the box */ }

The process now exits promptly on shell exit; the still-parked read is reaped by process exit. Verified on a real box: host prompt returns in ~0.03 s after one exit, vs ≥8 s requiring a second ENTER before the fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant