Skip to content

feat(ext/node): implement v8.setHeapSnapshotNearHeapLimit#35694

Merged
bartlomieju merged 5 commits into
denoland:mainfrom
nathanwhitbot:orcha/impl-e40098c6
Jul 6, 2026
Merged

feat(ext/node): implement v8.setHeapSnapshotNearHeapLimit#35694
bartlomieju merged 5 commits into
denoland:mainfrom
nathanwhitbot:orcha/impl-e40098c6

Conversation

@nathanwhitbot

Copy link
Copy Markdown
Contributor

Fixes #35685.

v8.setHeapSnapshotNearHeapLimit(limit) was effectively a no-op in Deno — the function did not exist in the node:v8 polyfill, so there was no way to auto-generate a heap snapshot when a process was about to OOM. This implements it so it actually works.

What it does

When the V8 heap approaches its limit, V8 now invokes a callback that streams a .heapsnapshot file to disk (up to limit times) right before the process would OOM — mirroring Node's Environment::NearHeapLimitCallback (src/heap_utils.cc).

Implementation

  • ext/node/ops/v8.rs — new op_v8_set_heap_snapshot_near_heap_limit(scope, limit). It captures the raw isolate pointer, leaks a small state struct (isolate ptr, limit, taken count, reentrancy guard, cwd, pid, seq), and installs an extern "C" near-heap-limit callback via add_near_heap_limit_callback. The callback:
    • Adds young-generation headroom to the returned heap limit so V8 has room to finish writing the snapshot (matching Node's current + max_young_gen_size).
    • Streams take_heap_snapshot chunks straight to a BufWriter — no in-memory buffering, so it doesn't OOM the very process it's snapshotting.
    • Writes Heap.<YYYYMMDD>.<HHMMSS>.<pid>.0.<seq>.heapsnapshot (matching writeHeapSnapshot's naming) into the cwd and prints Writing heap snapshot to <path> to stderr.
    • Once the snapshot budget is exhausted, removes the callback and returns the unchanged heap limit so V8 proceeds to OOM normally.
  • Write permission is enforced at install time: the op checks write permission for the target directory via the PermissionsContainer, so --deny-write (or the absence of --allow-write) prevents the callback from being installed and raises the standard Deno permission error — the snapshot cannot be written behind the permission system's back.
  • ext/node/lib.rs — registers the op.
  • ext/node/polyfills/v8.ts / v8_esm.ts — adds setHeapSnapshotNearHeapLimit(limit) with install-once semantics and validateUint32(limit, "limit", true) (positive-only, so limit = 0 throws ERR_OUT_OF_RANGE, matching Node's lib/v8.js), and exports it.
  • tests/specs/node/v8_set_heap_snapshot_near_heap_limit/ — spec test: a child process allocates until OOM under --v8-flags=--max-old-space-size=20 and a valid, parseable .heapsnapshot is written; plus coverage that --deny-write blocks the snapshot with a permission error and that limit = 0 throws ERR_OUT_OF_RANGE.

Deno's `node:v8` polyfill was missing `setHeapSnapshotNearHeapLimit`, so
calling it was a no-op and there was no way to auto-generate a heap snapshot
right before the process runs out of memory.

Add a `op_v8_set_heap_snapshot_near_heap_limit` op that installs a V8
near-heap-limit callback on the current isolate. When the heap approaches its
limit, the callback streams a `.heapsnapshot` file straight to disk (up to
`limit` times) using the same `Heap.<date>.<time>.<pid>.0.<seq>.heapsnapshot`
naming scheme as `writeHeapSnapshot`, printing the path to stderr like Node.

Mirroring Node's `Environment::NearHeapLimitCallback`, the callback returns a
raised heap limit (current + young-generation headroom) while it still has
snapshots to take so V8 has room to finish writing, and once the budget is
exhausted it removes itself and returns the unchanged limit so V8 proceeds to
OOM normally.

The JS binding follows Node's lib/v8.js (validate the limit, install once) and
is exported from both the CJS return object and `v8_esm.ts`.

Adds a spec test that runs an OOM script (under a tiny old-space limit) as a
child process, verifies it dies from a V8 fatal OOM after emitting the snapshot
line, then asserts a valid `.heapsnapshot` file was written to disk.
@nathanwhitbot

Copy link
Copy Markdown
Contributor Author

Pushed test: wait for serve watcher readiness to address the failing linux-aarch64 integration job. The flaky watcher test now waits for both deno serve to print its listening port and the debug Watching paths line before rewriting the watched file, so the restart event is not missed on slower runners.

Comment thread ext/node/ops/v8.rs
});
// Leak the state so it outlives the isolate (see the struct doc comment).
let data = Box::into_raw(state) as *mut c_void;
scope.add_near_heap_limit_callback(near_heap_limit_snapshot_callback, data);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verify that this can be called multiple times - we use this in deno_core too

@nathanwhitbot

Copy link
Copy Markdown
Contributor Author

Verified — safe to call multiple times, and it coexists with the deno_core near-heap-limit callback.

Multiple calls. The polyfill guards installation with heapSnapshotNearHeapLimitSet (ext/node/polyfills/v8.ts), so op_v8_set_heap_snapshot_near_heap_limit is invoked at most once per isolate — exactly mirroring Node's lib/v8.js, which relies on the same JS-side latch (Node's C++ side has no extra guard either). Empirically, calling v8.setHeapSnapshotNearHeapLimit() four times (limits 1, 2, 3, 1) then OOMing writes exactly one snapshot and then hits the normal V8 fatal OOM — the first call's limit wins, the rest are no-ops.

Coexistence with deno_core. deno_core installs its own near-heap-limit callback for workers with resourceLimits (cli/lib/worker.rs), and its remove path (libs/core/runtime/jsruntime.rs) only ever touches its own monomorphized near_heap_limit_callback::<C> fn pointer, never ours — so neither can remove or clobber the other's registration. V8 keeps callbacks in a stack and invokes only the most-recently-added one, and our callback removes precisely itself (matched by fn pointer, and it's guaranteed to be registered at the moment V8 invokes it) once its snapshot budget is exhausted.

Tested end-to-end in a worker with resourceLimits: { maxOldGenerationSizeMb: 20 } that also calls setHeapSnapshotNearHeapLimit(1): our callback writes the snapshot, removes itself, deno_core's callback then fires and terminates the worker gracefully ("Worker terminated due to reaching memory limit"), and the parent process exits cleanly (0) — no crash, no double-free.

No code change needed.

Comment thread ext/node/ops/v8.rs Outdated
Comment on lines +277 to +284
eprintln!("Failed to write heap snapshot to {}", path.display());
}
}
Err(e) => {
eprintln!(
"Failed to create heap snapshot file {}: {e}",
path.display()
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These should use log::error!

Comment thread ext/node/ops/v8.rs Outdated

match std::fs::File::create(&path) {
Ok(file) => {
eprintln!("Writing heap snapshot to {}", path.display());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this should use log::info! don't have to match node exactly

Comment thread ext/node/ops/v8.rs Outdated
Comment on lines +131 to +141
// --- setHeapSnapshotNearHeapLimit -----------------------------------------
//
// Implements `v8.setHeapSnapshotNearHeapLimit(limit)`. Installs a V8
// near-heap-limit callback that streams a `.heapsnapshot` file to disk (up to
// `limit` times) right before the process would OOM, mirroring Node's
// `Environment::NearHeapLimitCallback` (src/heap_utils.cc).

// State for the near-heap-limit snapshot callback. It is leaked (never freed)
// because it must outlive the isolate: the callback stays installed for the
// isolate/process lifetime and Node's API is one-shot, so leaking a single
// small allocation is acceptable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not super useful, so maybe remove these comments

@bartlomieju bartlomieju changed the title feat(node): implement v8.setHeapSnapshotNearHeapLimit feat(ext/node): implement v8.setHeapSnapshotNearHeapLimit Jul 3, 2026

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few comments, but LGTM overall

Address review feedback: use log::info! for the "Writing heap snapshot
to" message and log::error! for the write/create failure messages
instead of eprintln!.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The implementation looks good to me: the callback safety story holds up (synchronous on the isolate thread, non-owning isolate handle, reentrancy guard matching Node's, unchanged limit returned once the budget is exhausted so the process still OOMs), the install-time permission check writing to the checked path is the right design, and the Node semantics (ERR_OUT_OF_RANGE for 0, repeat calls as a no-op, validation before the no-op check) all match. Test coverage of the happy path, --deny-write, and the zero limit is solid.

Two improvements before merging, left inline: splitting out the unrelated watcher test fix, and the hardcoded thread id in the snapshot filename.

Comment thread tests/integration/watcher_tests.rs Outdated
})
}

async fn wait_for_listening_and_watcher<R>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is an unrelated drive-by: a flake fix for serve_watch_parallel_stops_old_workers (from #35136) that is not mentioned anywhere in the PR description. Please split it into its own PR; it is a good fix on its own, and keeping it separate keeps the history clean if the v8 feature ever needs a revert.

Comment thread ext/node/ops/v8.rs
…mitation

- Remove low-value struct field comments on HeapSnapshotNearHeapLimitState
- Document that the snapshot filename hardcodes thread id 0 (matching
  writeHeapSnapshot), noting the worker-thread filename collision limitation
- Revert the unrelated serve-watcher test readiness fix; it will be sent as
  its own PR
@nathanwhitbot

Copy link
Copy Markdown
Contributor Author

Addressed the review comments in 1e1ed90:

  • v8.rs:141 comments — trimmed the low-value struct field comments (kept the isolate-safety and reentrancy notes, which explain non-obvious behavior).
  • Watcher test fix — reverted tests/integration/watcher_tests.rs back to main; it's out of this PR now and will go in as its own PR.
  • Hardcoded thread id — I went with the documented-limitation option you offered rather than plumbing threadId. The native near-heap-limit callback has no ready access to the worker's threadId, and writeHeapSnapshot in v8.ts already hardcodes 0 the same way, so threading it through here would be an inconsistent partial fix. Added a comment on heap_snapshot_filename spelling out the worker-collision limitation.

@bartlomieju
bartlomieju merged commit da1897d into denoland:main Jul 6, 2026
136 checks passed
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.

useable v8.setHeapSnapshotNearHeapLimit

3 participants