Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
44 changes: 38 additions & 6 deletions src/features/workspaces/kernel/workspace-kernel-file-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions src/features/workspaces/kernel/workspace-kernel-file-limits.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
18 changes: 18 additions & 0 deletions src/routes/api/v1/workspaces.$workspaceId.file-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down
Loading