Skip to content

Commit 497b665

Browse files
committed
fix: prevent V8 OOM crash by returning headroom in near-heap-limit callback
V8 requires the NearHeapLimitCallback to return a limit higher than current_heap_limit, otherwise it goes straight to FatalProcessOutOfMemory and crashes the entire process. The previous code returned the same value, which V8 interpreted as "no room given". Now always returns current_heap_limit + 2MB headroom so V8 can complete the pending allocation and return to JS where terminate_execution() takes effect. This matches Node.js's fix (nodejs/node#41041) and Supabase edge-runtime's low_memory_multiplier approach. Bump 0.13.9 → 0.13.10
1 parent f9776e1 commit 497b665

File tree

3 files changed

+30
-33
lines changed

3 files changed

+30
-33
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ members = ["gc-derive"]
33

44
[package]
55
name = "openworkers-runtime-v8"
6-
version = "0.13.9"
6+
version = "0.13.10"
77
edition = "2024"
88
license = "MIT"
99

src/security/heap_limit.rs

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
//! ## How it works
88
//!
99
//! When V8's heap approaches the configured limit, it calls our callback.
10-
//! The callback:
11-
//! 1. First call: Increases the limit slightly (10%) to give V8 room to GC
12-
//! 2. Subsequent calls: Terminates execution via `isolate.terminate_execution()`
10+
//! The callback calls `terminate_execution()` and returns a slightly increased
11+
//! limit (+2 MB headroom). The headroom is critical: V8 only checks the
12+
//! termination flag when returning to JS execution, not during GC. Without it,
13+
//! V8 sees "no room given" and calls `FatalProcessOutOfMemory` (process crash).
1314
//!
1415
//! This prevents a single misbehaving worker from crashing the entire runner.
1516
//!
@@ -55,11 +56,20 @@ impl HeapLimitState {
5556
}
5657
}
5758

59+
/// Headroom given to V8 after calling `terminate_execution()`.
60+
///
61+
/// V8 only checks the termination flag when returning to JS execution.
62+
/// If we return the same `current_heap_limit`, V8 considers the allocation
63+
/// failed and goes straight to `FatalProcessOutOfMemory` (process crash).
64+
/// By returning a slightly higher limit, V8 can complete the pending
65+
/// allocation, return to JS, and see the termination flag.
66+
const TERMINATION_HEADROOM: usize = 2 * 1024 * 1024; // 2 MB
67+
5868
/// Near-heap-limit callback for V8.
5969
///
6070
/// This callback is invoked when V8's heap is approaching its limit.
61-
/// Returns a new heap limit to allow V8 to continue, or terminates execution
62-
/// if we've already given V8 extra room.
71+
/// On first call, terminates execution and gives V8 a small headroom
72+
/// so it can unwind back to JS where `terminate_execution()` takes effect.
6373
///
6474
/// # Safety
6575
///
@@ -68,50 +78,37 @@ impl HeapLimitState {
6878
pub unsafe extern "C" fn near_heap_limit_callback(
6979
data: *mut c_void,
7080
current_heap_limit: usize,
71-
initial_heap_limit: usize,
81+
_initial_heap_limit: usize,
7282
) -> usize {
7383
// SAFETY: data is a valid pointer to HeapLimitState, passed from install_heap_limit_callback
7484
let state = unsafe { &*(data as *const HeapLimitState) };
7585

7686
let count = state.invocation_count.fetch_add(1, Ordering::SeqCst);
7787

7888
tracing::warn!(
79-
"Near heap limit callback invoked (count: {}, current: {} MB, initial: {} MB, max: {} MB)",
89+
"Near heap limit callback invoked (count: {}, current: {} MB, max: {} MB)",
8090
count + 1,
8191
current_heap_limit / (1024 * 1024),
82-
initial_heap_limit / (1024 * 1024),
8392
state.max_heap_bytes / (1024 * 1024)
8493
);
8594

86-
// First invocation: give V8 a bit more room to try GC
87-
if count == 0 {
88-
// Increase by 10%, but don't exceed max
89-
let extra = current_heap_limit / 10;
90-
let new_limit = (current_heap_limit + extra).min(state.max_heap_bytes);
91-
92-
if new_limit > current_heap_limit {
93-
tracing::warn!(
94-
"Increasing heap limit to {} MB to allow GC",
95-
new_limit / (1024 * 1024)
96-
);
97-
return new_limit;
98-
}
99-
}
100-
101-
// We've already given extra room or can't give more - terminate execution
102-
tracing::error!(
103-
"Heap limit exhausted after {} callbacks, terminating execution",
104-
count + 1
105-
);
106-
10795
// Set the memory limit flag so the runner knows why execution stopped
10896
state.memory_limit_hit.store(true, Ordering::SeqCst);
10997

11098
// Terminate execution gracefully instead of letting V8 crash
11199
state.isolate_handle.terminate_execution();
112100

113-
// Return current limit (V8 will see termination and stop)
114-
current_heap_limit
101+
tracing::error!(
102+
"Heap limit exhausted after {} callbacks, terminating execution",
103+
count + 1
104+
);
105+
106+
// CRITICAL: Always return more than current_heap_limit.
107+
// V8 does not check terminate_execution() during GC — only when returning
108+
// to JS. If we return <= current_heap_limit, V8 sees "no room given" and
109+
// calls FatalProcessOutOfMemory, crashing the entire process.
110+
// The headroom lets V8 complete the pending allocation and unwind to JS.
111+
current_heap_limit + TERMINATION_HEADROOM
115112
}
116113

117114
/// OOM error handler for V8 isolates.

0 commit comments

Comments
 (0)