From 5c467d5661ff5caf3973193ce50b12d2ffb225ef Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:33:35 +0000 Subject: [PATCH] fix(workspaces): cap WorkspaceKernel in-memory file path to prevent DO resets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WorkspaceKernel Durable Object materializes whole file bodies in its ~128MB isolate on upload ingest and read (@cloudflare/shell only exposes whole-buffer read/write, so there is no streaming path). A large enough file exceeds the isolate memory limit or holds the storage output gate past its timeout, resetting the object mid-operation and risking in-flight writes. Bound the in-memory path by size so oversized files fail cleanly instead of resetting the object: - Reject uploads above the kernel cap at the route (handled 413) and again defensively in the DO before buffering. - Refuse to load over-cap bodies on read (covers files ingested before the cap). - Skip the memory-heavy preview decode (pdfium/photon-wasm) for large files. Files large enough to trip these caps already failed today — just catastrophically, by resetting the whole DO. Generated-By: PostHog Code Task-Id: a2948632-0f77-473d-ac45-0e43e79edfd2 --- .../kernel/workspace-kernel-file-commands.ts | 44 ++++++++++++++++--- .../kernel/workspace-kernel-file-limits.ts | 32 ++++++++++++++ .../v1/workspaces.$workspaceId.file-upload.ts | 18 ++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 src/features/workspaces/kernel/workspace-kernel-file-limits.ts diff --git a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts index e23f733c..84cf8874 100644 --- a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts @@ -7,6 +7,11 @@ import { WORKSPACE_FILE_PREVIEW_CONTENT_TYPE, } from "#/features/workspaces/files/workspace-file-preview"; import type { WorkspaceKernelEventBus } from "#/features/workspaces/kernel/workspace-kernel-events"; +import { + formatWorkspaceKernelByteLimit, + WORKSPACE_KERNEL_MAX_IN_MEMORY_BYTES, + WORKSPACE_KERNEL_MAX_PREVIEW_SOURCE_BYTES, +} from "#/features/workspaces/kernel/workspace-kernel-file-limits"; import { getWorkspaceKernelFilePreviewShellPath, getWorkspaceKernelFileShellPath, @@ -75,6 +80,19 @@ export class WorkspaceKernelFileCommands { throw new Error("Uploaded file size did not match the upload request."); } + // Never materialize a body large enough to exhaust the DO isolate. Such files + // already fail today by resetting the whole Durable Object (memory limit or + // storage-timeout); fail cleanly here instead so concurrent in-flight writes + // survive. The upload route rejects these earlier, so this is a safety net. + if (object.size > WORKSPACE_KERNEL_MAX_IN_MEMORY_BYTES) { + await this.r2.delete(input.objectKey); + throw new Error( + `File is too large to process (limit ${formatWorkspaceKernelByteLimit( + WORKSPACE_KERNEL_MAX_IN_MEMORY_BYTES, + )}).`, + ); + } + const bytes = new Uint8Array(await object.arrayBuffer()); const descriptor = getWorkspaceUploadFamily(input.assetKind); const contentType = resolveWorkspaceFileContentType({ @@ -150,7 +168,10 @@ export class WorkspaceKernelFileCommands { const previewGenerator = resolveUploadPreviewGenerator(descriptor); - if (previewGenerator) { + // Preview decoding (pdfium / photon-wasm) inflates memory to several multiples + // of the file size, so skip it for large files rather than risk the isolate. + // Previews are best-effort; a missing one degrades to no thumbnail. + if (previewGenerator && bytes.byteLength <= WORKSPACE_KERNEL_MAX_PREVIEW_SOURCE_BYTES) { await this.tryCreateUploadPreview({ bytes, generate: previewGenerator, @@ -172,17 +193,28 @@ export class WorkspaceKernelFileCommands { throw new Error("Workspace item is not a file."); } + const item = this.store.requireItem(input.itemId); + const contentType = getMetadataString(item.metadataJson, "mimeType"); + const originalName = getMetadataString(item.metadataJson, "originalName"); + const sizeBytes = getMetadataNumber(item.metadataJson, "sizeBytes"); + + // Refuse to load a body large enough to reset the DO isolate. This can only be + // hit by files ingested before the ingest cap existed; better a handled error + // than a Durable Object reset that also loses concurrent in-flight writes. + if (sizeBytes != null && sizeBytes > WORKSPACE_KERNEL_MAX_IN_MEMORY_BYTES) { + throw new Error( + `File is too large to load (limit ${formatWorkspaceKernelByteLimit( + WORKSPACE_KERNEL_MAX_IN_MEMORY_BYTES, + )}).`, + ); + } + const bytes = await this.workspace.readFileBytes(row.shell_path); if (!bytes) { throw new Error("Workspace file content was not found."); } - const item = this.store.requireItem(input.itemId); - const contentType = getMetadataString(item.metadataJson, "mimeType"); - const originalName = getMetadataString(item.metadataJson, "originalName"); - const sizeBytes = getMetadataNumber(item.metadataJson, "sizeBytes"); - return { bytes, contentType: contentType ?? "application/octet-stream", diff --git a/src/features/workspaces/kernel/workspace-kernel-file-limits.ts b/src/features/workspaces/kernel/workspace-kernel-file-limits.ts new file mode 100644 index 00000000..05dbecae --- /dev/null +++ b/src/features/workspaces/kernel/workspace-kernel-file-limits.ts @@ -0,0 +1,32 @@ +/** + * WorkspaceKernel runs inside a Cloudflare Durable Object whose isolate has a + * ~128MB memory ceiling. File ingest and read fully materialize the file body in + * that isolate — `@cloudflare/shell` only exposes whole-buffer `readFileBytes` / + * `writeFileBytes`, so there is no streaming path to fall back on. A single large + * file can therefore blow the memory limit, or hold the SQL storage output gate + * past its timeout, and reset the Durable Object mid-operation. + * + * These caps bound the in-memory path well under the isolate budget so an oversized + * file fails cleanly (a handled error) instead of resetting the object. They are + * intentionally lower than the product-level upload limit (`maxBytesPerSelection`): + * files above these thresholds already fail today, just catastrophically. + */ + +/** + * Largest file the kernel will materialize in the DO isolate (upload ingest and + * content reads). Kept well under the ~128MB isolate ceiling to leave headroom for + * the storage write copy and RPC framing. + */ +export const WORKSPACE_KERNEL_MAX_IN_MEMORY_BYTES = 64 * 1024 * 1024; + +/** + * Preview generation (pdfium / photon-wasm) decodes the source into raw RGBA and + * allocates several multiples of the file size on the wasm heap, so it is capped + * more tightly than the content path. Files above this simply skip the + * best-effort preview rather than risking the isolate. + */ +export const WORKSPACE_KERNEL_MAX_PREVIEW_SOURCE_BYTES = 16 * 1024 * 1024; + +export function formatWorkspaceKernelByteLimit(bytes: number) { + return `${Math.floor(bytes / (1024 * 1024))} MB`; +} diff --git a/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts b/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts index 3aba3786..7b6c48d3 100644 --- a/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts +++ b/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts @@ -8,6 +8,10 @@ import { createWorkspaceFileFromUpload, getWorkspaceKernel, } from "#/features/workspaces/kernel/workspace-kernel-access"; +import { + formatWorkspaceKernelByteLimit, + WORKSPACE_KERNEL_MAX_IN_MEMORY_BYTES, +} from "#/features/workspaces/kernel/workspace-kernel-file-limits"; import { resolveWorkspaceFileAiReadStrategy, WorkspaceFileUploadError, @@ -101,6 +105,20 @@ async function handleWorkspaceFileUpload(request: Request, workspaceId: string) file, }); + // The WorkspaceKernel Durable Object materializes the whole file body in its + // ~128MB isolate on ingest, so reject anything above the kernel's in-memory cap + // before it can reset the object. Uses the post-conversion size the kernel buffers. + if (upload.fileSize > WORKSPACE_KERNEL_MAX_IN_MEMORY_BYTES) { + return apiError( + requestId, + 413, + "UPLOAD_TOO_LARGE", + `This file is too large to process. Upload files up to ${formatWorkspaceKernelByteLimit( + WORKSPACE_KERNEL_MAX_IN_MEMORY_BYTES, + )}.`, + ); + } + objectKey = getWorkspaceFileUploadObjectKey(workspaceId); await env.WORKSPACE_KERNEL_FILES.put(objectKey, upload.body, { httpMetadata: {