From e3e3ea9c0808b2385d0363acc6137769a0bda59d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Apr 2026 17:30:21 -0700 Subject: [PATCH] fix(vm): categorize user or server side errors --- .../[id]/_preview/create-preview-route.ts | 10 ++++- .../[id]/docx/preview/route.test.ts | 40 +++++++++++++++++-- .../workspaces/[id]/pdf/preview/route.test.ts | 40 +++++++++++++++++-- .../[id]/pptx/preview/route.test.ts | 40 +++++++++++++++++-- apps/sim/lib/execution/isolated-vm.ts | 24 ++++++++++- apps/sim/lib/execution/sandbox/run-task.ts | 38 +++++++++++++++--- 6 files changed, 175 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts b/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts index a369f9472a..0e9c6a43e6 100644 --- a/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts +++ b/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -import { runSandboxTask } from '@/lib/execution/sandbox/run-task' +import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import type { SandboxTaskId } from '@/sandbox-tasks/registry' @@ -83,6 +83,14 @@ export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) { }) } catch (err) { const message = toError(err).message + if (err instanceof SandboxUserCodeError) { + logger.warn(`${config.label} preview user code failed`, { + error: message, + errorName: err.name, + workspaceId, + }) + return NextResponse.json({ error: message, errorName: err.name }, { status: 422 }) + } logger.error(`${config.label} preview generation failed`, { error: message, workspaceId }) return NextResponse.json({ error: message }, { status: 500 }) } diff --git a/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts index cffe9cf9ae..6f14fd0649 100644 --- a/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts @@ -6,9 +6,15 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -const { mockRunSandboxTask } = vi.hoisted(() => ({ - mockRunSandboxTask: vi.fn(), -})) +const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { + class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + } + return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } +}) const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership @@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: mockRunSandboxTask, + SandboxUserCodeError, })) import { POST } from '@/app/api/workspaces/[id]/docx/preview/route' @@ -189,4 +196,31 @@ describe('DOCX preview API route', () => { expect(response.status).toBe(500) await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) }) + + it('returns 422 when user code throws inside the sandbox', async () => { + mockRunSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') + ) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'const x = ' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(422) + await expect(response.json()).resolves.toEqual({ + error: 'Invalid or unexpected token', + errorName: 'SyntaxError', + }) + }) }) diff --git a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts index cf5bd49e45..2dd189f89c 100644 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts @@ -6,9 +6,15 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -const { mockRunSandboxTask } = vi.hoisted(() => ({ - mockRunSandboxTask: vi.fn(), -})) +const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { + class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + } + return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } +}) const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership @@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: mockRunSandboxTask, + SandboxUserCodeError, })) import { POST } from '@/app/api/workspaces/[id]/pdf/preview/route' @@ -187,4 +194,31 @@ describe('PDF preview API route', () => { expect(response.status).toBe(500) await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) }) + + it('returns 422 when user code throws inside the sandbox', async () => { + mockRunSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') + ) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'const x = ' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(422) + await expect(response.json()).resolves.toEqual({ + error: 'Invalid or unexpected token', + errorName: 'SyntaxError', + }) + }) }) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts index 08a8e11f88..900dd41f63 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts @@ -6,9 +6,15 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -const { mockRunSandboxTask } = vi.hoisted(() => ({ - mockRunSandboxTask: vi.fn(), -})) +const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { + class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + } + return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } +}) const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership @@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: mockRunSandboxTask, + SandboxUserCodeError, })) import { POST } from '@/app/api/workspaces/[id]/pptx/preview/route' @@ -189,4 +196,31 @@ describe('PPTX preview API route', () => { expect(response.status).toBe(500) await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) }) + + it('returns 422 when user code throws inside the sandbox', async () => { + mockRunSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') + ) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'const x = ' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(422) + await expect(response.json()).resolves.toEqual({ + error: 'Invalid or unexpected token', + errorName: 'SyntaxError', + }) + }) }) diff --git a/apps/sim/lib/execution/isolated-vm.ts b/apps/sim/lib/execution/isolated-vm.ts index 62221a1ea9..33301e671e 100644 --- a/apps/sim/lib/execution/isolated-vm.ts +++ b/apps/sim/lib/execution/isolated-vm.ts @@ -99,6 +99,15 @@ export interface IsolatedVMError { line?: number column?: number lineContent?: string + /** + * True when the failure is host-infrastructure caused (worker crash, IPC + * failure, pool saturation, task misconfig) rather than anything the user's + * code did. Callers use this to keep genuine server failures as 5xx while + * translating user-caused failures (code errors, timeouts, aborts, per-owner + * rate limits) into 4xx. Defaults to undefined/false — new error sites + * default to user-caused unless explicitly marked. + */ + isSystemError?: boolean } const POOL_SIZE = Number.parseInt(env.IVM_POOL_SIZE) || 4 @@ -838,7 +847,11 @@ function cleanupWorker(workerId: number) { pending.resolve({ result: null, stdout: '', - error: { message: 'Code execution failed unexpectedly. Please try again.', name: 'Error' }, + error: { + message: 'Code execution failed unexpectedly. Please try again.', + name: 'Error', + isSystemError: true, + }, }) workerInfo.pendingExecutions.delete(id) } @@ -1125,7 +1138,11 @@ function dispatchToWorker( resolve({ result: null, stdout: '', - error: { message: 'Code execution failed to start. Please try again.', name: 'Error' }, + error: { + message: 'Code execution failed to start. Please try again.', + name: 'Error', + isSystemError: true, + }, }) if (workerInfo.retiring && workerInfo.activeExecutions === 0) { cleanupWorker(workerInfo.id) @@ -1159,6 +1176,7 @@ function enqueueExecution( error: { message: 'Code execution is at capacity. Please try again in a moment.', name: 'Error', + isSystemError: true, }, }) return @@ -1198,6 +1216,7 @@ function enqueueExecution( error: { message: 'Code execution timed out waiting for an available worker. Please try again.', name: 'Error', + isSystemError: true, }, }) }, QUEUE_TIMEOUT_MS) @@ -1294,6 +1313,7 @@ export async function executeInIsolatedVM( error: { message: `Task "${req.task.id}" requires broker "${brokerName}" but none was provided`, name: 'Error', + isSystemError: true, }, } } diff --git a/apps/sim/lib/execution/sandbox/run-task.ts b/apps/sim/lib/execution/sandbox/run-task.ts index c46a325138..78cc6aadeb 100644 --- a/apps/sim/lib/execution/sandbox/run-task.ts +++ b/apps/sim/lib/execution/sandbox/run-task.ts @@ -20,6 +20,24 @@ export interface RunSandboxTaskOptions { signal?: AbortSignal } +/** + * Thrown when the sandbox failure is attributable to the caller — user code + * errors (SyntaxError, ReferenceError, user-thrown exceptions), timeouts from + * user code, client aborts, or per-owner rate limits. Callers should translate + * this into a 4xx response so genuine 5xx remains a signal of server health. + * + * System-origin failures (worker crash, IPC failure, pool saturation, task + * misconfig) are tagged with `isSystemError` at the isolated-vm layer and + * surface as a plain `Error` → 500. + */ +export class SandboxUserCodeError extends Error { + constructor(message: string, name: string, stack?: string) { + super(message) + this.name = name || 'SandboxUserCodeError' + if (stack) this.stack = stack + } +} + /** * Executes a sandbox task inside the shared isolated-vm pool and returns the * binary result buffer. Throws with a human-readable message if the task fails @@ -70,7 +88,9 @@ export async function runSandboxTask( const queueMs = result.timings ? Math.max(0, elapsedMs - result.timings.total) : undefined if (result.error) { - logger.warn('Sandbox task failed', { + const isSystemError = result.error.isSystemError === true + const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger) + logFn('Sandbox task failed', { taskId, requestId, workspaceId: input.workspaceId, @@ -79,11 +99,19 @@ export async function runSandboxTask( timings: result.timings, error: result.error.message, errorName: result.error.name, + isSystemError, }) - const err = new Error(result.error.message) - err.name = result.error.name || 'SandboxTaskError' - if (result.error.stack) err.stack = result.error.stack - throw err + if (isSystemError) { + const err = new Error(result.error.message) + err.name = result.error.name || 'SandboxSystemError' + if (result.error.stack) err.stack = result.error.stack + throw err + } + throw new SandboxUserCodeError( + result.error.message, + result.error.name || 'SandboxTaskError', + result.error.stack + ) } if (typeof result.bytesBase64 !== 'string' || result.bytesBase64.length === 0) {