Repository: denoland/deno
Pull request: fix(ext/node): signal listeners added via `process.once` can now be removed

## Summary

- Fix `process.once("SIGINT", handler)` followed by `process.removeListener("SIGINT", handler)` not actually removing the Deno-level signal listener
- When `EventEmitter.once()` wraps a handler, `Process.prototype.off` now looks up the wrapper before passing it to `Deno.removeSignalListener`
- Add spec test covering `once`, `off`, `on`, and `prependOnceListener` removal paths

Closes #32603

## Test plan

- [x] New spec test `process_once_remove_signal_listener` verifies listener count drops to 0 after removal for all registration methods
- [x] Manual verification that the repro from #32603 no longer fires the handler after removal

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

Changed files:
filename: ext/node/polyfills/process.ts
patch: @@ -484,6 +484,31 @@ function Process(this: any) {
 }
 Process.prototype = Object.create(EventEmitter.prototype);
 
+// Look up the actual registered listener for a signal event. When `once()` is
+// used, EventEmitter wraps the listener in a function with a `.listener`
+// property. We need the wrapper to pass to `Deno.removeSignalListener`.
+function _findSignalListener(
+  // deno-lint-ignore no-explicit-any
+  target: any,
+  event: string,
+  // deno-lint-ignore no-explicit-any
+  listener: (...args: any[]) => void,
+  // deno-lint-ignore no-explicit-any
+): ((...args: any[]) => void) | undefined {
+  const events = target._events;
+  if (events === undefined) return undefined;
+  const list = events[event];
+  if (list === undefined) return undefined;
+  if (typeof list === "function") {
+    if (list !== listener && list.listener === listener) return list;
+    return undefined;
+  }
+  for (let i = list.length - 1; i >= 0; i--) {
+    if (list[i] !== listener && list[i].listener === listener) return list[i];
+  }
+  return undefined;
+}
+
 /** https://nodejs.org/api/process.html#process_process_events */
 Process.prototype.on = function (
   // deno-lint-ignore no-explicit-any
@@ -529,8 +554,16 @@ Process.prototype.off = function (
     ) {
       // Ignores all signals except SIGBREAK, SIGINT, and SIGWINCH on windows.
     } else {
+      // Find the actual registered listener before EventEmitter removes it.
+      // When using `once()`, EventEmitter wraps the original listener in a
+      // wrapper with a `.listener` property pointing to the original. We need
+      // to pass the wrapper (not the original) to Deno.removeSignalListener.
+      const registered = _findSignalListener(this, event, listener);
       EventEmitter.prototype.off.call(this, event, listener);
-      Deno.removeSignalListener(event as Deno.Signal, listener);
+      Deno.removeSignalListener(
+        event as Deno.Signal,
+        registered ?? listener,
+      );
     }
   } else {
     EventEmitter.prototype.off.call(this, event, listener);
@@ -589,6 +622,49 @@ Process.prototype.removeListener = function (
   return this.off(event, listener);
 };
 
+Process.prototype.removeAllListeners = function (
+  // deno-lint-ignore no-explicit-any
+  event?: string | any,
+) {
+  if (arguments.length === 0) {
+    // Remove all listeners for all events - find all signal events and
+    // unregister their Deno signal listeners before clearing.
+    const events = this._events;
+    if (events !== undefined) {
+      for (const key of Object.keys(events)) {
+        if (typeof key === "string" && key.startsWith("SIG")) {
+          _removeAllSignalListeners(this, key);
+        }
+      }
+    }
+    return EventEmitter.prototype.removeAllListeners.call(this);
+  }
+  if (typeof event === "string" && event.startsWith("SIG")) {
+    _removeAllSignalListeners(this, event);
+  }
+  return EventEmitter.prototype.removeAllListeners.call(this, event);
+};
+
+function _removeAllSignalListeners(
+  // deno-lint-ignore no-explicit-any
+  target: any,
+  event: string,
+) {
+  const events = target._events;
+  if (events === undefined) return;
+  const list = events[event];
+  if (list === undefined) return;
+  if (typeof list === "function") {
+    const actual = list.listener ?? list;
+    Deno.removeSignalListener(event as Deno.Signal, actual);
+  } else {
+    for (let i = 0; i < list.length; i++) {
+      const actual = list[i].listener ?? list[i];
+      Deno.removeSignalListener(event as Deno.Signal, actual);
+    }
+  }
+}
+
 /** https://nodejs.org/api/process.html#process_process */
 // @ts-ignore TS doesn't work well with ES5 classes
 const process = new Process();

filename: tests/specs/node/process_once_remove_signal_listener/__test__.jsonc
patch: @@ -0,0 +1,9 @@
+{
+  "tests": {
+    "process_once_remove_signal_listener": {
+      "if": "unix",
+      "args": "run main.ts",
+      "output": "main.out"
+    }
+  }
+}

filename: tests/specs/node/process_once_remove_signal_listener/main.out
patch: @@ -0,0 +1,12 @@
+SIGINT listeners before removeListener: 1
+SIGINT listeners after removeListener: 0
+SIGINT listeners before off: 1
+SIGINT listeners after off: 0
+SIGINT listeners before on removeListener: 1
+SIGINT listeners after on removeListener: 0
+SIGINT listeners before prependOnce removeListener: 1
+SIGINT listeners after prependOnce removeListener: 0
+SIGINT listeners before removeAllListeners: 2
+SIGINT listeners after removeAllListeners: 0
+SIGINT listeners before removeAllListeners(): 2
+SIGINT listeners after removeAllListeners(): 0

filename: tests/specs/node/process_once_remove_signal_listener/main.ts
patch: @@ -0,0 +1,73 @@
+import process from "node:process";
+
+function sigintHandler() {
+  console.log("SIGINT caught");
+}
+
+// Test 1: once + removeListener should fully remove the listener
+process.once("SIGINT", sigintHandler);
+console.log(
+  "SIGINT listeners before removeListener:",
+  process.listenerCount("SIGINT"),
+);
+process.removeListener("SIGINT", sigintHandler);
+console.log(
+  "SIGINT listeners after removeListener:",
+  process.listenerCount("SIGINT"),
+);
+
+// Test 2: once + off should also work
+process.once("SIGINT", sigintHandler);
+console.log("SIGINT listeners before off:", process.listenerCount("SIGINT"));
+process.off("SIGINT", sigintHandler);
+console.log("SIGINT listeners after off:", process.listenerCount("SIGINT"));
+
+// Test 3: on + removeListener should still work (no once-wrapping)
+process.on("SIGINT", sigintHandler);
+console.log(
+  "SIGINT listeners before on removeListener:",
+  process.listenerCount("SIGINT"),
+);
+process.removeListener("SIGINT", sigintHandler);
+console.log(
+  "SIGINT listeners after on removeListener:",
+  process.listenerCount("SIGINT"),
+);
+
+// Test 4: prependOnceListener + removeListener
+process.prependOnceListener("SIGINT", sigintHandler);
+console.log(
+  "SIGINT listeners before prependOnce removeListener:",
+  process.listenerCount("SIGINT"),
+);
+process.removeListener("SIGINT", sigintHandler);
+console.log(
+  "SIGINT listeners after prependOnce removeListener:",
+  process.listenerCount("SIGINT"),
+);
+
+// Test 5: removeAllListeners("SIGINT") should remove all signal listeners
+process.on("SIGINT", sigintHandler);
+process.once("SIGINT", sigintHandler);
+console.log(
+  "SIGINT listeners before removeAllListeners:",
+  process.listenerCount("SIGINT"),
+);
+process.removeAllListeners("SIGINT");
+console.log(
+  "SIGINT listeners after removeAllListeners:",
+  process.listenerCount("SIGINT"),
+);
+
+// Test 6: removeAllListeners() with no args should remove signal listeners too
+process.on("SIGINT", sigintHandler);
+process.once("SIGINT", sigintHandler);
+console.log(
+  "SIGINT listeners before removeAllListeners():",
+  process.listenerCount("SIGINT"),
+);
+process.removeAllListeners();
+console.log(
+  "SIGINT listeners after removeAllListeners():",
+  process.listenerCount("SIGINT"),
+);