Skip to content

Instantly share code, notes, and snippets.

@hjanuschka
Created October 31, 2025 09:29
Show Gist options
  • Select an option

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

Select an option

Save hjanuschka/d73c08768b732a1a660ec00a896c267a to your computer and use it in GitHub Desktop.
chrome.userScripts.execute() with Cancellation - How Await and Cancel Work Together

chrome.userScripts.execute() with Cancellation Support

New API Overview

The chrome.userScripts.execute() now supports script cancellation through execution IDs:

1. Execute with optional executionId (handle pattern)

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 present

2. Terminate execution

const 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 otherwise

The Key Insight: Cancellation While Awaiting

The 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:

  1. Generate executionId with crypto.randomUUID() before calling execute()
  2. Store it in a Map so other code can find it
  3. Pass it to execute() and await the result
  4. 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!
    }
}

Integration Patterns for Sitegeist

Pattern 1: Simple Tracking with Separate Cancel Function

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 seconds

Pattern 2: AbortController Integration (Modern & Clean)

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

Pattern 3: Promise Race for Timeout

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

How Await + Cancel Work Together

The Flow:

  1. Generate IDconst execId = crypto.randomUUID()
  2. Store in Mapthis.activeExecutions.set(sandboxId, { tabId, execId })
  3. Start Executionconst promise = chrome.userScripts.execute({ executionId: execId })
  4. Await Resultawait promise ← This waits for script to finish
  5. Meanwhile... → Another function calls chrome.userScripts.terminate(tabId, execId)
  6. Termination Happens → V8 kills the script in ~10ms
  7. Promise Rejects/Resolves → The await in step 4 now completes with error

Key Points:

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

API Reference

chrome.userScripts.execute()

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

chrome.userScripts.terminate()

Signature:

function terminate(
    tabId: number,
    executionId: string,
    callback?: (success: boolean) => void
): Promise<boolean>;

Parameters:

  • tabId: The ID of the tab where the script is executing
  • executionId: The execution ID to terminate (from InjectionResult or provided to execute())
  • callback: Optional callback, receives true if 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)

Usage Notes

  1. Handle Pattern: Generate executionId with crypto.randomUUID() BEFORE calling execute() to enable immediate cancellation
  2. Per-Execution: Only terminates the specific execution, not all scripts on the tab
  3. Broadcast: Termination request is sent to all frames in the tab; renderer returns true if it finds and terminates the execution
  4. Async Scripts: Works with both sync loops (that yield via setTimeout) and naturally async operations (fetch, promises)
  5. Latency: Termination typically completes in ~10ms from API call to V8 termination
  6. Error Handling: Always catch errors from execute() - cancellation causes rejection
  7. Cleanup: Use finally blocks to clean up tracking state

Chrome Requirements

  • Chrome 138+ (unreleased, currently in development)
  • Enable flag: --enable-features=ApiUserScriptsExecute
  • Extensions must request userScripts permission in manifest.json

Testing

See working demo extension: /home/chrome/script_cancel_demo/

Launch Chrome with:

chrome --enable-features=ApiUserScriptsExecute --load-extension=/path/to/extension

Real-World Test Results

From 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

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