Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/heavy-bananas-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/sandbox": patch
---

fix JS and TS executors to await Promise in context execution
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ import * as util from 'node:util';
import * as vm from 'node:vm';
import type { RichOutput } from '../../process-pool';

interface Thenable<T> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Thenable interface and isThenable() function are duplicated in both node_executor.ts and ts_executor.ts. Per CLAUDE.md, shared types belong in packages/shared/src/. Move to new file packages/shared/src/thenable.ts and import:

import { isThenable, type Thenable } from '@repo/shared';

then: (
onfulfilled?: (value: T) => unknown,
onrejected?: (reason: unknown) => unknown
) => unknown;
}

function isThenable(value: unknown): value is Thenable<unknown> {
return (
value !== null &&
typeof value === 'object' &&
'then' in value &&
typeof (value as Thenable<unknown>).then === 'function'
);
}

// Create CommonJS-like globals for the sandbox
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -84,6 +100,11 @@ rl.on('line', async (line: string) => {
}

result = vm.runInContext(code, context, options);

// If result is a Promise (thenable), await it
if (isThenable(result)) {
result = await result;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical timeout bypass: vm.runInContext timeout only applies to sync execution. When awaiting the Promise here, long-running or infinite Promises ignore the timeout entirely.

Wrap in Promise.race:

if (isThenable(result)) {
  if (timeout !== undefined) {
    const timeoutPromise = new Promise((_, reject) => 
      setTimeout(() => reject(new Error('Execution timeout')), timeout)
    );
    result = await Promise.race([result, timeoutPromise]);
  } else {
    result = await result;
  }
}

} catch (error: unknown) {
const err = error as Error;
stderr += err.stack || err.toString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ import * as vm from 'node:vm';
import { transformSync } from 'esbuild';
import type { RichOutput } from '../../process-pool';

interface Thenable<T> {
then: (
onfulfilled?: (value: T) => unknown,
onrejected?: (reason: unknown) => unknown
) => unknown;
}

function isThenable(value: unknown): value is Thenable<unknown> {
return (
value !== null &&
typeof value === 'object' &&
'then' in value &&
typeof (value as Thenable<unknown>).then === 'function'
);
}

// Create CommonJS-like globals for the sandbox
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -95,6 +111,11 @@ rl.on('line', async (line: string) => {
}

result = vm.runInContext(jsCode, context, options);

// If result is a Promise (thenable), await it
if (isThenable(result)) {
result = await result;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same timeout bypass as node_executor.ts:107. Apply Promise.race fix here too.

} catch (error: unknown) {
const err = error as Error;
if (err.message?.includes('Transform failed')) {
Expand Down
303 changes: 303 additions & 0 deletions tests/e2e/code-interpreter-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,309 @@ describe('Code Interpreter Workflow (E2E)', () => {
);
}, 120000);

// ============================================================================
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests cover happy path and error cases well, but missing timeout enforcement tests. Add:

  • Test with long-running async code (10s Promise with 1s timeout)
  • Test with infinite Promise (never resolves)

Without these, the timeout bypass at executor lines 105-107/116-118 goes undetected.

// Async/Await Promise Handling (Issue #206)
// ============================================================================

test('should resolve async IIFE and return the value', async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to validate - all the tests are wrapped in an IIFE. Would this change work if the user passed code not wrapped in an IIFE? If not, is the answer then to wrap the underlying implementation in an async IIFE?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ghostwriternr to support top-level await we'd need to wrap the code in an async IIFE, which adds complexity (need to handle "last expression" capture for multi-statement code) and changes. For this PR, I'd suggest keeping as is since it solves the issue. We can open a separate issue for top-level await? or should we just do it properly here?

currentSandboxId = createSandboxId();
const headers = createTestHeaders(currentSandboxId);

// Create JavaScript context
const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, {
method: 'POST',
headers,
body: JSON.stringify({ language: 'javascript' })
});

const context = await ctxResponse.json();

// Execute async IIFE that returns a value (wrapped in object for json output)
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
method: 'POST',
headers,
body: JSON.stringify({
code: '(async () => { return { value: 42 }; })()',
options: { context }
})
});

expect(execResponse.status).toBe(200);
const execution = (await execResponse.json()) as ExecutionResult;

expect(execution.error).toBeUndefined();
expect(execution.results).toBeDefined();
expect(execution.results.length).toBeGreaterThan(0);
// The result should be { value: 42 }, not an empty object {}
const resultData = execution.results[0];
expect(resultData.json).toEqual({ value: 42 });
}, 120000);

test('should resolve Promise.resolve() and return the value', async () => {
currentSandboxId = createSandboxId();
const headers = createTestHeaders(currentSandboxId);

// Create JavaScript context
const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, {
method: 'POST',
headers,
body: JSON.stringify({ language: 'javascript' })
});

const context = await ctxResponse.json();

// Execute Promise.resolve
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
method: 'POST',
headers,
body: JSON.stringify({
code: 'Promise.resolve({ status: "success", value: 123 })',
options: { context }
})
});

expect(execResponse.status).toBe(200);
const execution = (await execResponse.json()) as ExecutionResult;

expect(execution.error).toBeUndefined();
expect(execution.results).toBeDefined();
expect(execution.results.length).toBeGreaterThan(0);
// The result should be the resolved object, not {}
const resultData = execution.results[0];
expect(resultData.json).toEqual({ status: 'success', value: 123 });
}, 120000);

test('should handle nested async operations', async () => {
currentSandboxId = createSandboxId();
const headers = createTestHeaders(currentSandboxId);

// Create JavaScript context
const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, {
method: 'POST',
headers,
body: JSON.stringify({ language: 'javascript' })
});

const context = await ctxResponse.json();

// Execute nested async code (wrapped in object for json output)
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
method: 'POST',
headers,
body: JSON.stringify({
code: `
(async () => {
const a = await Promise.resolve(10);
const b = await Promise.resolve(20);
return { sum: a + b };
})()
`.trim(),
options: { context }
})
});

expect(execResponse.status).toBe(200);
const execution = (await execResponse.json()) as ExecutionResult;

expect(execution.error).toBeUndefined();
expect(execution.results).toBeDefined();
expect(execution.results.length).toBeGreaterThan(0);
// The result should be { sum: 30 }
const resultData = execution.results[0];
expect(resultData.json).toEqual({ sum: 30 });
}, 120000);

test('should still handle synchronous code correctly', async () => {
currentSandboxId = createSandboxId();
const headers = createTestHeaders(currentSandboxId);

// Create JavaScript context
const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, {
method: 'POST',
headers,
body: JSON.stringify({ language: 'javascript' })
});

const context = await ctxResponse.json();

// Execute synchronous code
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
method: 'POST',
headers,
body: JSON.stringify({
code: '({ sum: 1 + 2 + 3, product: 2 * 3 * 4 })',
options: { context }
})
});

expect(execResponse.status).toBe(200);
const execution = (await execResponse.json()) as ExecutionResult;

expect(execution.error).toBeUndefined();
expect(execution.results).toBeDefined();
expect(execution.results.length).toBeGreaterThan(0);
const resultData = execution.results[0];
expect(resultData.json).toEqual({ sum: 6, product: 24 });
}, 120000);

test('should resolve TypeScript async code', async () => {
currentSandboxId = createSandboxId();
const headers = createTestHeaders(currentSandboxId);

// Create TypeScript context
const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, {
method: 'POST',
headers,
body: JSON.stringify({ language: 'typescript' })
});

const context = await ctxResponse.json();

// Execute TypeScript async code (wrapped in object for json output)
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
method: 'POST',
headers,
body: JSON.stringify({
code: `
(async (): Promise<{ result: number }> => {
const value: number = await Promise.resolve(100);
return { result: value * 2 };
})()
`.trim(),
options: { context }
})
});

expect(execResponse.status).toBe(200);
const execution = (await execResponse.json()) as ExecutionResult;

expect(execution.error).toBeUndefined();
expect(execution.results).toBeDefined();
expect(execution.results.length).toBeGreaterThan(0);
// The result should be { result: 200 }
const resultData = execution.results[0];
expect(resultData.json).toEqual({ result: 200 });
}, 120000);

test('should handle Promise.reject() and report the error', async () => {
currentSandboxId = createSandboxId();
const headers = createTestHeaders(currentSandboxId);

// Create JavaScript context
const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, {
method: 'POST',
headers,
body: JSON.stringify({ language: 'javascript' })
});

const context = await ctxResponse.json();

// Execute Promise.reject
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
method: 'POST',
headers,
body: JSON.stringify({
code: 'Promise.reject(new Error("Intentional rejection"))',
options: { context }
})
});

expect(execResponse.status).toBe(200);
const execution = (await execResponse.json()) as ExecutionResult;

// Should have an error from the rejected Promise
expect(execution.error).toBeDefined();
if (!execution.error) throw new Error('Expected error to be defined');

expect(
execution.error.message ||
execution.error.name ||
execution.logs.stderr.join('')
).toMatch(/Intentional rejection|Error/i);
}, 120000);

test('should handle async function that throws an error', async () => {
currentSandboxId = createSandboxId();
const headers = createTestHeaders(currentSandboxId);

// Create JavaScript context
const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, {
method: 'POST',
headers,
body: JSON.stringify({ language: 'javascript' })
});

const context = await ctxResponse.json();

// Execute async function that throws
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
method: 'POST',
headers,
body: JSON.stringify({
code: `
(async () => {
throw new Error("Async error thrown");
})()
`.trim(),
options: { context }
})
});

expect(execResponse.status).toBe(200);
const execution = (await execResponse.json()) as ExecutionResult;

// Should have an error from the thrown exception
expect(execution.error).toBeDefined();
if (!execution.error) throw new Error('Expected error to be defined');

expect(
execution.error.message ||
execution.error.name ||
execution.logs.stderr.join('')
).toMatch(/Async error thrown|Error/i);
}, 120000);

test('should handle TypeScript async function that throws', async () => {
currentSandboxId = createSandboxId();
const headers = createTestHeaders(currentSandboxId);

// Create TypeScript context
const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, {
method: 'POST',
headers,
body: JSON.stringify({ language: 'typescript' })
});

const context = await ctxResponse.json();

// Execute TypeScript async function that throws
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
method: 'POST',
headers,
body: JSON.stringify({
code: `
(async (): Promise<never> => {
throw new TypeError("TypeScript async error");
})()
`.trim(),
options: { context }
})
});

expect(execResponse.status).toBe(200);
const execution = (await execResponse.json()) as ExecutionResult;

// Should have an error from the thrown exception
expect(execution.error).toBeDefined();
if (!execution.error) throw new Error('Expected error to be defined');

expect(
execution.error.message ||
execution.error.name ||
execution.logs.stderr.join('')
).toMatch(/TypeScript async error|TypeError|Error/i);
}, 120000);

// ============================================================================
// Streaming Execution
// ============================================================================
Expand Down
Loading