Repository: denoland/deno
Pull request: fix(ext/node): fix TLA stall with native TTY handles and make stdio indestructible

## Summary

- Fix TLA (top-level await) stall detection to account for active native
  libuv-compat TTY handles. Without this, any `await` on readline/stdin
  with a non-stdout output stream (like `@inquirer/prompts`' MuteStream
  pattern) would immediately error with "Top-level await promise never
  resolved".
- Make TTY `process.stdout`/`process.stderr` indestructible at the process
  level, matching Node.js's `dummyDestroy` behavior. Sets `_isStdio = true`
  to prevent `Stream.pipe()` from ending stdout when a piped source ends.
- Add PTY integration tests for consecutive readline prompts (both direct
  stdout and PassThrough/MuteStream patterns).

Fixes #32782
Fixes #30747

## Test plan

- [x] Manually tested `@inquirer/prompts` `select()` consecutive calls
- [x] Manually tested reproduction from #30747 (top-level await with readline)
- [x] PTY integration tests: `readline_multi_prompt_pty`, `readline_muted_multi_prompt_pty`
- [x] Existing `process_stdout_indestructible` spec test passes
- [ ] CI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/node/polyfills/process.ts
patch: @@ -1244,6 +1244,22 @@ internals.__bootstrapNodeProcess = function (
     if (io.stdout.isTerminal()) {
       /** https://nodejs.org/api/process.html#process_process_stdout */
       stdout = process.stdout = new TTYWriteStream(1);
+      // Match Node.js: stdio streams are indestructible.
+      // Libraries like mute-stream (@inquirer/prompts) call destroy()/end()
+      // on process.stdout between prompts. Without this, the underlying TTY
+      // handle is closed, breaking subsequent I/O.
+      // _isStdio also prevents Stream.pipe() from calling end() on stdout
+      // when a piped source stream ends.
+      // Ref: https://github.com/nodejs/node/blob/main/lib/internal/bootstrap/switches/is_main_thread.js
+      stdout._isStdio = true;
+      stdout.destroySoon = stdout.destroy;
+      stdout._destroy = function (err, cb) {
+        cb(err);
+        this._undestroy();
+        if (!this._writableState.emitClose) {
+          nextTick(() => this.emit("close"));
+        }
+      };
     } else {
       stdout = process.stdout = createWritableStdioStream(
         io.stdout,
@@ -1254,6 +1270,15 @@ internals.__bootstrapNodeProcess = function (
     if (io.stderr.isTerminal()) {
       /** https://nodejs.org/api/process.html#process_process_stderr */
       stderr = process.stderr = new TTYWriteStream(2);
+      stderr._isStdio = true;
+      stderr.destroySoon = stderr.destroy;
+      stderr._destroy = function (err, cb) {
+        cb(err);
+        this._undestroy();
+        if (!this._writableState.emitClose) {
+          nextTick(() => this.emit("close"));
+        }
+      };
     } else {
       stderr = process.stderr = createWritableStdioStream(
         io.stderr,

filename: libs/core/runtime/jsruntime.rs
patch: @@ -2342,6 +2342,7 @@ impl JsRuntime {
         || pending_state.has_pending_external_ops
         || pending_state.has_tick_scheduled
         || pending_state.has_refed_immediates > 0
+        || pending_state.has_uv_alive_handles
       {
         // pass, will be polled again
       } else {
@@ -2361,6 +2362,7 @@ impl JsRuntime {
         || pending_state.has_pending_external_ops
         || pending_state.has_tick_scheduled
         || pending_state.has_refed_immediates > 0
+        || pending_state.has_uv_alive_handles
       {
         // pass, will be polled again
       } else if realm.modules_idle() {

filename: tests/integration/run_tests.rs
patch: @@ -3513,3 +3513,38 @@ fn process_stdout_destroy_undestroy_pty() {
       console.expect("after");
     });
 }
+
+// Regression test for https://github.com/denoland/deno/issues/32782
+// Verifies that consecutive readline prompts work (e.g. @inquirer/prompts).
+#[test]
+fn readline_multi_prompt_pty() {
+  TestContext::default()
+    .new_command()
+    .args_vec(["run", "run/readline_multi_prompt.ts"])
+    .with_pty(|mut console| {
+      console.expect("Q1?");
+      console.write_line("hello");
+      console.expect("A1: hello");
+      console.expect("Q2?");
+      console.write_line("world");
+      console.expect("A2: world");
+    });
+}
+
+// Regression test for https://github.com/denoland/deno/issues/32782
+// Verifies that consecutive readline prompts work when output is a
+// PassThrough/MuteStream piped to process.stdout (like @inquirer/prompts).
+#[test]
+fn readline_muted_multi_prompt_pty() {
+  TestContext::default()
+    .new_command()
+    .args_vec(["run", "run/readline_muted_multi_prompt.ts"])
+    .with_pty(|mut console| {
+      console.expect("Q1?");
+      console.write_line("hello");
+      console.expect("A1: hello");
+      console.expect("Q2?");
+      console.write_line("world");
+      console.expect("A2: world");
+    });
+}

filename: tests/testdata/run/readline_multi_prompt.ts
patch: @@ -0,0 +1,22 @@
+// Minimal reproduction: consecutive readline prompts on the same stdin/stdout
+import process from "node:process";
+import { createInterface } from "node:readline";
+
+function ask(question: string): Promise<string> {
+  return new Promise((resolve) => {
+    const rl = createInterface({
+      input: process.stdin,
+      output: process.stdout,
+      terminal: true,
+    });
+    rl.question(question, (answer) => {
+      rl.close();
+      resolve(answer);
+    });
+  });
+}
+
+const a1 = await ask("Q1? ");
+console.log("A1:", a1);
+const a2 = await ask("Q2? ");
+console.log("A2:", a2);

filename: tests/testdata/run/readline_muted_multi_prompt.ts
patch: @@ -0,0 +1,29 @@
+// Reproduce @inquirer/prompts pattern: MuteStream piped to stdout
+import process from "node:process";
+import { createInterface } from "node:readline";
+import { PassThrough } from "node:stream";
+
+function ask(question: string): Promise<string> {
+  return new Promise((resolve) => {
+    const output = new PassThrough();
+    output.pipe(process.stdout);
+
+    const rl = createInterface({
+      input: process.stdin,
+      output: output,
+      terminal: true,
+    });
+
+    rl.question(question, (answer) => {
+      rl.close();
+      output.unpipe(process.stdout);
+      output.end();
+      resolve(answer);
+    });
+  });
+}
+
+const a1 = await ask("Q1? ");
+console.log("A1:", a1);
+const a2 = await ask("Q2? ");
+console.log("A2:", a2);