Skip to content

Commit 18c710f

Browse files
committed
fix: V8 run mode pumps event loop via _waitForActiveHandles, ESM modules stay alive for timers/handles
1 parent c83789d commit 18c710f

4 files changed

Lines changed: 82 additions & 22 deletions

File tree

native/v8-runtime/src/execution.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,7 @@ fn set_pending_module_evaluation(
678678
});
679679
}
680680

681-
fn set_pending_script_evaluation(
681+
pub fn set_pending_script_evaluation(
682682
scope: &mut v8::HandleScope,
683683
promise: v8::Local<v8::Promise>,
684684
) {

native/v8-runtime/src/session.rs

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -545,13 +545,24 @@ fn session_thread(
545545
}
546546

547547
// Run event loop while bridge work or async ESM
548-
// evaluation is still pending.
549-
let event_loop_status =
550-
if pending.len() > 0
551-
|| execution::has_pending_module_evaluation()
552-
|| execution::has_pending_script_evaluation()
553-
|| !deferred_queue.lock().unwrap().is_empty()
554-
{
548+
// evaluation is still pending. For ESM modules (mode != 0),
549+
// always enter the event loop even if no pending promises
550+
// are visible yet — the module body may have registered
551+
// timers, stdin listeners, or child_process handles that
552+
// need event loop pumping to deliver their callbacks.
553+
let should_enter_event_loop =
554+
pending.len() > 0
555+
|| execution::has_pending_module_evaluation()
556+
|| execution::has_pending_script_evaluation()
557+
|| !deferred_queue.lock().unwrap().is_empty()
558+
|| mode != 0; // Always pump for ESM modules
559+
let event_loop_status = if should_enter_event_loop {
560+
eprintln!("[v8-runtime] entering event loop: pending={} module_eval={} script_eval={} deferred={} esm={}",
561+
pending.len(),
562+
execution::has_pending_module_evaluation(),
563+
execution::has_pending_script_evaluation(),
564+
!deferred_queue.lock().unwrap().is_empty(),
565+
mode != 0);
555566
let scope = &mut v8::HandleScope::new(iso);
556567
let ctx = v8::Local::new(scope, &exec_context);
557568
let scope = &mut v8::ContextScope::new(scope, ctx);
@@ -566,7 +577,7 @@ fn session_thread(
566577
EventLoopStatus::Completed
567578
};
568579

569-
let terminated = matches!(event_loop_status, EventLoopStatus::Terminated);
580+
let mut terminated = matches!(event_loop_status, EventLoopStatus::Terminated);
570581
if let EventLoopStatus::Failed(next_code, next_error) = event_loop_status {
571582
code = next_code;
572583
error = Some(next_error);
@@ -587,6 +598,62 @@ fn session_thread(
587598
}
588599
}
589600

601+
// For ESM modules: call _waitForActiveHandles() to keep
602+
// the session alive while handles (timers, child processes,
603+
// stdin listeners) are active. This creates a pending promise
604+
// that the event loop pumps until all handles resolve.
605+
if !terminated && mode != 0 && error.is_none() {
606+
// Phase 1: call _waitForActiveHandles() to register a pending promise
607+
{
608+
let scope = &mut v8::HandleScope::new(iso);
609+
let ctx = v8::Local::new(scope, &exec_context);
610+
let scope = &mut v8::ContextScope::new(scope, ctx);
611+
let global = ctx.global(scope);
612+
let key = v8::String::new(scope, "_waitForActiveHandles").unwrap();
613+
if let Some(func) = global.get(scope, key.into()) {
614+
if func.is_function() {
615+
let func = v8::Local::<v8::Function>::try_from(func).unwrap();
616+
let recv = v8::undefined(scope).into();
617+
if let Some(result) = func.call(scope, recv, &[]) {
618+
if result.is_promise() {
619+
let promise = v8::Local::<v8::Promise>::try_from(result).unwrap();
620+
eprintln!("[v8-runtime] _waitForActiveHandles promise state: {:?}", promise.state());
621+
if promise.state() == v8::PromiseState::Pending {
622+
execution::set_pending_script_evaluation(scope, promise);
623+
}
624+
}
625+
}
626+
}
627+
}
628+
}
629+
630+
// Phase 2: pump event loop for active handles
631+
if pending.len() > 0
632+
|| execution::has_pending_script_evaluation()
633+
|| !deferred_queue.lock().unwrap().is_empty()
634+
{
635+
eprintln!("[v8-runtime] pumping event loop for ESM active handles");
636+
let scope = &mut v8::HandleScope::new(iso);
637+
let ctx = v8::Local::new(scope, &exec_context);
638+
let scope = &mut v8::ContextScope::new(scope, ctx);
639+
let event_loop_status = run_event_loop(
640+
scope,
641+
&rx,
642+
&pending,
643+
maybe_abort_rx.as_ref(),
644+
Some(&deferred_queue),
645+
);
646+
647+
if matches!(event_loop_status, EventLoopStatus::Terminated) {
648+
terminated = true;
649+
}
650+
if let EventLoopStatus::Failed(next_code, next_error) = event_loop_status {
651+
code = next_code;
652+
error = Some(next_error);
653+
}
654+
}
655+
}
656+
590657
if !terminated && mode == 0 && error.is_none() {
591658
let scope = &mut v8::HandleScope::new(iso);
592659
let ctx = v8::Local::new(scope, &exec_context);

packages/nodejs/src/kernel-runtime.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -910,19 +910,10 @@ class NodeRuntimeDriver implements RuntimeDriver {
910910
console.error(`[_resolveEntry] ESM→CJS transform OK: ${scriptPath} (${content.length}${transformed.length})`);
911911
return { code: transformed, filePath: scriptPath };
912912
}
913-
// CJS transform failed (e.g., top-level await). Wrap in a CJS
914-
// async launcher that imports the module and then waits for active
915-
// handles (event loop pump). This runs in exec mode with full
916-
// event loop support via _waitForActiveHandles.
917-
console.error(`[_resolveEntry] ESM async launcher: ${scriptPath} (${content.length} bytes)`);
918-
const launcher = [
919-
`/*__secure_exec_require_esm__*/`,
920-
`(async function() {`,
921-
` try { await __dynamicImport(${JSON.stringify(scriptPath)}, ${JSON.stringify(scriptPath)}); }`,
922-
` catch(e) { process.stderr.write(String(e.stack || e.message) + "\\n"); process.exit(1); }`,
923-
`})();`,
924-
].join("\n");
925-
return { code: launcher, filePath: scriptPath };
913+
// CJS transform failed (e.g., top-level await). Fall through to
914+
// V8 native ESM "run" mode. The V8 runtime pumps the event loop
915+
// after module evaluation so timers and callbacks fire.
916+
console.error(`[_resolveEntry] ESM→CJS failed, using V8 ESM run mode: ${scriptPath}`);
926917
}
927918
return { code: content, filePath: scriptPath };
928919
} catch {

packages/v8/src/runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ export async function createV8Runtime(
113113
stdio: ["ignore", "pipe", "pipe"],
114114
env: childEnv,
115115
});
116+
// Forward V8 runtime stderr to host stderr for debugging
117+
child.stderr?.pipe(process.stderr);
116118

117119
// Track whether the process is alive
118120
let processAlive = true;

0 commit comments

Comments
 (0)