@@ -11,13 +11,15 @@ pub use self::{
1111} ;
1212use crate :: { config:: JsRuntimeConfig , js_runtime:: script:: ScriptDefinition } ;
1313use anyhow:: { Context , anyhow} ;
14- use deno_core:: { Extension , RuntimeOptions , scope, serde_v8, v8} ;
14+ use deno_core:: { Extension , JsRuntimeForSnapshot , RuntimeOptions , scope, serde_v8, v8} ;
1515use serde:: { Serialize , de:: DeserializeOwned } ;
1616use std:: {
17+ num:: NonZeroUsize ,
1718 sync:: {
18- Arc ,
19+ Arc , OnceLock ,
1920 atomic:: { AtomicUsize , Ordering } ,
2021 } ,
22+ thread,
2123 time:: { Duration , Instant } ,
2224} ;
2325use tokio:: {
@@ -43,47 +45,106 @@ const SCRIPT_EXCLUDED_OPS: [&str; 6] = [
4345 "op_eval_context" ,
4446] ;
4547
48+ /// Cached V8 startup snapshot, built once per process in `init_platform`. The
49+ /// snapshot is deliberately empty - it captures the default V8 context state
50+ /// *before* any extensions are registered. Per-worker `deno_core::JsRuntime`
51+ /// instances still get their own `retrack_ext` extension, so behaviour is
52+ /// unchanged; the snapshot exists purely to amortise context bootstrap cost
53+ /// across every script execution in the process.
54+ static STARTUP_SNAPSHOT : OnceLock < & ' static [ u8 ] > = OnceLock :: new ( ) ;
55+
56+ /// Build a blank V8 startup snapshot once and cache the resulting byte slice
57+ /// in a `OnceLock`. The bytes are intentionally leaked so the slice can live
58+ /// for the process lifetime with a `'static` lifetime - V8 requires snapshot
59+ /// blobs to outlive the isolates they're attached to.
60+ fn startup_snapshot ( ) -> & ' static [ u8 ] {
61+ STARTUP_SNAPSHOT . get_or_init ( || {
62+ let runtime = JsRuntimeForSnapshot :: new ( RuntimeOptions :: default ( ) ) ;
63+ let snapshot = runtime. snapshot ( ) ;
64+ Box :: leak ( snapshot. into_vec ( ) . into_boxed_slice ( ) )
65+ } )
66+ }
67+
68+ /// Pick a sensible worker count. We clamp between 2 and 8 so that small boxes
69+ /// still get parallelism and large ones don't pay for per-worker V8 overhead
70+ /// we can't actually saturate. The previous implementation always used 1.
71+ fn default_worker_count ( ) -> usize {
72+ thread:: available_parallelism ( )
73+ . map ( NonZeroUsize :: get)
74+ . unwrap_or ( 4 )
75+ . clamp ( 2 , 8 )
76+ }
77+
4678/// An abstraction over the V8/Deno runtime that allows any utilities to execute custom user
4779/// JavaScript scripts.
4880pub struct JsRuntime {
49- tx : mpsc:: Sender < ScriptTask > ,
81+ /// Per-worker senders. Incoming work is dispatched round-robin across
82+ /// these, giving us up to `workers.len()` concurrent script executions
83+ /// without any additional synchronisation on the caller side.
84+ workers : Vec < mpsc:: Sender < ScriptTask > > ,
85+ next : AtomicUsize ,
5086}
5187
5288impl JsRuntime {
5389 /// Initializes the JS runtime platform, should be called only once and in the main thread.
5490 pub fn init_platform ( config : & JsRuntimeConfig ) -> anyhow:: Result < Self > {
5591 deno_core:: JsRuntime :: init_platform ( None ) ;
5692
57- // JsRuntime will be initialized in the dedicated thread.
58- let ( tx, mut rx) = mpsc:: channel :: < ScriptTask > ( config. channel_buffer_size ) ;
93+ // Build the shared V8 snapshot on the main thread before any worker is
94+ // spawned, so every worker can cheaply clone the pointer later.
95+ let _ = startup_snapshot ( ) ;
96+
97+ let worker_count = default_worker_count ( ) ;
98+ let mut workers = Vec :: with_capacity ( worker_count) ;
99+ for idx in 0 ..worker_count {
100+ workers. push ( Self :: spawn_worker ( idx, config. channel_buffer_size ) ?) ;
101+ }
102+
103+ Ok ( Self {
104+ workers,
105+ next : AtomicUsize :: new ( 0 ) ,
106+ } )
107+ }
108+
109+ /// Spawn a single long-lived worker thread that owns a `CurrentThread`
110+ /// tokio runtime + `LocalSet` and drains `ScriptTask`s from its channel.
111+ /// Returns the sender half for dispatching work to this worker.
112+ fn spawn_worker (
113+ index : usize ,
114+ channel_buffer : usize ,
115+ ) -> anyhow:: Result < mpsc:: Sender < ScriptTask > > {
116+ let ( tx, mut rx) = mpsc:: channel :: < ScriptTask > ( channel_buffer) ;
59117 let rt = Builder :: new_current_thread ( )
60118 . enable_all ( )
61119 . build ( )
62- . context ( "Unable to initialize JS runtime worker thread." ) ?;
63- std:: thread:: spawn ( move || {
64- let local = LocalSet :: new ( ) ;
65- local. spawn_local ( async move {
66- while let Some ( task) = rx. recv ( ) . await {
67- match task. script {
68- Script :: ApiTargetConfigurator ( def) => {
69- JsRuntime :: handle_script ( & task. config , def) . await ;
70- }
71- Script :: ApiTargetExtractor ( def) => {
72- JsRuntime :: handle_script ( & task. config , def) . await ;
73- }
74- Script :: ActionFormatter ( def) => {
75- JsRuntime :: handle_script ( & task. config , def) . await ;
76- }
77- Script :: Custom ( def) => {
78- JsRuntime :: handle_script ( & task. config , def) . await ;
120+ . context ( "Unable to initialize JS runtime worker tokio runtime" ) ?;
121+ let name = format ! ( "retrack-js-worker-{index}" ) ;
122+ thread:: Builder :: new ( )
123+ . name ( name)
124+ . spawn ( move || {
125+ let local = LocalSet :: new ( ) ;
126+ local. spawn_local ( async move {
127+ while let Some ( task) = rx. recv ( ) . await {
128+ match task. script {
129+ Script :: ApiTargetConfigurator ( def) => {
130+ JsRuntime :: handle_script ( & task. config , def) . await ;
131+ }
132+ Script :: ApiTargetExtractor ( def) => {
133+ JsRuntime :: handle_script ( & task. config , def) . await ;
134+ }
135+ Script :: ActionFormatter ( def) => {
136+ JsRuntime :: handle_script ( & task. config , def) . await ;
137+ }
138+ Script :: Custom ( def) => {
139+ JsRuntime :: handle_script ( & task. config , def) . await ;
140+ }
79141 }
80142 }
81- }
82- } ) ;
83- rt. block_on ( local) ;
84- } ) ;
85-
86- Ok ( Self { tx } )
143+ } ) ;
144+ rt. block_on ( local) ;
145+ } )
146+ . context ( "Failed to spawn JS runtime worker thread" ) ?;
147+ Ok ( tx)
87148 }
88149
89150 /// Executes a user script and returns the result.
@@ -95,7 +156,8 @@ impl JsRuntime {
95156 ) -> Result < Option < ScriptResult > , anyhow:: Error > {
96157 let ( script_result_tx, script_result_rx) = oneshot:: channel ( ) ;
97158
98- self . tx
159+ let slot = self . next . fetch_add ( 1 , Ordering :: Relaxed ) % self . workers . len ( ) ;
160+ self . workers [ slot]
99161 . send ( ScriptTask {
100162 config : script_config,
101163 script : script_args. build ( script_src, script_result_tx) . 0 ,
@@ -128,6 +190,7 @@ impl JsRuntime {
128190 create_params : Some (
129191 v8:: Isolate :: create_params ( ) . heap_limits ( 1_048_576 , config. max_heap_size ) ,
130192 ) ,
193+ startup_snapshot : Some ( startup_snapshot ( ) ) ,
131194 // Disable certain built-in operations.
132195 extensions : vec ! [ Extension {
133196 name: "retrack_ext" ,
@@ -933,7 +996,6 @@ pub mod tests {
933996 "Deno.core.writeSync()",
934997 "Deno.core.writeTypeError()",
935998 "Error()",
936- "ErrorStackTraceLimit=10",
937999 "EvalError()",
9381000 "FinalizationRegistry()",
9391001 "Float32Array()",
0 commit comments