Skip to content

Instantly share code, notes, and snippets.

@hjanuschka
Created November 16, 2025 17:16
Show Gist options
  • Select an option

  • Save hjanuschka/80d1e6a8b8e9fabfb702522f76857561 to your computer and use it in GitHub Desktop.

Select an option

Save hjanuschka/80d1e6a8b8e9fabfb702522f76857561 to your computer and use it in GitHub Desktop.
Fix for BrowserJS Abort Issue - V8 Termination Needs Yield Points

How to Fix BrowserJS Abort Issue - V8 Termination Needs Yield Points

Summary

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.

The Problem

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:

  1. setTimeout() is called → JS yields control
  2. terminate() is called → termination flag is set
  3. 5 seconds later: timeout callback fires in a new execution context
  4. The termination flag doesn't carry over → alert() runs anyway

The Solution (From Working Demo)

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.

Required Changes to Sitegeist

1. Inject Yield Helper in Wrapper Code

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 };
    })();
  `;
}

2. Add Timeout Protection

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
}

3. Update AI System Prompt

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
    }
  });
`;

Why This Works

The key pattern is:

await new Promise(resolve => setTimeout(resolve, 0));

This creates a macrotask that:

  1. Pauses current execution
  2. Returns control to browser event loop
  3. Allows V8 to process termination request
  4. Either resumes execution OR interrupts if termination is pending

Testing

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;
});

What V8 Termination CAN'T Do

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

Summary

Three changes needed:

  1. Inject __sitegeist_yield() helper + initial yield in wrapper
  2. Add 30s timeout protection
  3. Update AI prompt to recommend yields

This gives you the same cancellation behavior as your working demo.

Technical Deep Dive

How V8 Termination Works

When v8::Isolate::TerminateExecution() is called:

  1. V8 sets an internal termination flag on the isolate
  2. At certain check points, V8 examines this flag
  3. If set, it throws a special uncatchable TerminateExecution exception

Where Are These Check Points?

  • Between JavaScript statements in the interpreter
  • At function call boundaries
  • At loop back-edges (but only periodically)
  • At await boundaries (macrotasks)
  • NOT at microtask boundaries (Promise.resolve().then())

Why setTimeout(0) Works

await new Promise(resolve => setTimeout(resolve, 0));

This schedules a macrotask, which:

  1. Exits current execution context
  2. Goes through browser's event loop
  3. Re-enters V8 with a fresh execution context
  4. V8 checks termination flag before resuming

Compare to:

await Promise.resolve(); // Microtask - may not check termination flag

The CL Implementation

CL 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.

Related Resources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment