Repository: denoland/deno
Pull request: fix(ext/node): make worker_threads ref/unref idempotent like Node.js

Closes #31871

## Summary
- Changes `Worker.ref()`/`Worker.unref()` from a reference counter to an idempotent boolean toggle, matching Node.js semantics
- In Node.js, `ref()`/`unref()` are idempotent: calling `ref()` multiple times is the same as calling it once, and a single `unref()` is sufficient to allow the process to exit
- Deno was using a counter (`#refCount`) that started at 1 and incremented/decremented on each `ref()`/`unref()` call. Libraries like [comlink](https://github.com/GoogleChromeLabs/comlink) call `ref()` for each pending RPC request but only `unref()` once when all pending requests complete — this left the counter elevated and prevented the process from exiting

## Test plan
- [x] Verified with the [comlink reproduction repo](https://github.com/ignatij/deno-vitest-4-compatibility-issue) — process exits correctly after single `unref()` call
- [x] `cargo test --test node_compat parallel -- --filter test-worker-ref-onexit` (passes)
- [x] `cargo test --test node_compat parallel -- --filter test-worker-terminate-unrefed` (passes)
- [x] `cargo test --test node_compat parallel -- --filter test-worker-unref-from-message` (passes)
- [x] `tools/lint.js` and `tools/format.js` pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/node/polyfills/worker_threads.ts
patch: @@ -160,7 +160,7 @@ const privateWorkerRef = Symbol("privateWorkerRef");
 class NodeWorker extends EventEmitter {
   #id = 0;
   #name = "";
-  #refCount = 1;
+  #refed = true;
   #messagePromise = undefined;
   #controlPromise = undefined;
   #workerOnline = false;
@@ -381,25 +381,24 @@ class NodeWorker extends EventEmitter {
   }
 
   [privateWorkerRef](ref) {
-    if (ref) {
-      this.#refCount++;
-    } else {
-      this.#refCount--;
+    if (ref === this.#refed) {
+      return;
     }
+    this.#refed = ref;
 
-    if (!ref && this.#refCount == 0) {
+    if (ref) {
       if (this.#controlPromise) {
-        core.unrefOpPromise(this.#controlPromise);
+        core.refOpPromise(this.#controlPromise);
       }
       if (this.#messagePromise) {
-        core.unrefOpPromise(this.#messagePromise);
+        core.refOpPromise(this.#messagePromise);
       }
-    } else if (ref && this.#refCount == 1) {
+    } else {
       if (this.#controlPromise) {
-        core.refOpPromise(this.#controlPromise);
+        core.unrefOpPromise(this.#controlPromise);
       }
       if (this.#messagePromise) {
-        core.refOpPromise(this.#messagePromise);
+        core.unrefOpPromise(this.#messagePromise);
       }
     }
   }
@@ -420,7 +419,7 @@ class NodeWorker extends EventEmitter {
   #pollControl = async () => {
     while (this.#status === "RUNNING") {
       this.#controlPromise = op_host_recv_ctrl(this.#id);
-      if (this.#refCount < 1) {
+      if (!this.#refed) {
         core.unrefOpPromise(this.#controlPromise);
       }
       const { 0: type, 1: data } = await this.#controlPromise;
@@ -474,7 +473,7 @@ class NodeWorker extends EventEmitter {
   #pollMessages = async () => {
     while (this.#status !== "TERMINATED") {
       this.#messagePromise = op_host_recv_message(this.#id);
-      if (this.#refCount < 1) {
+      if (!this.#refed) {
         core.unrefOpPromise(this.#messagePromise);
       }
       const data = await this.#messagePromise;

filename: tests/node_compat/config.jsonc
patch: @@ -1589,6 +1589,7 @@
     "parallel/test-worker-relative-path-double-dot.js": {},
     "parallel/test-worker-relative-path.js": {},
     "parallel/test-worker-terminate-unrefed.js": {},
+    "parallel/test-worker-unref-from-message-during-exit.js": {},
     "parallel/test-worker-thread-name.js": {},
     "parallel/test-worker.js": {},
     "parallel/test-worker.mjs": {},