Good news: CL 7110745 is working correctly (all 31 bots passed). The issue is that V8's TerminateExecution() can only interrupt JavaScript at yield points, and the current sitegeist wrapper doesn't create them.
When you call chrome.userScripts.terminate(), it invokes v8::Isolate::TerminateExecution(). However, this only works when JavaScript yields control back to the event loop.
Your test case:
await new Promise(resolve => setTimeout(resolve, 5000));
alert('still executes');What happens:
setTimeout()is called → JS yields controlterminate()is called → termination flag is set- 5 seconds later: timeout callback fires in a new execution context
- The termination flag doesn't carry over →
alert()runs anyway
Your demo at https://github.com/hjanuschka/script_cancel_demo works because it includes explicit macrotask yields:
// This pattern allows termination:
await new Promise(resolve => setTimeout(resolve, 0));This creates a macrotask boundary where V8 can check the termination flag and interrupt.
Find where you build the wrapper code (likely buildWrapperCode() or similar in runtime-providers.ts). Add:
function buildWrapperCode(userCode, sandboxId, args) {
return `
(async function() {
// ========== ADD THIS: Yield helper ==========
const yieldControl = () => new Promise(resolve => setTimeout(resolve, 0));
globalThis.__sitegeist_yield = yieldControl;
// Setup sandbox environment...
// (your existing sandbox setup code)
// ========== ADD THIS: Initial yield ==========
await yieldControl(); // Ensures at least one yield point exists
// Execute user code
const userFunc = ${userCode};
const result = await userFunc(...${JSON.stringify(args)});
return { success: true, lastValue: result };
})();
`;
}In your handleMessage() or wherever you call chrome.userScripts.execute():
const EXECUTION_TIMEOUT = 30000; // 30 seconds
let timeoutId;
if (executionId) {
// Force-terminate after timeout
timeoutId = setTimeout(async () => {
console.warn(`[BrowserJsRuntimeProvider] Force-terminating ${executionId} after timeout`);
try {
await chrome.userScripts.terminate(tab.id, executionId);
} catch (e) {
console.error('Timeout termination failed:', e);
}
}, EXECUTION_TIMEOUT);
}
try {
const results = await chrome.userScripts.execute(injectionConfig);
if (timeoutId) clearTimeout(timeoutId);
// ... handle results
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
// ... handle error
}Add to your browserjs tool description so the AI knows to use yields:
const BROWSERJS_DESCRIPTION = `
Browser JavaScript execution tool.
IMPORTANT: For long-running operations to be cancellable, include periodic yields:
await __sitegeist_yield();
Example:
browserjs(async () => {
for (let i = 0; i < 1000000; i++) {
// Do work...
if (i % 100 === 0) await __sitegeist_yield(); // Allows cancellation
}
});
`;The key pattern is:
await new Promise(resolve => setTimeout(resolve, 0));This creates a macrotask that:
- Pauses current execution
- Returns control to browser event loop
- Allows V8 to process termination request
- Either resumes execution OR interrupts if termination is pending
Test these scenarios:
Should NOW terminate:
browserjs(async () => {
await new Promise(r => setTimeout(r, 5000));
alert('This should NOT appear if you abort during sleep');
});Should still timeout (pure CPU):
browserjs(() => {
// No yields = can't terminate, will timeout at 30s
let sum = 0;
for (let i = 0; i < 1e10; i++) sum += i;
return sum;
});Should terminate (with manual yields):
browserjs(async () => {
let sum = 0;
for (let i = 0; i < 1e10; i++) {
sum += i;
if (i % 1000 === 0) await __sitegeist_yield();
}
return sum;
});Just so you know the limitations:
- ❌ Pure sync loops without yields
- ❌ Microtask-only async (pure
Promise.resolve().then()chains) - ❌ Native code execution (e.g., inside
JSON.parse()) - ✅ Async operations with setTimeout/macrotask boundaries
- ✅ Sync code with explicit yields
Three changes needed:
- Inject
__sitegeist_yield()helper + initial yield in wrapper - Add 30s timeout protection
- Update AI prompt to recommend yields
This gives you the same cancellation behavior as your working demo.
When v8::Isolate::TerminateExecution() is called:
- V8 sets an internal termination flag on the isolate
- At certain check points, V8 examines this flag
- If set, it throws a special uncatchable
TerminateExecutionexception
- Between JavaScript statements in the interpreter
- At function call boundaries
- At loop back-edges (but only periodically)
- At
awaitboundaries (macrotasks) - NOT at microtask boundaries (Promise.resolve().then())
await new Promise(resolve => setTimeout(resolve, 0));This schedules a macrotask, which:
- Exits current execution context
- Goes through browser's event loop
- Re-enters V8 with a fresh execution context
- V8 checks termination flag before resuming
Compare to:
await Promise.resolve(); // Microtask - may not check termination flagCL 7110745 at extensions/renderer/script_injection_manager.cc:599:
isolate->TerminateExecution();This is correct - it's the standard V8 API. The limitation is inherent to V8's design, not a bug in the CL.
- Working demo: https://github.com/hjanuschka/script_cancel_demo
- CL 7110745: https://chromium-review.googlesource.com/c/chromium/src/+/7110745
- Chromium Bug: b/457135430
- V8 Documentation: https://v8.dev/docs/embed