feat(ext/node): implement v8.setHeapSnapshotNearHeapLimit#35694
Conversation
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.
|
Pushed |
| }); | ||
| // 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); |
There was a problem hiding this comment.
Verify that this can be called multiple times - we use this in deno_core too
|
Verified — safe to call multiple times, and it coexists with the deno_core near-heap-limit callback. Multiple calls. The polyfill guards installation with Coexistence with deno_core. deno_core installs its own near-heap-limit callback for workers with Tested end-to-end in a worker with No code change needed. |
| eprintln!("Failed to write heap snapshot to {}", path.display()); | ||
| } | ||
| } | ||
| Err(e) => { | ||
| eprintln!( | ||
| "Failed to create heap snapshot file {}: {e}", | ||
| path.display() | ||
| ); |
There was a problem hiding this comment.
These should use log::error!
|
|
||
| match std::fs::File::create(&path) { | ||
| Ok(file) => { | ||
| eprintln!("Writing heap snapshot to {}", path.display()); |
There was a problem hiding this comment.
this should use log::info! don't have to match node exactly
| // --- 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. |
There was a problem hiding this comment.
Not super useful, so maybe remove these comments
bartlomieju
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
| }) | ||
| } | ||
|
|
||
| async fn wait_for_listening_and_watcher<R>( |
There was a problem hiding this comment.
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.
…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
|
Addressed the review comments in 1e1ed90:
|
Fixes #35685.
v8.setHeapSnapshotNearHeapLimit(limit)was effectively a no-op in Deno — the function did not exist in thenode:v8polyfill, 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
.heapsnapshotfile to disk (up tolimittimes) right before the process would OOM — mirroring Node'sEnvironment::NearHeapLimitCallback(src/heap_utils.cc).Implementation
ext/node/ops/v8.rs— newop_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 anextern "C"near-heap-limit callback viaadd_near_heap_limit_callback. The callback:current + max_young_gen_size).take_heap_snapshotchunks straight to aBufWriter— no in-memory buffering, so it doesn't OOM the very process it's snapshotting.Heap.<YYYYMMDD>.<HHMMSS>.<pid>.0.<seq>.heapsnapshot(matchingwriteHeapSnapshot's naming) into the cwd and printsWriting heap snapshot to <path>to stderr.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— addssetHeapSnapshotNearHeapLimit(limit)with install-once semantics andvalidateUint32(limit, "limit", true)(positive-only, solimit = 0throwsERR_OUT_OF_RANGE, matching Node'slib/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=20and a valid, parseable.heapsnapshotis written; plus coverage that--deny-writeblocks the snapshot with a permission error and thatlimit = 0throwsERR_OUT_OF_RANGE.