Repository: denoland/deno
Pull request: fix(ext/node): set kLastWriteWasAsync in JS write path to prevent double callback

## Summary

- Set `streamBaseState[kLastWriteWasAsync] = 1` in the JS `writeBuffer` and `writev` fast path before returning, so `afterWriteDispatched` correctly identifies the write as async and defers the callback
- Without this, a preceding Rust sync write (e.g. TTY stdout) leaves `kLastWriteWasAsync=0`, causing a subsequent JS Pipe write to fire the callback twice → `ERR_MULTIPLE_CALLBACK`

Fixes #32803
Fixes #32813

Changed files:
filename: ext/node/polyfills/internal_binding/stream_wrap.ts
patch: @@ -213,6 +213,13 @@ export class LibuvStreamWrap extends HandleWrap {
       );
     }
 
+    // Mark as async so afterWriteDispatched does not fire the callback
+    // synchronously. The Rust write_buffer path sets this on the shared
+    // streamBaseState array (0 for sync, 1 for async), but the JS #write
+    // path is always async. Without this, a preceding Rust sync write
+    // leaves kLastWriteWasAsync=0, causing a double callback.
+    streamBaseState[kLastWriteWasAsync] = 1;
+
     this.#write(req, data);
 
     return 0;
@@ -243,6 +250,7 @@ export class LibuvStreamWrap extends HandleWrap {
       if (typeof chunks[0] === "string") chunks[0] = Buffer.from(chunks[0]);
       if (typeof chunks[1] === "string") chunks[1] = Buffer.from(chunks[1]);
 
+      streamBaseState[kLastWriteWasAsync] = 1;
       PromisePrototypeThen(
         op_raw_write_vectored(rid, chunks[0], chunks[1]),
         (nwritten) => {

filename: tests/integration/run_tests.rs
patch: @@ -2447,6 +2447,47 @@ fn fsfile_set_raw_should_not_panic_on_no_tty() {
   );
 }
 
+// Regression test for https://github.com/denoland/deno/issues/32803
+// After a Rust-side TTY sync write sets kLastWriteWasAsync=0, a JS Pipe
+// write must not cause ERR_MULTIPLE_CALLBACK. Needs a PTY so stdout is a
+// real TTY backed by the Rust LibUvStreamWrap.
+#[cfg(target_os = "macos")]
+#[test]
+fn pipe_write_no_double_callback_after_tty_write() {
+  let deno_exe = util::deno_exe_path();
+  let testdata = util::testdata_path();
+  let script = testdata.join("node/pipe_write_no_double_callback.mjs");
+  let output = Command::new("expect")
+    .arg("-c")
+    .arg(format!(
+      concat!(
+        "set timeout 30\n",
+        "spawn {} run --allow-all -q {}\n",
+        "expect eof\n",
+        "lassign [wait] pid spawnid os_error_flag value\n",
+        "exit $value\n",
+      ),
+      deno_exe.to_string().replace("{", "\\{").replace("}", "\\}"),
+      script.to_string().replace("{", "\\{").replace("}", "\\}"),
+    ))
+    .output()
+    .unwrap();
+  let combined = String::from_utf8_lossy(&output.stdout);
+  assert!(
+    !combined.contains("ERR_MULTIPLE_CALLBACK"),
+    "ERR_MULTIPLE_CALLBACK detected.\nstdout:\n{combined}"
+  );
+  assert!(
+    combined.contains("OK"),
+    "test did not print OK.\nstdout:\n{combined}"
+  );
+  assert!(
+    output.status.success(),
+    "test failed (exit={:?}).\nstdout:\n{combined}",
+    output.status.code()
+  );
+}
+
 #[test]
 fn timeout_clear() {
   // https://github.com/denoland/deno/issues/7599

filename: tests/testdata/node/pipe_write_no_double_callback.mjs
patch: @@ -0,0 +1,54 @@
+// Regression test for https://github.com/denoland/deno/issues/32803
+//
+// Must be run inside a PTY so process.stdout is a real TTY backed by the
+// Rust LibUvStreamWrap (whose write_buffer sets kLastWriteWasAsync = 0).
+
+import net from "node:net";
+import path from "node:path";
+import process from "node:process";
+
+const errors = [];
+
+process.on("uncaughtException", (err) => {
+  errors.push(err);
+});
+
+const socketPath = path.join(Deno.makeTempDirSync(), "test.sock");
+
+const server = net.createServer((conn) => {
+  conn.resume();
+  conn.on("end", () => conn.end());
+});
+
+server.listen(socketPath, () => {
+  const client = net.connect(socketPath, () => {
+    // Write to stdout (TTY → Rust write path → kLastWriteWasAsync=0),
+    // then to the pipe (JS write path). Without the fix, the stale
+    // kLastWriteWasAsync=0 causes afterWriteDispatched to fire the
+    // callback synchronously, then async #write fires it again.
+    let pending = 20;
+    for (let i = 0; i < 20; i++) {
+      process.stdout.write(".");
+      client.write(`msg ${i}\n`, () => {
+        if (--pending === 0) done();
+      });
+    }
+
+    function done() {
+      client.end(() => {
+        server.close(() => {
+          process.stdout.write("\n");
+          const multiCb = errors.find(
+            (e) => e.code === "ERR_MULTIPLE_CALLBACK",
+          );
+          if (multiCb) {
+            console.log("FAIL: ERR_MULTIPLE_CALLBACK");
+            process.exit(1);
+          }
+          console.log("OK");
+          process.exit(0);
+        });
+      });
+    }
+  });
+});