Skip to content

fix(worker): close owned file handles and descriptors on exit - #184

Merged
steipete merged 6 commits into
mainfrom
claude/worker-owned-file-handles
Oct 16, 2026
Merged

steipete merged 6 commits into
mainfrom
claude/worker-owned-file-handles

Conversation

@steipete

@steipete steipete commented Oct 16, 2026 •

Copy link
Copy Markdown

What does this PR do?

Close descriptors owned by a node:worker_threads Worker when it terminates or exits naturally. Without cleanup, repeated worker restarts retain file descriptors and can eventually exhaust the process descriptor limit.

A worker-local registry records successful fs.open results and FileHandle ownership. It records the JavaScript-visible descriptor after native-to-CRT conversion on Windows, so explicit close and teardown use the same identity. Raw descriptors follow Node's trackUnmanagedFds option (default true, inherited by nested workers); FileHandles close even when raw tracking is disabled. Explicit close removes ownership, and canceled close work restores it only when the descriptor was previously owned. The teardown sweep runs after off-thread filesystem work returns, so an outstanding operation cannot use a descriptor number after cleanup reuses it. Parent descriptors and inherited stdio are never registered merely because a worker uses them.

This adapts the open proposal oven-sh#34260 by robobun to the fork's current VM teardown and filesystem task ownership. Existing constructor-transfer bookkeeping is preserved; FileHandle transfer through postMessage() remains unsupported. A regression asserts its existing DataCloneError and verifies the sender can still use and close the handle.

How did you verify your code works?

Node.js 24.22.0 passes the portable oracle: termination and natural exit; raw sync/callback opens; FileHandles with tracking enabled or disabled; parent descriptors; multiple handles; explicit double close and subsequent parent allocation; nested tracking inheritance. These held-handle exits emit no warning in Node. Existing garbage-collection diagnostics are unchanged.

Native Linux before/after proof on the same base:

  • test/js/node/worker_threads/worker-track-unmanaged-fds.test.ts: 4 pass / 11 fail before; all 15 pass after. This includes raw-fd options, FileHandles, multiple/closed handles, natural-exit writes, pending closes/opens, and ReadStreams.
  • test/js/node/worker_threads and test/js/node/fs: 1,259 pass / 115 skip. Four files required a separate run with --expose-internals after initial harness-import errors: 18 pass / 5 skip / 0 fail. Combined: 1,277 pass / 120 skip; no unresolved test failure.
  • The synthetic consumer reduction opens a heartbeat lock in a Worker, unlinks it in the parent, terminates the Worker, and verifies the descriptor is closed. It fails before the change and passes after it. No real application state is used.
  • The initial Rust cross-target sweep passed ten non-Windows targets; both Windows architectures then passed at 8c4133df4 after correcting the libuv cancellation comparison. The final descriptor normalization passes both Linux x64 and macOS arm64 native build and compatibility CI. Native Windows runtime behavior was not exercised.

The built-in TypeScript project typechecks, and all 26 CI-selection tests pass. Independent Codex and Claude reviews of the complete change are scoped-clean at P2. The final main-branch integration also has a clean Codex P2 review.

Final head db8e84ba88ff6c0b55855ec38664e5b0f1d4dcd8 passes Linux x64 and macOS arm64 native CI, including the new regression file on both platforms. The reports contain 51 successful Linux execution entries and 48 successful macOS entries. All final-head formatting, lint, type, and Rust checks pass. The opened-only claude-find-issues run encountered the known missing CI credential on the initial head; it was not rerun.

@steipete
steipete marked this pull request as ready for review October 16, 2026 12:57
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T13:04:28.739087Z db8e84b Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@steipete
steipete merged commit 464f2fa into main Oct 16, 2026
12 checks passed
@steipete
steipete deleted the claude/worker-owned-file-handles branch October 16, 2026 12:59

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: db8e84ba88

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

w.on("message", async () => {
// Wait for the pool to have opened them, then end the worker. If the
// worker's busy loop ends first, report that instead of polling forever.
while (openFds(ino) < 50 && !exited) await new Promise(r => setTimeout(r, 5));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace timed polling with deterministic synchronization

When filesystem jobs take longer than the worker's two-second busy loop, this polling loop stops as soon as exit fires and records fewer than 50 descriptors, causing the test to fail even if cleanup is correct. This makes the result depend on thread-pool scheduling and machine load; synchronize the worker with the parent or use a bounded condition mechanism instead of repeated setTimeout sleeps.

AGENTS.md reference: AGENTS.md:L105-L110

Useful? React with 👍 / 👎.


### [`node:worker_threads`](https://nodejs.org/api/worker_threads.html)

Worker termination and natural exit close FileHandles opened by the Worker. Raw descriptors from `fs.open()` and `fs.openSync()` close by default; `trackUnmanagedFds: false` preserves them. Parent-owned descriptors remain open. FileHandle transfer through `postMessage()` remains unsupported.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the stale compatibility-table claim

This newly added paragraph says trackUnmanagedFds is implemented, but the same node:worker_threads section still says at line 388 that Bun ignores the option. Users therefore receive mutually exclusive compatibility guidance on one page; update the existing limitation entry as part of this behavior change.

AGENTS.md reference: AGENTS.md:L187-L191

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant