The chrome.userScripts.execute() now supports script cancellation through execution IDs:
const results = await chrome.userScripts.execute({
js: [{ code: wrapperCode }],
target: { tabId: tab.id, allFrames: false },
world: "USER_SCRIPT",
worldId: FIXED_WORLD_ID,
injectImmediately: true,
executionId: "optional-your-own-id" // NEW: Optional execution ID
});
// The result now includes the executionId
const executionId = results[0]?.executionId; // NEW: Always presentconst success = await chrome.userScripts.terminate(
tabId, // Required: The tab ID where script is running
executionId // Required: The execution ID to cancel
);
// Returns true if execution was found and terminated, false otherwiseThe magic: You can call terminate() from a different context while execute() is still awaiting!
The executionId exists immediately when you start the execution, even though the Promise hasn't resolved yet. This enables:
- Generate
executionIdwithcrypto.randomUUID()before callingexecute() - Store it in a Map so other code can find it
- Pass it to
execute()and await the result - Meanwhile, from another function/handler, call
terminate()to interrupt it
// Function A: Starts execution and awaits result
async executeScript() {
const execId = crypto.randomUUID();
this.currentExecution = { tabId, execId }; // Store for Function B
const results = await chrome.userScripts.execute({
executionId: execId, // Pass the ID
// ... other params
});
// This await will throw if terminated!
}
// Function B: Called from UI button, cancels Function A
async handleCancelButton() {
if (this.currentExecution) {
await chrome.userScripts.terminate(
this.currentExecution.tabId,
this.currentExecution.execId
);
// Function A's await will now throw!
}
}This is the recommended approach for Mario's code:
class BrowserJsRuntimeProvider {
// Track active executions for cancellation
private activeExecutions = new Map<string, {
tabId: number;
executionId: string;
startTime: number;
}>();
async execute(sandboxId: string, code: string, tab: chrome.tabs.Tab): Promise<any> {
// Generate execution ID upfront for immediate cancellation capability
const executionId = crypto.randomUUID();
// Store execution info BEFORE executing - this allows cancel from another context!
this.activeExecutions.set(sandboxId, {
tabId: tab.id!,
executionId: executionId,
startTime: Date.now()
});
try {
// Configure the fixed world with CSP
if (chrome.userScripts && typeof chrome.userScripts.execute === "function") {
try {
await chrome.userScripts.configureWorld({
worldId: FIXED_WORLD_ID,
messaging: true,
csp: "script-src 'unsafe-eval' 'unsafe-inline'; connect-src 'none'; img-src 'none'; media-src 'none'; frame-src 'none'; font-src 'none'; object-src 'none'; default-src 'none';",
});
} catch (e) {
console.warn("[BrowserJsRuntimeProvider] Failed to configure userScripts world:", e);
}
// Execute and await - but cancellation can happen from cancelExecution() below!
const results = await chrome.userScripts.execute({
js: [{ code: wrapperCode }],
target: { tabId: tab.id, allFrames: false },
world: "USER_SCRIPT",
worldId: FIXED_WORLD_ID,
injectImmediately: true,
executionId: executionId, // NEW: Pass our pre-generated ID
});
const result = results[0]?.result as
| {
success: boolean;
lastValue?: unknown;
error?: string;
stack?: string;
}
| undefined;
// Verify executionId matches
console.log(`Execution completed with ID: ${results[0]?.executionId}`);
// Get console output
const consoleLogs = pageConsoleProvider.getLogs();
if (!result) {
return {
success: true,
error: "No result returned from script execution",
console: consoleLogs,
};
}
if (!result.success) {
return {
success: false,
error: result.error,
stack: result.stack,
console: consoleLogs,
};
}
return {
success: true,
result: result.lastValue,
console: consoleLogs,
};
} else {
// Firefox fallback
return {
success: false,
error: 'Firefox is currently not supported for browserjs(). Use Chrome 138+ with "Allow User Scripts" enabled.',
};
}
} catch (error: any) {
console.error("[BrowserJsRuntimeProvider] Error:", error);
// Check if error is due to cancellation
if (error.message && error.message.includes("terminate")) {
return {
success: false,
error: "Script execution was cancelled",
cancelled: true
};
}
return {
success: false,
error: error.message || String(error),
};
} finally {
// Cleanup - remove from tracking
this.activeExecutions.delete(sandboxId);
this.cleanup(sandboxId);
}
}
// NEW: Cancel a running execution
// This can be called from UI, timeout, or any other context!
async cancelExecution(sandboxId: string): Promise<boolean> {
const execution = this.activeExecutions.get(sandboxId);
if (!execution) {
console.warn(`[BrowserJsRuntimeProvider] No active execution for sandbox ${sandboxId}`);
return false;
}
try {
console.log(`[BrowserJsRuntimeProvider] Cancelling execution ${execution.executionId} on tab ${execution.tabId}`);
// This will cause the awaiting execute() to throw!
const success = await chrome.userScripts.terminate(
execution.tabId,
execution.executionId
);
if (success) {
console.log(`[BrowserJsRuntimeProvider] Successfully cancelled execution ${execution.executionId}`);
this.activeExecutions.delete(sandboxId);
return true;
} else {
console.warn(`[BrowserJsRuntimeProvider] Failed to cancel execution ${execution.executionId} (not found)`);
return false;
}
} catch (error: any) {
console.error(`[BrowserJsRuntimeProvider] Error cancelling execution:`, error);
return false;
}
}
// NEW: Cancel all active executions (e.g., on tab close)
async cancelAllExecutions(): Promise<void> {
const cancelPromises = Array.from(this.activeExecutions.keys()).map(
sandboxId => this.cancelExecution(sandboxId)
);
await Promise.all(cancelPromises);
}
// NEW: Get active execution info (for UI display)
getActiveExecutions(): Array<{sandboxId: string, tabId: number, duration: number}> {
const now = Date.now();
return Array.from(this.activeExecutions.entries()).map(([sandboxId, exec]) => ({
sandboxId,
tabId: exec.tabId,
duration: now - exec.startTime
}));
}
}
// Usage example:
// Thread 1: Start execution (awaits result)
const resultPromise = provider.execute(sandboxId, code, tab);
// ... this is awaiting ...
// Thread 2: Cancel from UI button (interrupts Thread 1)
async function handleCancelButton(sandboxId: string) {
const cancelled = await provider.cancelExecution(sandboxId);
if (cancelled) {
console.log("Script cancelled successfully");
// Thread 1's await will now throw/resolve with error
}
}
// Thread 3: Auto-cancel after timeout
setTimeout(async () => {
await provider.cancelExecution(sandboxId);
}, 30000); // Cancel after 30 secondsFor a more modern approach using standard Web APIs:
class BrowserJsRuntimeProvider {
private activeExecutions = new Map<string, {
tabId: number;
executionId: string;
abortController: AbortController;
}>();
async execute(
sandboxId: string,
code: string,
tab: chrome.tabs.Tab,
parentSignal?: AbortSignal // Allow passing in an AbortSignal from caller
): Promise<any> {
const executionId = crypto.randomUUID();
const abortController = new AbortController();
// Store execution with abort controller
this.activeExecutions.set(sandboxId, {
tabId: tab.id!,
executionId: executionId,
abortController: abortController
});
// If parent provided a signal, link it to our controller
if (parentSignal) {
parentSignal.addEventListener('abort', () => {
abortController.abort();
});
}
// Listen for our own abort signal
const abortHandler = async () => {
try {
await chrome.userScripts.terminate(tab.id!, executionId);
} catch (e) {
console.error("Failed to terminate:", e);
}
};
abortController.signal.addEventListener('abort', abortHandler);
try {
// Check if already aborted
if (abortController.signal.aborted) {
throw new Error("Script execution was cancelled before starting");
}
const results = await chrome.userScripts.execute({
js: [{ code: wrapperCode }],
target: { tabId: tab.id, allFrames: false },
world: "USER_SCRIPT",
worldId: FIXED_WORLD_ID,
injectImmediately: true,
executionId: executionId,
});
// Check if aborted during execution
if (abortController.signal.aborted) {
throw new Error("Script execution was cancelled");
}
return { success: true, result: results[0]?.result };
} catch (error: any) {
if (abortController.signal.aborted) {
return { success: false, error: "Script execution was cancelled", cancelled: true };
}
throw error;
} finally {
abortController.signal.removeEventListener('abort', abortHandler);
this.activeExecutions.delete(sandboxId);
this.cleanup(sandboxId);
}
}
// Cancel using the stored AbortController
async cancelExecution(sandboxId: string): Promise<boolean> {
const execution = this.activeExecutions.get(sandboxId);
if (!execution) {
return false;
}
// Trigger abort - this will call the abort handler which calls terminate()
execution.abortController.abort();
return true;
}
}
// Usage with external AbortController:
const controller = new AbortController();
// Start execution
const execPromise = provider.execute(sandboxId, code, tab, controller.signal);
// Cancel from UI button
document.getElementById('cancel').addEventListener('click', () => {
controller.abort(); // This cancels the execution!
});
// Cancel after timeout
setTimeout(() => controller.abort(), 5000);
// Await result (will throw if cancelled)
try {
const result = await execPromise;
console.log("Result:", result);
} catch (e) {
console.log("Cancelled or failed:", e);
}Combine execution with automatic timeout:
async executeWithTimeout(
sandboxId: string,
code: string,
tab: chrome.tabs.Tab,
timeoutMs: number = 30000
): Promise<any> {
const executionId = crypto.randomUUID();
this.activeExecutions.set(sandboxId, {
tabId: tab.id!,
executionId: executionId,
startTime: Date.now()
});
// Create timeout promise
const timeoutPromise = new Promise((_, reject) => {
setTimeout(async () => {
await this.cancelExecution(sandboxId);
reject(new Error(`Script execution timeout after ${timeoutMs}ms`));
}, timeoutMs);
});
// Create execution promise
const executionPromise = chrome.userScripts.execute({
js: [{ code: wrapperCode }],
target: { tabId: tab.id, allFrames: false },
world: "USER_SCRIPT",
worldId: FIXED_WORLD_ID,
injectImmediately: true,
executionId: executionId,
});
try {
// Race between execution and timeout
const results = await Promise.race([executionPromise, timeoutPromise]);
return { success: true, result: results[0]?.result };
} catch (error: any) {
return { success: false, error: error.message, timedOut: error.message.includes('timeout') };
} finally {
this.activeExecutions.delete(sandboxId);
this.cleanup(sandboxId);
}
}- Generate ID →
const execId = crypto.randomUUID() - Store in Map →
this.activeExecutions.set(sandboxId, { tabId, execId }) - Start Execution →
const promise = chrome.userScripts.execute({ executionId: execId }) - Await Result →
await promise← This waits for script to finish - Meanwhile... → Another function calls
chrome.userScripts.terminate(tabId, execId) - Termination Happens → V8 kills the script in ~10ms
- Promise Rejects/Resolves → The
awaitin step 4 now completes with error
✅ Non-blocking ID: The executionId exists before the script finishes
✅ Stored Reference: The Map allows any code to find and cancel by sandboxId
✅ Interrupt Await: terminate() causes the Promise to reject, interrupting the await
✅ Multiple Contexts: Cancel can be called from UI buttons, timeouts, cleanup handlers, etc.
✅ Graceful Errors: Handle cancellation in the catch block
Parameters:
interface UserScriptInjection {
js: ScriptSource[];
target: InjectionTarget;
world?: "USER_SCRIPT" | "MAIN";
worldId?: string;
injectImmediately?: boolean;
executionId?: string; // NEW: Optional execution ID (if omitted, one is generated)
}Returns:
interface InjectionResult {
documentId: string;
frameId: number;
result?: any;
error?: string;
executionId: string; // NEW: Always present (either provided or generated)
}Signature:
function terminate(
tabId: number,
executionId: string,
callback?: (success: boolean) => void
): Promise<boolean>;Parameters:
tabId: The ID of the tab where the script is executingexecutionId: The execution ID to terminate (from InjectionResult or provided to execute())callback: Optional callback, receivestrueif execution was found and terminated
Returns: Promise that resolves to true if execution was found and terminated, false otherwise
Effects on execute():
- The awaiting
execute()Promise will reject with an error - Catch the error to handle cancellation gracefully
- The script stops immediately in V8 (typical latency: ~10ms)
- Handle Pattern: Generate executionId with
crypto.randomUUID()BEFORE calling execute() to enable immediate cancellation - Per-Execution: Only terminates the specific execution, not all scripts on the tab
- Broadcast: Termination request is sent to all frames in the tab; renderer returns true if it finds and terminates the execution
- Async Scripts: Works with both sync loops (that yield via setTimeout) and naturally async operations (fetch, promises)
- Latency: Termination typically completes in ~10ms from API call to V8 termination
- Error Handling: Always catch errors from execute() - cancellation causes rejection
- Cleanup: Use finally blocks to clean up tracking state
- Chrome 138+ (unreleased, currently in development)
- Enable flag:
--enable-features=ApiUserScriptsExecute - Extensions must request
userScriptspermission in manifest.json
See working demo extension: /home/chrome/script_cancel_demo/
Launch Chrome with:
chrome --enable-features=ApiUserScriptsExecute --load-extension=/path/to/extensionFrom actual testing with the demo extension:
[001859.943254] Script execution starting, executionId: 47be277a-3655-4784-a0ae-4d2e5c06ca93
[001859.959471] Tracked injection: 47be277a-3655-4784-a0ae-4d2e5c06ca93
[001901.653244] Calling chrome.userScripts.terminate(1132145197, "47be277a...") ← 42 seconds later!
[001901.660863] TerminateScriptExecution called for: 47be277a... (map size: 1)
[001901.661046] Calling TerminateExecution() for 47be277a...
[001901.662231] Termination call completed, result: true ← Total: 9ms
✅ Script ran for 42 seconds before being cancelled
✅ Termination completed in 9ms from API call to completion
✅ Successfully interrupted the awaiting Promise