Skip to content

Commit 5aea6b9

Browse files
goffrieConvex, Inc.
authored and
Convex, Inc.
committed
Impose a 64MiB ArrayBuffer memory limit per isolate (#37851)
V8's ArrayBuffer allocates outside of the V8 heap and doesn't abide the normal V8 heap limit - instead the default allocator directly calls `malloc`/`calloc`/`free`. This is very sad and means that JS can crash the entire process with an OOM. We can install our own allocator instead that tracks the amount of memory handed out, and put a limit of 64MiB. GitOrigin-RevId: 47f744add45dbda5e588a499877d3c7a3338f393
1 parent 8a0ceef commit 5aea6b9

File tree

12 files changed

+223
-5
lines changed

12 files changed

+223
-5
lines changed

Cargo.lock

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

crates/common/src/knobs.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,10 @@ pub static ISOLATE_MAX_USER_HEAP_SIZE: LazyLock<usize> =
793793
pub static ISOLATE_MAX_HEAP_EXTRA_SIZE: LazyLock<usize> =
794794
LazyLock::new(|| env_config("ISOLATE_MAX_HEAP_EXTRA_SIZE", 1 << 25));
795795

796+
/// Set a separate 64MB limit on ArrayBuffer allocations.
797+
pub static ISOLATE_MAX_ARRAY_BUFFER_TOTAL_SIZE: LazyLock<usize> =
798+
LazyLock::new(|| env_config("ISOLATE_MAX_ARRAY_BUFFER_TOTAL_SIZE", 1 << 26));
799+
796800
/// Chunk sizes: 1, 2, 3, ..., MAX_DYNAMIC_SMART_CHUNK_SIZE incrementing by 1.
797801
/// These chunk sizes allow small (common) batches to be handled in a single
798802
/// chunk, while limiting the size of a chunk (don't overload the db), and

crates/isolate/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ http-body-util = { workspace = true }
4545
humansize = { workspace = true }
4646
itertools = { workspace = true }
4747
keybroker = { path = "../keybroker" }
48+
libc = { workspace = true }
4849
maplit = { workspace = true, optional = true }
4950
metrics = { path = "../metrics" }
5051
mime = { workspace = true }
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
use std::{
2+
alloc::{
3+
alloc,
4+
alloc_zeroed,
5+
dealloc,
6+
Layout,
7+
},
8+
ffi::c_void,
9+
mem,
10+
ptr,
11+
sync::{
12+
atomic::{
13+
AtomicUsize,
14+
Ordering,
15+
},
16+
Arc,
17+
},
18+
};
19+
20+
use deno_core::v8::{
21+
new_rust_allocator,
22+
Allocator,
23+
RustAllocatorVtable,
24+
UniqueRef,
25+
};
26+
27+
pub struct ArrayBufferMemoryLimit {
28+
available: AtomicUsize,
29+
limit: usize,
30+
}
31+
32+
impl ArrayBufferMemoryLimit {
33+
/// Returns the amount of memory used by ArrayBuffers.
34+
/// This can be an overestimate since it includes buffers that would be GC'd
35+
/// but haven't yet.
36+
pub fn used(&self) -> usize {
37+
self.limit
38+
.saturating_sub(self.available.load(Ordering::Relaxed))
39+
}
40+
41+
fn consume(&self, amount: usize) -> bool {
42+
let mut limit = self.available.load(Ordering::Relaxed);
43+
loop {
44+
if limit < amount {
45+
crate::metrics::log_array_buffer_oom();
46+
return false;
47+
}
48+
match self.available.compare_exchange_weak(
49+
limit,
50+
limit - amount,
51+
Ordering::Relaxed,
52+
Ordering::Relaxed,
53+
) {
54+
Ok(_) => return true,
55+
Err(v) => limit = v,
56+
}
57+
}
58+
}
59+
}
60+
61+
const ALIGNMENT: usize = mem::align_of::<libc::max_align_t>();
62+
63+
unsafe extern "C" fn allocate(handle: &ArrayBufferMemoryLimit, len: usize) -> *mut c_void {
64+
if handle.consume(len) {
65+
alloc_zeroed(Layout::from_size_align_unchecked(len, ALIGNMENT)).cast()
66+
} else {
67+
ptr::null_mut()
68+
}
69+
}
70+
unsafe extern "C" fn allocate_uninitialized(
71+
handle: &ArrayBufferMemoryLimit,
72+
len: usize,
73+
) -> *mut c_void {
74+
if handle.consume(len) {
75+
alloc(Layout::from_size_align_unchecked(len, ALIGNMENT)).cast()
76+
} else {
77+
ptr::null_mut()
78+
}
79+
}
80+
unsafe extern "C" fn free(handle: &ArrayBufferMemoryLimit, data: *mut c_void, len: usize) {
81+
handle.available.fetch_add(len, Ordering::Relaxed);
82+
dealloc(
83+
data.cast(),
84+
Layout::from_size_align_unchecked(len, ALIGNMENT),
85+
);
86+
}
87+
unsafe extern "C" fn drop(handle: *const ArrayBufferMemoryLimit) {
88+
Arc::from_raw(handle);
89+
}
90+
91+
pub fn limited_array_buffer_allocator(
92+
limit: usize,
93+
) -> (Arc<ArrayBufferMemoryLimit>, UniqueRef<Allocator>) {
94+
unsafe {
95+
let limit = Arc::new(ArrayBufferMemoryLimit {
96+
available: AtomicUsize::new(limit),
97+
limit,
98+
});
99+
const VTABLE: RustAllocatorVtable<ArrayBufferMemoryLimit> = RustAllocatorVtable {
100+
allocate,
101+
allocate_uninitialized,
102+
free,
103+
drop,
104+
};
105+
let allocator = new_rust_allocator(Arc::into_raw(limit.clone()), &VTABLE);
106+
(limit, allocator)
107+
}
108+
}

crates/isolate/src/execution_scope.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use std::{
1111
use anyhow::{
1212
anyhow,
1313
bail,
14+
Context as _,
1415
};
1516
use async_recursion::async_recursion;
1617
use common::{
@@ -36,6 +37,7 @@ use serde_json::Value as JsonValue;
3637
use value::heap_size::HeapSize;
3738

3839
use crate::{
40+
array_buffer_allocator::ArrayBufferMemoryLimit,
3941
bundled_js::system_udf_file,
4042
environment::{
4143
IsolateEnvironment,
@@ -200,13 +202,18 @@ impl<'a, 'b: 'a, RT: Runtime, E: IsolateEnvironment<RT>> ExecutionScope<'a, 'b,
200202

201203
pub fn record_heap_stats(&mut self) -> anyhow::Result<()> {
202204
let stats = self.get_heap_statistics();
205+
let array_buffer_size = self
206+
.get_slot::<Arc<ArrayBufferMemoryLimit>>()
207+
.context("missing ArrayBufferMemoryLimit?")?
208+
.used();
203209
self.with_state_mut(|state| {
204210
let blobs_heap_size = state.blob_parts.heap_size();
205211
let streams_heap_size = state.streams.heap_size() + state.stream_listeners.heap_size();
206212
state.environment.record_heap_stats(IsolateHeapStats::new(
207213
stats,
208214
blobs_heap_size,
209215
streams_heap_size,
216+
array_buffer_size,
210217
));
211218
})
212219
}

crates/isolate/src/isolate.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use anyhow::Context as _;
1010
use common::{
1111
knobs::{
1212
FUNRUN_INITIAL_PERMIT_TIMEOUT,
13+
ISOLATE_MAX_ARRAY_BUFFER_TOTAL_SIZE,
1314
ISOLATE_MAX_USER_HEAP_SIZE,
1415
},
1516
runtime::Runtime,
@@ -31,6 +32,7 @@ use humansize::{
3132
use value::heap_size::WithHeapSize;
3233

3334
use crate::{
35+
array_buffer_allocator::ArrayBufferMemoryLimit,
3436
concurrency_limiter::ConcurrencyLimiter,
3537
environment::IsolateEnvironment,
3638
helpers::pump_message_loop,
@@ -64,6 +66,7 @@ pub struct Isolate<RT: Runtime> {
6466
// we reclaim after removing the callback.
6567
heap_ctx_ptr: *mut HeapContext,
6668
limiter: ConcurrencyLimiter,
69+
array_buffer_memory_limit: Arc<ArrayBufferMemoryLimit>,
6770

6871
created: tokio::time::Instant,
6972
}
@@ -124,13 +127,15 @@ pub struct IsolateHeapStats {
124127

125128
pub blobs_heap_size: usize,
126129
pub streams_heap_size: usize,
130+
pub array_buffer_size: usize,
127131
}
128132

129133
impl IsolateHeapStats {
130134
pub fn new(
131135
stats: v8::HeapStatistics,
132136
blobs_heap_size: usize,
133137
streams_heap_size: usize,
138+
array_buffer_size: usize,
134139
) -> Self {
135140
Self {
136141
v8_total_heap_size: stats.total_heap_size(),
@@ -142,6 +147,7 @@ impl IsolateHeapStats {
142147
environment_heap_size: 0,
143148
blobs_heap_size,
144149
streams_heap_size,
150+
array_buffer_size,
145151
}
146152
}
147153

@@ -153,7 +159,13 @@ impl IsolateHeapStats {
153159
impl<RT: Runtime> Isolate<RT> {
154160
pub fn new(rt: RT, max_user_timeout: Option<Duration>, limiter: ConcurrencyLimiter) -> Self {
155161
let _timer = create_isolate_timer();
156-
let mut v8_isolate = crate::udf_runtime::create_isolate_with_udf_runtime();
162+
let (array_buffer_memory_limit, array_buffer_allocator) =
163+
crate::array_buffer_allocator::limited_array_buffer_allocator(
164+
*ISOLATE_MAX_ARRAY_BUFFER_TOTAL_SIZE,
165+
);
166+
let mut v8_isolate = crate::udf_runtime::create_isolate_with_udf_runtime(
167+
v8::CreateParams::default().array_buffer_allocator(array_buffer_allocator),
168+
);
157169

158170
// Tells V8 to capture current stack trace when uncaught exception occurs and
159171
// report it to the message listeners. The option is off by default.
@@ -189,6 +201,7 @@ impl<RT: Runtime> Isolate<RT> {
189201
);
190202

191203
assert!(v8_isolate.set_slot(handle.clone()));
204+
assert!(v8_isolate.set_slot(array_buffer_memory_limit.clone()));
192205

193206
Self {
194207
created: rt.monotonic_now(),
@@ -198,6 +211,7 @@ impl<RT: Runtime> Isolate<RT> {
198211
heap_ctx_ptr,
199212
max_user_timeout,
200213
limiter,
214+
array_buffer_memory_limit,
201215
}
202216
}
203217

@@ -235,7 +249,7 @@ impl<RT: Runtime> Isolate<RT> {
235249
// Heap stats for an isolate that has no associated state or environment.
236250
pub fn heap_stats(&mut self) -> IsolateHeapStats {
237251
let stats = self.v8_isolate.get_heap_statistics();
238-
IsolateHeapStats::new(stats, 0, 0)
252+
IsolateHeapStats::new(stats, 0, 0, self.array_buffer_memory_limit.used())
239253
}
240254

241255
pub fn check_isolate_clean(&mut self) -> Result<(), IsolateNotClean> {

crates/isolate/src/isolate2/thread.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ pub struct Thread {
88

99
impl Thread {
1010
pub fn new() -> Self {
11-
let mut isolate = crate::udf_runtime::create_isolate_with_udf_runtime();
11+
let mut isolate =
12+
crate::udf_runtime::create_isolate_with_udf_runtime(v8::CreateParams::default());
1213

1314
// Tells V8 to capture current stack trace when uncaught exception occurs and
1415
// report it to the message listeners. The option is off by default.

crates/isolate/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#![feature(impl_trait_in_assoc_type)]
1010
#![feature(round_char_boundary)]
1111

12+
mod array_buffer_allocator;
1213
pub mod bundled_js;
1314
pub mod client;
1415
mod concurrency_limiter;

crates/isolate/src/metrics.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,14 @@ pub fn log_system_timeout() {
356356
log_counter(&UDF_SYSTEM_TIMEOUT_TOTAL, 1);
357357
}
358358

359+
register_convex_counter!(
360+
ARRAY_BUFFER_OOM_TOTAL,
361+
"Number of times that isolates hit the ArrayBuffer memory limit"
362+
);
363+
pub fn log_array_buffer_oom() {
364+
log_counter(&ARRAY_BUFFER_OOM_TOTAL, 1);
365+
}
366+
359367
register_convex_counter!(
360368
RECREATE_ISOLATE_TOTAL,
361369
"Number of times an isolate is recreated",
@@ -500,6 +508,10 @@ register_convex_gauge!(
500508
ISOLATE_TOTAL_MALLOCED_MEMORY_BYTES,
501509
"Total isolate malloc'd memory across all isolates"
502510
);
511+
register_convex_gauge!(
512+
ISOLATE_TOTAL_ARRAY_BUFFER_MEMORY_BYTES,
513+
"Total isolate ArrayBuffer-allocated memory across all isolates"
514+
);
503515

504516
pub fn log_aggregated_heap_stats(stats: &IsolateHeapStats) {
505517
log_gauge(
@@ -526,6 +538,10 @@ pub fn log_aggregated_heap_stats(stats: &IsolateHeapStats) {
526538
&ISOLATE_TOTAL_MALLOCED_MEMORY_BYTES,
527539
stats.v8_malloced_memory as f64,
528540
);
541+
log_gauge(
542+
&ISOLATE_TOTAL_ARRAY_BUFFER_MEMORY_BYTES,
543+
stats.array_buffer_size as f64,
544+
);
529545
}
530546

531547
register_convex_histogram!(UDF_FETCH_SECONDS, "Duration of UDF fetch", &STATUS_LABEL);

crates/isolate/src/tests/adversarial.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use common::{
1414
ComponentPath,
1515
PublicFunctionPath,
1616
},
17+
knobs::ISOLATE_MAX_ARRAY_BUFFER_TOTAL_SIZE,
1718
log_lines::{
1819
LogLines,
1920
TRUNCATED_LINE_SUFFIX,
@@ -972,3 +973,53 @@ async fn test_subfunction_depth(rt: TestRuntime) -> anyhow::Result<()> {
972973
assert_eq!(result, ConvexValue::Float64(8.0));
973974
Ok(())
974975
}
976+
977+
#[convex_macro::test_runtime]
978+
async fn test_array_buffer_size_limit(rt: TestRuntime) -> anyhow::Result<()> {
979+
let t = UdfTest::default(rt).await?;
980+
// Allocating an ArrayBuffer over the size limit fails
981+
let e = t
982+
.query_js_error(
983+
"adversarial:allocateArrayBuffers",
984+
assert_obj!(
985+
"size" => (*ISOLATE_MAX_ARRAY_BUFFER_TOTAL_SIZE + 1) as f64,
986+
"count" => 1.0,
987+
"retain" => 1.0,
988+
),
989+
)
990+
.await?;
991+
assert_contains(&e, "RangeError: Array buffer allocation failed");
992+
// However, we can repeatedly allocate under the size limit and succeed as
993+
// long as the old ones can be GC'd
994+
t.query(
995+
"adversarial:allocateArrayBuffers",
996+
assert_obj!(
997+
"size" => *ISOLATE_MAX_ARRAY_BUFFER_TOTAL_SIZE as f64,
998+
"count" => 10.0,
999+
"retain" => 0.0,
1000+
),
1001+
)
1002+
.await?;
1003+
t.query(
1004+
"adversarial:allocateArrayBuffers",
1005+
assert_obj!(
1006+
"size" => (*ISOLATE_MAX_ARRAY_BUFFER_TOTAL_SIZE / 2) as f64,
1007+
"count" => 10.0,
1008+
"retain" => 1.0,
1009+
),
1010+
)
1011+
.await?;
1012+
// ... but retaining too many ArrayBuffers will fail
1013+
let e = t
1014+
.query_js_error(
1015+
"adversarial:allocateArrayBuffers",
1016+
assert_obj!(
1017+
"size" => (*ISOLATE_MAX_ARRAY_BUFFER_TOTAL_SIZE / 2) as f64,
1018+
"count" => 10.0,
1019+
"retain" => 2.0,
1020+
),
1021+
)
1022+
.await?;
1023+
assert_contains(&e, "RangeError: Array buffer allocation failed");
1024+
Ok(())
1025+
}

crates/isolate/src/udf_runtime.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@ const INITIAL_HEAP_SIZE: usize = 1 << 16;
4040

4141
/// Creates a new V8 isolate from the saved snapshot. Contexts created in this
4242
/// isolate will have the UDF runtime already loaded.
43-
pub(crate) fn create_isolate_with_udf_runtime() -> v8::OwnedIsolate {
43+
pub(crate) fn create_isolate_with_udf_runtime(create_params: v8::CreateParams) -> v8::OwnedIsolate {
4444
let snapshot = BASE_SNAPSHOT
4545
.get()
4646
.expect("udf_runtime::initialize not called");
4747
v8::Isolate::new(
48-
v8::CreateParams::default()
48+
create_params
4949
.heap_limits(
5050
INITIAL_HEAP_SIZE,
5151
*ISOLATE_MAX_USER_HEAP_SIZE + *ISOLATE_MAX_HEAP_EXTRA_SIZE,

0 commit comments

Comments
 (0)