Skip to content

Commit ea3cba6

Browse files
committed
feat(platform): improve JSRuntime performance
1 parent 1cee0db commit ea3cba6

6 files changed

Lines changed: 383 additions & 91 deletions

File tree

benches/js-runtime-perf/scripts/responder.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
// Responder-shaped script: reads `context.body`, does light JSON work, returns
2-
// the standard `{ body, headers, statusCode }` envelope. The body is encoded
3-
// to a `Uint8Array` inside the script so this scenario compiles against both
4-
// the pre-change `execute_script` (no body normalisation) and the post-change
5-
// runtime (where the built-in normalisation is a pass-through for Uint8Array).
2+
// the standard `{ body, headers, statusCode }` envelope. Designed to exercise
3+
// the same JS→Rust conversion path that `wrap_script_with_body_conversion`
4+
// wraps around user-supplied responder scripts.
65
(async () => {
76
const input = JSON.parse(Deno.core.decode(new Uint8Array(context.body)));
87
const response = {
@@ -11,7 +10,7 @@
1110
total: input.items.reduce((acc, item) => acc + item.value, 0),
1211
};
1312
return {
14-
body: Deno.core.encode(JSON.stringify(response)),
13+
body: response,
1514
headers: { 'content-type': 'application/json', 'x-responder': 'perf' },
1615
statusCode: 200,
1716
};

benches/js-runtime-perf/src/scenarios/responder_like.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
//! `responder_like`: reproduces the real responder flow - a user script that
22
//! reads bytes out of `context.body`, does a small amount of JSON work, and
3-
//! returns the Secutils `{ body, headers, statusCode }` envelope. Uses
4-
//! `execute_script_with_body_conversion`, which wraps the script in the
5-
//! runtime and normalises the returned body into a `Uint8Array` in Rust.
3+
//! returns the Secutils `{ body, headers, statusCode }` envelope. Exercises
4+
//! the production `JsRuntime::execute_script`, which wraps the script in an
5+
//! async IIFE and normalises the returned body into a `Uint8Array` in Rust.
66
//!
77
//! This is the scenario most sensitive to Tier 2 #4 (moving the body
88
//! serialisation from JS back into Rust).

src/js_runtime.rs

Lines changed: 182 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,219 @@
11
mod js_runtime_config;
22
mod op_proxy_request;
33
mod script_termination_reason;
4+
mod worker_pool;
45

56
pub use self::{
67
js_runtime_config::JsRuntimeConfig,
78
op_proxy_request::{ProxyState, PublicUrlValidator},
89
};
9-
use crate::js_runtime::script_termination_reason::ScriptTerminationReason;
10+
use crate::js_runtime::{
11+
script_termination_reason::ScriptTerminationReason, worker_pool::ScriptTask,
12+
};
1013
use anyhow::Context;
11-
use deno_core::{PollEventLoopOptions, RuntimeOptions, scope, serde_v8, v8};
14+
use deno_core::{JsRuntimeForSnapshot, PollEventLoopOptions, RuntimeOptions, scope, serde_v8, v8};
1215
use serde::Deserialize;
1316
use std::{
1417
sync::{
15-
Arc,
18+
Arc, OnceLock,
1619
atomic::{AtomicBool, AtomicUsize, Ordering},
1720
},
1821
time::{Duration, Instant},
1922
};
23+
use tokio::sync::oneshot;
2024
use tracing::error;
2125

2226
/// Defines a maximum interval on which script is checked for timeout.
2327
const SCRIPT_TIMEOUT_CHECK_INTERVAL: Duration = Duration::from_secs(2);
2428

2529
deno_core::extension!(secutils_ext, ops = [op_proxy_request::op_proxy_request]);
2630

27-
/// Wraps a user script in an async IIFE that auto-converts the `body` field
28-
/// of the returned object: strings are UTF-8 encoded, objects/arrays are
29-
/// JSON-serialized, and plain number arrays become `Uint8Array` for backward
30-
/// compatibility. `Uint8Array`/`ArrayBuffer` values pass through unchanged.
31-
pub fn wrap_script_with_body_conversion(script: &str) -> String {
31+
/// Cached V8 startup snapshot, built once on the main thread at `init_platform`
32+
/// time and reused by every subsequent `deno_core::JsRuntime::new` call across
33+
/// every worker. `Box::leak` gives us the `&'static [u8]` shape required by
34+
/// `RuntimeOptions::startup_snapshot`; the bytes live for the process lifetime,
35+
/// which is exactly what we want.
36+
static STARTUP_SNAPSHOT: OnceLock<&'static [u8]> = OnceLock::new();
37+
38+
fn build_startup_snapshot() -> &'static [u8] {
39+
// The snapshot intentionally does not bake in `secutils_ext` or any JS
40+
// modules: baking ops into a snapshot requires the snapshotting runtime
41+
// to register the exact same op layout at runtime (a minefield of subtle
42+
// version/feature mismatches). What we capture here is the expensive
43+
// part - the V8 context setup and builtin JS globals. `secutils_ext` is
44+
// still registered at `JsRuntime::new` time per invocation, but on top
45+
// of a warm, pre-initialised context.
46+
let runtime = JsRuntimeForSnapshot::new(RuntimeOptions::default());
47+
let snapshot = runtime.snapshot();
48+
Box::leak(snapshot) as &[u8]
49+
}
50+
51+
fn startup_snapshot() -> &'static [u8] {
52+
STARTUP_SNAPSHOT.get_or_init(build_startup_snapshot)
53+
}
54+
55+
/// Wraps the raw user script in a minimal async IIFE so we can use
56+
/// `runtime.execute_script` + `runtime.resolve` uniformly whether the user
57+
/// returned a promise or a value.
58+
fn wrap_user_script_in_async_iife(script: &str) -> String {
3259
let script = script.trim().trim_end_matches(';');
33-
format!(
34-
r#"(async (globalThis) => {{
35-
const __result = await ({script});
36-
if (__result && __result.body !== undefined && __result.body !== null) {{
37-
const __body = __result.body;
38-
if (__body instanceof Uint8Array || __body instanceof ArrayBuffer || ArrayBuffer.isView(__body)) {{
39-
}} else if (typeof __body === 'string') {{
40-
__result.body = Deno.core.encode(__body);
41-
}} else if (Array.isArray(__body)) {{
42-
if (__body.length === 0 || typeof __body[0] === 'number') {{
43-
__result.body = new Uint8Array(__body);
44-
}} else {{
45-
__result.body = Deno.core.encode(JSON.stringify(__body));
46-
}}
47-
}} else {{
48-
__result.body = Deno.core.encode(JSON.stringify(__body));
49-
}}
50-
}}
51-
return __result;
52-
}})(globalThis);"#
53-
)
60+
format!("(async () => (await ({script})))();")
61+
}
62+
63+
/// Mutates `result` in place so that, if it is an object with a `body`
64+
/// property, the body value becomes a `Uint8Array` of bytes suitable for
65+
/// serde_v8 to deserialise into `Vec<u8>`.
66+
///
67+
/// The conversion rules match what the legacy JS wrapper did:
68+
/// - `Uint8Array` / `ArrayBuffer` / typed-array views: passed through.
69+
/// - `string`: UTF-8 encoded.
70+
/// - numeric arrays (or empty arrays): copied into a `Uint8Array`.
71+
/// - non-numeric arrays and any other objects/primitives: `JSON.stringify`
72+
/// followed by UTF-8 encoding.
73+
///
74+
/// `null`/`undefined` body values are left untouched so `Option<Vec<u8>>`
75+
/// fields can deserialise as `None`.
76+
fn normalize_response_body_in_place(
77+
scope: &mut v8::PinScope<'_, '_>,
78+
result: v8::Local<v8::Value>,
79+
) {
80+
let Ok(obj) = v8::Local::<v8::Object>::try_from(result) else {
81+
return;
82+
};
83+
let Some(body_key) = v8::String::new(scope, "body") else {
84+
return;
85+
};
86+
let body_key_v: v8::Local<v8::Value> = body_key.into();
87+
88+
let Some(body) = obj.get(scope, body_key_v) else {
89+
return;
90+
};
91+
if body.is_null_or_undefined() {
92+
return;
93+
}
94+
if body.is_uint8_array() || body.is_array_buffer() || body.is_array_buffer_view() {
95+
return;
96+
}
97+
98+
let replacement_bytes: Vec<u8> = if body.is_string() {
99+
v8::Local::<v8::String>::try_from(body)
100+
.map(|s| s.to_rust_string_lossy(scope).into_bytes())
101+
.unwrap_or_default()
102+
} else if body.is_array() {
103+
let Ok(arr) = v8::Local::<v8::Array>::try_from(body) else {
104+
return;
105+
};
106+
let len = arr.length();
107+
if len == 0 {
108+
Vec::new()
109+
} else {
110+
// The legacy JS picked the code path from the first element's type:
111+
// numeric first element -> treat every element as a u8 byte;
112+
// anything else -> JSON.stringify the whole array. Preserve that
113+
// exact semantic so behaviour is byte-identical to the old IIFE.
114+
let first = arr
115+
.get_index(scope, 0)
116+
.unwrap_or_else(|| v8::undefined(scope).into());
117+
if first.is_number() {
118+
let mut bytes = Vec::with_capacity(len as usize);
119+
for i in 0..len {
120+
let v = arr
121+
.get_index(scope, i)
122+
.unwrap_or_else(|| v8::undefined(scope).into());
123+
let n = v.number_value(scope).unwrap_or(0.0);
124+
bytes.push(n as u8);
125+
}
126+
bytes
127+
} else {
128+
match v8::json::stringify(scope, body) {
129+
Some(s) => s.to_rust_string_lossy(scope).into_bytes(),
130+
None => return,
131+
}
132+
}
133+
}
134+
} else {
135+
match v8::json::stringify(scope, body) {
136+
Some(s) => s.to_rust_string_lossy(scope).into_bytes(),
137+
None => return,
138+
}
139+
};
140+
141+
let len = replacement_bytes.len();
142+
let backing = v8::ArrayBuffer::new_backing_store_from_vec(replacement_bytes).make_shared();
143+
let buffer = v8::ArrayBuffer::with_backing_store(scope, &backing);
144+
let Some(typed) = v8::Uint8Array::new(scope, buffer, 0, len) else {
145+
return;
146+
};
147+
obj.set(scope, body_key_v, typed.into());
54148
}
55149

56150
/// An abstraction over the V8/Deno runtime that allows any utilities to execute custom user
57-
/// JavaScript scripts. Each invocation runs inside a dedicated `spawn_blocking` task with its own
58-
/// `CurrentThread` tokio runtime so that async Deno ops (e.g. `op_proxy_request`) work correctly.
151+
/// JavaScript scripts. Script executions are dispatched to a process-wide pool of long-lived
152+
/// worker threads (see [`worker_pool`]), each owning its own persistent `CurrentThread` tokio
153+
/// runtime and `LocalSet`. A fresh V8 isolate is still created for every execution to preserve
154+
/// isolation between scripts; what we avoid is rebuilding the surrounding tokio machinery on
155+
/// every call.
59156
pub struct JsRuntime;
60157

61158
impl JsRuntime {
62-
/// Initializes the JS runtime platform, should be called only once and in the main thread.
159+
/// Initializes the JS runtime platform, builds the shared V8 startup
160+
/// snapshot, and eagerly spins up the worker pool. Should be called exactly
161+
/// once, from the main thread, during server startup.
63162
pub fn init_platform() {
64163
deno_core::JsRuntime::init_platform(None);
164+
// Build the snapshot on the main thread before any worker boots so the
165+
// first script execution on each worker does not pay for it. V8 requires
166+
// the snapshotting isolate to run on a single thread, which is why we
167+
// do it here rather than lazily inside a worker.
168+
let _ = startup_snapshot();
169+
worker_pool::init();
65170
}
66171

67-
/// Executes a user script and returns the result. The script runs inside a `spawn_blocking`
68-
/// task with its own `CurrentThread` tokio runtime and V8 isolate, providing full isolation
69-
/// from other concurrent scripts and the main server.
172+
/// Executes a user script and returns the deserialised result.
173+
///
174+
/// The script runs on one of the process-wide worker threads (round-robin scheduled),
175+
/// using that worker's long-lived tokio runtime and a fresh V8 isolate. This provides
176+
/// full isolation between scripts without paying the per-call cost of building a new
177+
/// tokio runtime every time.
178+
///
179+
/// The raw user script is wrapped in a trivial async IIFE so callers can supply either
180+
/// a sync expression or a promise. After the script resolves, the returned object's
181+
/// `body` field (if present) is normalised in place to a `Uint8Array` - see
182+
/// [`normalize_response_body_in_place`] for the exact conversion rules. This keeps the
183+
/// responder code path (`Vec<u8>` bodies deserialised via `serde_bytes`) fast and
184+
/// avoids an async/await round-trip + JS conditional for every invocation.
70185
///
71-
/// `js_script_context` is an optional JSON string that will be parsed by V8's native JSON
72-
/// parser and made available as the global `context` variable.
186+
/// `js_script_context` is an optional JSON string that will be parsed by V8's native
187+
/// JSON parser and made available as the global `context` variable.
73188
pub async fn execute_script<R: for<'de> Deserialize<'de> + Send + 'static>(
74189
config: JsRuntimeConfig,
75190
js_code: String,
76191
js_script_context: Option<String>,
77192
proxy_state: Option<ProxyState>,
78193
) -> Result<(R, Duration), anyhow::Error> {
79-
tokio::task::spawn_blocking(move || {
80-
let rt = tokio::runtime::Builder::new_current_thread()
81-
.enable_all()
82-
.build()
83-
.context("Failed to build CurrentThread tokio runtime for script execution")?;
84-
rt.block_on(async {
85-
Self::execute_script_internal(config, js_code, js_script_context, proxy_state).await
194+
let wrapped = wrap_user_script_in_async_iife(&js_code);
195+
let (tx, rx) = oneshot::channel::<Result<(R, Duration), anyhow::Error>>();
196+
let task = ScriptTask::new(move || {
197+
Box::pin(async move {
198+
let result = Self::execute_script_internal::<R>(
199+
config,
200+
wrapped,
201+
js_script_context,
202+
proxy_state,
203+
)
204+
.await;
205+
// Ignore a dropped receiver: the caller awaiting `rx` either
206+
// got the result or gave up; either way, nothing to do.
207+
let _ = tx.send(result);
86208
})
87-
})
88-
.await
89-
.map_err(|join_err| anyhow::anyhow!("Script execution task panicked: {join_err}"))?
209+
});
210+
211+
worker_pool::global()
212+
.submit(task)
213+
.map_err(|_| anyhow::anyhow!("JS runtime worker pool unavailable"))?;
214+
215+
rx.await
216+
.map_err(|_| anyhow::anyhow!("JS runtime worker dropped the script task"))?
90217
}
91218

92219
async fn execute_script_internal<R: for<'de> Deserialize<'de>>(
@@ -100,6 +227,7 @@ impl JsRuntime {
100227
let mut runtime = deno_core::JsRuntime::new(RuntimeOptions {
101228
create_params: Some(v8::Isolate::create_params().heap_limits(0, config.max_heap_size)),
102229
extensions: vec![secutils_ext::init()],
230+
startup_snapshot: Some(startup_snapshot()),
103231
..Default::default()
104232
});
105233

@@ -224,6 +352,7 @@ impl JsRuntime {
224352
scope!(scope, runtime);
225353

226354
let local = v8::Local::new(scope, script_result);
355+
normalize_response_body_in_place(scope, local);
227356
serde_v8::from_v8(scope, local)
228357
.map(|result| (result, now.elapsed()))
229358
.with_context(|| "Error deserializing script result")
@@ -549,9 +678,13 @@ pub mod tests {
549678
#[serde(rename_all = "camelCase")]
550679
struct ProxyResult {
551680
status_code: u16,
681+
#[serde(with = "serde_bytes")]
552682
body: Vec<u8>,
553683
}
554684

685+
// Script returns `body` as a plain JS number array. The runtime's body
686+
// normalisation converts it to a `Uint8Array` before serde_v8 sees it,
687+
// so `ProxyResult::body` deserialises via `serde_bytes`.
555688
let url = format!("{}/data", mock_server.base_url());
556689
let script = format!(
557690
r#"(async () => {{
@@ -1374,9 +1507,7 @@ pub mod tests {
13741507
#[cfg(feature = "bin-tests")]
13751508
mod body_auto_convert {
13761509
use super::*;
1377-
use crate::{
1378-
js_runtime::wrap_script_with_body_conversion, utils::webhooks::ResponderScriptResult,
1379-
};
1510+
use crate::utils::webhooks::ResponderScriptResult;
13801511

13811512
fn config() -> JsRuntimeConfig {
13821513
JsRuntimeConfig {
@@ -1388,7 +1519,7 @@ pub mod tests {
13881519
async fn run_script(user_script: &str) -> anyhow::Result<ResponderScriptResult> {
13891520
let (result, _) = JsRuntime::execute_script::<ResponderScriptResult>(
13901521
config(),
1391-
wrap_script_with_body_conversion(user_script),
1522+
user_script.to_string(),
13921523
None,
13931524
None,
13941525
)

0 commit comments

Comments
 (0)