Repository: denoland/deno
Pull request: fix(ext/node): use sync writes for TTY stdout/stderr WriteStream

## Summary

- After #32343 switched `process.stdout`/`process.stderr` to `tty.WriteStream` (extending `net.Socket`), writes became asynchronous through the `LibuvStreamWrap` handle
- Since `console.log` in Deno writes synchronously via `Deno.core.print()`, this created a race condition where async `process.stdout.write()` calls could complete after a subsequent synchronous `console.log`, causing output corruption (e.g. listr2 ANSI redraws overwriting later output)
- Fix by overriding `_write` for fd 1 and 2 to use `writeSync`, matching the behavior of the previous `createWritableStdioStream` implementation

Closes #32473

Changed files:
filename: ext/node/polyfills/internal/tty.js
patch: @@ -10,6 +10,7 @@ const {
   Error,
   ObjectEntries,
   ObjectPrototypeHasOwnProperty,
+  ObjectPrototypeIsPrototypeOf,
   ObjectValues,
   RegExpPrototypeExec,
   SafeMap,
@@ -22,6 +23,8 @@ const {
   SetPrototypeGetSize,
   StringPrototypeSplit,
   StringPrototypeToLowerCase,
+  TypedArrayPrototypeSlice,
+  Uint8ArrayPrototype,
 } = primordials;
 
 import { ERR_INVALID_FD } from "ext:deno_node/internal/errors.ts";
@@ -35,6 +38,7 @@ import {
   cursorTo,
   moveCursor,
 } from "ext:deno_node/internal/readline/callbacks.mjs";
+import { Buffer } from "node:buffer";
 import { release } from "node:os";
 
 // Color depth constants
@@ -308,6 +312,33 @@ export class WriteStream extends Socket {
     this.columns = columns;
     this.rows = rows;
     this.isTTY = true;
+
+    // For stdout/stderr, use synchronous writes to avoid races with
+    // console.log which writes synchronously via Deno.core.print().
+    // Without this, async writes through the Socket/TTY handle can complete
+    // after a subsequent synchronous console.log, causing output corruption
+    // (e.g. listr2 ANSI redraws overwriting later output).
+    if (fd === 1 || fd === 2) {
+      const writer = fd === 1 ? io.stdout : io.stderr;
+      this._write = function (data, enc, cb) {
+        try {
+          let buf = ObjectPrototypeIsPrototypeOf(Uint8ArrayPrototype, data)
+            ? data
+            : Buffer.from(data, enc);
+          // deno-lint-ignore prefer-primordials
+          while (buf.byteLength > 0) {
+            const nwritten = writer.writeSync(buf);
+            // deno-lint-ignore prefer-primordials
+            if (nwritten >= buf.byteLength) break;
+            buf = TypedArrayPrototypeSlice(buf, nwritten);
+          }
+        } catch (e) {
+          cb(e);
+          return;
+        }
+        cb();
+      };
+    }
   }
 
   on(event, listener) {

filename: tests/integration/run_tests.rs
patch: @@ -3483,3 +3483,20 @@ fn test_permission_broker() {
 [WILDCARD]"#,
   );
 }
+
+// Regression test for https://github.com/denoland/deno/issues/32473
+// Verifies that process.stdout.write() and console.log() produce output
+// in the correct order when stdout is a TTY (uses PTY).
+#[test]
+fn process_stdout_write_order_pty() {
+  TestContext::default()
+    .new_command()
+    .args_vec(["run", "run/process_stdout_write_order.ts"])
+    .with_pty(|mut console| {
+      console.expect("A");
+      console.expect("B");
+      console.expect("C");
+      console.expect("D");
+      console.expect("E");
+    });
+}

filename: tests/testdata/run/process_stdout_write_order.ts
patch: @@ -0,0 +1,10 @@
+// Regression test for https://github.com/denoland/deno/issues/32473
+// Verifies that process.stdout.write() and console.log() produce output
+// in the correct order when stdout is a TTY.
+import process from "node:process";
+
+process.stdout.write("A\n");
+console.log("B");
+process.stdout.write("C\n");
+console.log("D");
+process.stdout.write("E\n");