@@ -219,8 +219,11 @@ function createRedirectingNetworkAdapter(getMockUrl: () => string): NetworkAdapt
219219 * Build sandbox code that loads Pi's main module in interactive mode.
220220 * Uses PI_MAIN instead of PI_CLI to avoid undici import issues in-VM.
221221 * API redirect is handled by the networkAdapter at the bridge level.
222- * Uses ESM format (export {}) so V8 uses execute_module() which properly
223- * handles top-level await — CJS execute_script() doesn't await promises.
222+ *
223+ * Uses ESM with dynamic import() but does NOT await main() — main()
224+ * starts the TUI loop which keeps the V8 event loop alive via pending
225+ * async bridge promises (_stdinRead, _scheduleTimer). This matches
226+ * how Pi's cli.js works: it calls main() without await.
224227 */
225228function buildPiInteractiveCode ( ) : string {
226229 const flags = [
@@ -236,10 +239,16 @@ function buildPiInteractiveCode(): string {
236239// Override process.argv for Pi CLI
237240process.argv = ['node', 'pi', ${ flags . map ( ( f ) => JSON . stringify ( f ) ) . join ( ', ' ) } ];
238241
239- // Import main.js and call main() — in interactive mode main() starts
240- // the TUI and stays running until the user exits
242+ // Keepalive timer: prevents the V8 event loop from exiting before main()
243+ // makes its first async bridge call. The TLA promise is V8-native (not
244+ // bridge-tracked), so without a bridge-level pending promise, the sidecar's
245+ // run_event_loop() exits immediately after execute_module() returns.
246+ const _keepalive = setInterval(() => {}, 60000);
247+
248+ // Import main.js and start main() — in interactive mode main() starts
249+ // the TUI and stays running until the user exits.
241250const { main } = await import(${ JSON . stringify ( PI_MAIN ) } );
242- await main(process.argv.slice(2));
251+ main(process.argv.slice(2)).finally(() => clearInterval(_keepalive ));
243252` ;
244253}
245254
0 commit comments