From 82d72217befc67f469d30d20a68316d76c567b96 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 24 Jun 2026 18:18:22 +0200 Subject: [PATCH] Set dynamic worker executor timeout to 60 seconds --- src/tools/execute.ts | 20 +++++++++++++++++--- tests/executor.test.ts | 32 +++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/tools/execute.ts b/src/tools/execute.ts index 8022ba0..f21adf3 100644 --- a/src/tools/execute.ts +++ b/src/tools/execute.ts @@ -20,6 +20,8 @@ interface CodeExecutorEntrypoint { evaluate(): Promise<{ result: unknown; err?: string; stack?: string }> } +export const EXECUTE_TIMEOUT_MS = 60_000 + type GlobalOutboundProps = { apiToken: string; fetchWithRetryCaller: string } /** @@ -59,10 +61,11 @@ export class GlobalOutbound extends WorkerEntrypoint { * a warm isolate), and the API token is injected via the GlobalOutbound props * so it never enters the user code isolate. */ -async function runExecute( +export async function runExecute( code: string, accountId: string | undefined, - apiToken: string + apiToken: string, + timeoutMs = EXECUTE_TIMEOUT_MS ): Promise { const apiBase = env.CLOUDFLARE_API_BASE const workerId = `cloudflare-api-${crypto.randomUUID()}` @@ -89,6 +92,8 @@ async function runExecute( import { WorkerEntrypoint } from "cloudflare:workers"; const apiBase = ${JSON.stringify(apiBase)}; +const executeTimeoutMs = ${JSON.stringify(timeoutMs)}; +const executeTimeoutError = ${JSON.stringify(`Execution timed out after ${timeoutMs / 1000} seconds`)}; ${accountIdPrelude} export default class CodeExecutor extends WorkerEntrypoint { @@ -181,11 +186,20 @@ export default class CodeExecutor extends WorkerEntrypoint { } }; + let timeoutId; try { - const result = await (${code})(); + const execution = Promise.resolve().then(() => (${code})()); + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(executeTimeoutError)), executeTimeoutMs); + }); + const result = await Promise.race([execution, timeout]); return { result, err: undefined }; } catch (err) { return { result: undefined, err: err.message, stack: err.stack }; + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } } } } diff --git a/tests/executor.test.ts b/tests/executor.test.ts index 261feab..4b6af42 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -6,6 +6,7 @@ import { clearKv } from './helpers/kv' import { clearSpec, seedSpec } from './helpers/spec' import { callTool, toolText } from './helpers/mcp' import { server } from './setup/msw' +import { EXECUTE_TIMEOUT_MS, runExecute as runExecuteCode } from '../src/tools/execute' /** * Behaviour tests for the code executors, run through the REAL worker: a @@ -25,7 +26,11 @@ afterEach(async () => { }) /** Run an `execute` tool call whose code hits `path`, with MSW returning `body`. */ -async function runExecute(path: string, body: unknown, init?: ResponseInit): Promise { +async function runExecuteRequest( + path: string, + body: unknown, + init?: ResponseInit +): Promise { mockIdentityProbe({ accounts: [{ id: ACCOUNT_ID, name: 'Acc' }] }) server.use( http.get(`${API_BASE}${path}`, () => @@ -40,9 +45,26 @@ async function runExecute(path: string, body: unknown, init?: ResponseInit): Pro return toolText(result) } +describe('execute: dynamic worker timeout', () => { + it('defaults to 60 seconds', () => { + expect(EXECUTE_TIMEOUT_MS).toBe(60_000) + }) + + it('rejects when sandboxed code exceeds the configured timeout', async () => { + await expect( + runExecuteCode( + 'async () => new Promise((resolve) => setTimeout(resolve, 100))', + ACCOUNT_ID, + API_TOKEN, + 10 + ) + ).rejects.toThrow('Execution timed out after 0.01 seconds') + }) +}) + describe('execute: REST responses', () => { it('returns the success envelope with the response status', async () => { - const text = await runExecute( + const text = await runExecuteRequest( `/accounts/${ACCOUNT_ID}/tokens/verify`, cfSuccess({ status: 'active' }) ) @@ -51,7 +73,7 @@ describe('execute: REST responses', () => { }) it('surfaces a clean "Cloudflare API error" for a failure envelope with errors', async () => { - const text = await runExecute( + const text = await runExecuteRequest( `/accounts/${ACCOUNT_ID}/tokens/verify`, { success: false, @@ -69,7 +91,7 @@ describe('execute: REST responses', () => { // Regression: the REST branch must not assume data.errors is an array. // A {success:false} body with a missing errors array (e.g. a gateway/proxy // envelope) previously threw "Cannot read properties of undefined (map)". - const text = await runExecute( + const text = await runExecuteRequest( `/accounts/${ACCOUNT_ID}/tokens/verify`, { success: false, result: null }, { status: 502 } @@ -80,7 +102,7 @@ describe('execute: REST responses', () => { }) it('returns non-JSON responses as raw text', async () => { - const text = await runExecute(`/accounts/${ACCOUNT_ID}/something`, 'raw-value', { + const text = await runExecuteRequest(`/accounts/${ACCOUNT_ID}/something`, 'raw-value', { headers: { 'Content-Type': 'text/plain' } }) expect(text).toContain('raw-value')