Skip to content

Commit ad6ab04

Browse files
committed
chore: US-028 fix V8 SIGSEGV with Intl.Segmenter polyfill + module cache preservation
Root cause: V8's native Intl.Segmenter (ICU JSSegments::Create) crashes with SIGSEGV during perform_microtask_checkpoint() when processing TUI render cycles from Pi interactive mode (~1600 modules loaded). Fix: - Add Intl.Segmenter JS polyfill to bridge setupGlobals() covering grapheme/word/sentence granularity (bypasses native ICU crash) - Add inline Segmenter polyfill in pi-interactive.test.ts for snapshot-restored contexts - Preserve MODULE_RESOLVE_STATE module cache across event loop (execute_module no longer clears on success path) - Add update_bridge_ctx() to update bridge pointer without losing cache - Set V8 --stack-size=16384 for deep microtask chains - Support SECURE_EXEC_V8_JITLESS=1 env var for debugging Result: Pi TUI renders, input works, Ctrl+C works, PTY resize works (4/9 tests pass). Remaining: LLM streaming response and clean exit.
1 parent d95b3b6 commit ad6ab04

6 files changed

Lines changed: 143 additions & 7 deletions

File tree

native/v8-runtime/src/execution.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,17 @@ fn clear_module_state() {
586586
});
587587
}
588588

589+
/// Update the bridge_ctx pointer in module resolve state without clearing the
590+
/// module cache. Used to preserve compiled modules across the event loop while
591+
/// updating the bridge context for the new session.
592+
pub(crate) fn update_bridge_ctx(bridge_ctx: *const crate::host_call::BridgeCallContext) {
593+
MODULE_RESOLVE_STATE.with(|cell| {
594+
if let Some(state) = cell.borrow_mut().as_mut() {
595+
state.bridge_ctx = bridge_ctx;
596+
}
597+
});
598+
}
599+
589600
/// Register the dynamic import callback on the isolate.
590601
/// Must be called after isolate creation (not captured in snapshots).
591602
pub fn enable_dynamic_import(isolate: &mut v8::OwnedIsolate) {
@@ -1045,7 +1056,10 @@ pub fn execute_module(
10451056
}
10461057
};
10471058

1048-
clear_module_state();
1059+
// NOTE: Do NOT clear module state on success path.
1060+
// The event loop re-uses the module cache for dynamic import()
1061+
// in timer callbacks. The session clears it after the event loop ends.
1062+
// Error paths above still clear on failure.
10491063
(0, Some(exports_bytes), None)
10501064
}
10511065
}

native/v8-runtime/src/isolate.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ pub fn init_v8_platform() {
1010
V8_INIT.call_once(|| {
1111
let platform = v8::new_default_platform(0, false).make_shared();
1212
v8::V8::initialize_platform(platform);
13+
// Set V8 flags before initialization.
14+
// Increase V8's internal stack limit to match the 32 MiB thread stack.
15+
// Default V8 stack limit is ~1 MB which is insufficient for deep
16+
// microtask chains from TUI frameworks (Ink/React).
17+
v8::V8::set_flags_from_string("--stack-size=16384");
18+
if std::env::var("SECURE_EXEC_V8_JITLESS").is_ok() {
19+
v8::V8::set_flags_from_string("--jitless");
20+
}
1321
v8::V8::initialize();
1422
});
1523
}

native/v8-runtime/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ mod timeout;
1313

1414
use std::collections::HashMap;
1515
use std::fs;
16+
1617
use std::io::{self, Read, Write};
1718
use std::os::unix::fs::DirBuilderExt;
1819
use std::os::unix::io::{AsRawFd, RawFd};

native/v8-runtime/src/session.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ impl SessionManager {
119119
};
120120
let join_handle = thread::Builder::new()
121121
.name(format!("session-{}", name_prefix))
122-
.stack_size(32 * 1024 * 1024) // 32 MiB — V8 with large module graphs needs extra stack
122+
.stack_size(32 * 1024 * 1024) // 32 MiB — V8 microtask checkpoints with large module graphs need extra stack
123123
.spawn(move || {
124124
session_thread(
125125
heap_limit_mb,
@@ -512,12 +512,19 @@ fn session_thread(
512512
)
513513
};
514514

515-
// Re-initialize module resolve state for the event loop.
516-
// execute_script/execute_module clear MODULE_RESOLVE_STATE
517-
// on return, but dynamic import() calls during the event loop
518-
// (e.g. from timer callbacks) need it to resolve modules.
515+
// Update module resolve state for the event loop.
516+
// execute_module preserves the module cache (names + compiled
517+
// modules) on success so the event loop can reuse them for
518+
// dynamic import() in timer callbacks. We update the bridge_ctx
519+
// pointer (it points to the stack-local bridge_ctx which is still
520+
// valid). For execute_script (CJS), state was cleared on return,
521+
// so we initialize fresh if needed.
519522
execution::MODULE_RESOLVE_STATE.with(|cell| {
520-
if cell.borrow().is_none() {
523+
if cell.borrow().is_some() {
524+
// Preserve module cache, just update bridge pointer
525+
execution::update_bridge_ctx(&bridge_ctx as *const _);
526+
} else {
527+
// CJS path or error path — initialize fresh
521528
*cell.borrow_mut() = Some(execution::ModuleResolveState {
522529
bridge_ctx: &bridge_ctx as *const _,
523530
module_names: std::collections::HashMap::new(),
@@ -527,6 +534,12 @@ fn session_thread(
527534
});
528535

529536
// Run event loop if there are pending async promises
537+
// Keep auto microtask policy during event loop.
538+
// The SIGSEGV that previously occurred during auto microtask
539+
// processing in resolver.resolve() was caused by V8's native
540+
// Intl.Segmenter crashing (JSSegments::Create NULL deref in ICU).
541+
// With Intl.Segmenter polyfilled in JS, auto policy works correctly.
542+
530543
let mut terminated = if pending.len() > 0 {
531544
let scope = &mut v8::HandleScope::new(iso);
532545
let ctx = v8::Local::new(scope, &exec_context);
@@ -576,6 +589,7 @@ fn session_thread(
576589
}
577590
}
578591

592+
579593
// Clear module resolve state after event loop completes
580594
execution::MODULE_RESOLVE_STATE.with(|cell| {
581595
*cell.borrow_mut() = None;

packages/nodejs/src/bridge/process.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,4 +1402,67 @@ export function setupGlobals(): void {
14021402
cryptoObj.randomUUID = cryptoPolyfill.randomUUID;
14031403
}
14041404
}
1405+
1406+
// Intl.Segmenter — V8 sidecar's native ICU Segmenter crashes (SIGSEGV in
1407+
// JSSegments::Create) when called after loading large module graphs. Polyfill
1408+
// with a JS implementation that covers grapheme/word/sentence granularity.
1409+
if (typeof Intl !== "undefined") {
1410+
const IntlObj = Intl as Record<string, unknown>;
1411+
function SegmenterPolyfill(
1412+
this: { _gran: string },
1413+
_locale?: string,
1414+
options?: { granularity?: string },
1415+
): void {
1416+
this._gran = (options && options.granularity) || "grapheme";
1417+
}
1418+
SegmenterPolyfill.prototype.segment = function (
1419+
this: { _gran: string },
1420+
input: unknown,
1421+
) {
1422+
const str = String(input);
1423+
const gran = this._gran;
1424+
const result: Array<Record<string, unknown>> = [];
1425+
if (gran === "grapheme") {
1426+
let idx = 0;
1427+
for (const ch of str) {
1428+
result.push({ segment: ch, index: idx, input: str });
1429+
idx += ch.length;
1430+
}
1431+
} else if (gran === "word") {
1432+
const re = /[\w]+|[^\w]+/g;
1433+
let m;
1434+
while ((m = re.exec(str)) !== null) {
1435+
result.push({
1436+
segment: m[0],
1437+
index: m.index,
1438+
input: str,
1439+
isWordLike: /[a-zA-Z0-9]/.test(m[0]),
1440+
});
1441+
}
1442+
} else {
1443+
result.push({ segment: str, index: 0, input: str });
1444+
}
1445+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1446+
const res = result as any;
1447+
res.containing = (idx: number) =>
1448+
result.find(
1449+
(s) =>
1450+
idx >= (s.index as number) &&
1451+
idx < (s.index as number) + (s.segment as string).length,
1452+
);
1453+
res[Symbol.iterator] = function* () {
1454+
yield* result;
1455+
};
1456+
return res;
1457+
};
1458+
SegmenterPolyfill.prototype.resolvedOptions = function (this: {
1459+
_gran: string;
1460+
}) {
1461+
return { locale: "en", granularity: this._gran };
1462+
};
1463+
SegmenterPolyfill.supportedLocalesOf = function () {
1464+
return ["en"];
1465+
};
1466+
IntlObj.Segmenter = SegmenterPolyfill;
1467+
}
14051468
}

packages/secure-exec/tests/cli-tools/pi-interactive.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,42 @@ function buildPiInteractiveCode(): string {
236236

237237
return `export {};
238238
239+
// Polyfill Intl.Segmenter — V8 sidecar's native ICU Segmenter crashes
240+
// (SIGSEGV in JSSegments::Create) with large module graphs. The bridge
241+
// polyfill covers fresh isolates, but snapshot-restored contexts need
242+
// this re-application since the snapshot was built without the polyfill.
243+
if (typeof Intl !== 'undefined') {
244+
function SegmenterPolyfill(locale, options) {
245+
this._gran = (options && options.granularity) || 'grapheme';
246+
}
247+
SegmenterPolyfill.prototype.segment = function(input) {
248+
var str = String(input);
249+
var gran = this._gran;
250+
var result = [];
251+
if (gran === 'grapheme') {
252+
var idx = 0;
253+
for (var ch of str) {
254+
result.push({ segment: ch, index: idx, input: str });
255+
idx += ch.length;
256+
}
257+
} else if (gran === 'word') {
258+
var re = /[\\w]+|[^\\w]+/g;
259+
var m;
260+
while ((m = re.exec(str)) !== null) {
261+
result.push({ segment: m[0], index: m.index, input: str, isWordLike: /[a-zA-Z0-9]/.test(m[0]) });
262+
}
263+
} else {
264+
result.push({ segment: str, index: 0, input: str });
265+
}
266+
result.containing = function(idx) { return result.find(function(s) { return idx >= s.index && idx < s.index + s.segment.length; }); };
267+
result[Symbol.iterator] = function() { var i = 0; return { next: function() { return i < result.length ? { value: result[i++], done: false } : { done: true }; } }; };
268+
return result;
269+
};
270+
SegmenterPolyfill.prototype.resolvedOptions = function() { return { locale: 'en', granularity: this._gran }; };
271+
SegmenterPolyfill.supportedLocalesOf = function() { return ['en']; };
272+
Intl.Segmenter = SegmenterPolyfill;
273+
}
274+
239275
// Override process.argv for Pi CLI
240276
process.argv = ['node', 'pi', ${flags.map((f) => JSON.stringify(f)).join(', ')}];
241277

0 commit comments

Comments
 (0)