From d2e91d1fcda110bea707fa53dd8d89ae1dda626c Mon Sep 17 00:00:00 2001
From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com>
Date: Fri, 10 Jul 2026 22:07:16 +0000
Subject: [PATCH] fix(workspaces): stream kernel file reads to avoid Durable
Object resets
The WorkspaceKernel Durable Object read whole file blobs into a single in-memory
Uint8Array when serving content, and stored blobs inline in the DO's SQLite up to a
1.5MB threshold. Under that load the DO tripped Cloudflare's isolate memory limit and
storage-operation timeout and reset itself, dropping the client's live connection and
taking the whole workspace page down (notably when opening large files/PDFs).
- Serve file content via a streamed read (readFileStream) so R2-backed bytes flow
straight through the isolate instead of being buffered into one Uint8Array. The
content route now streams the ReadableStream directly in its Response.
- Lower the inline SQLite storage threshold from 1.5MB to 128KB so large files always
spill to R2 and never bloat a single SQLite row. This only governs new writes;
existing files keep whatever backend they were written with.
- Add a scoped error boundary around the workspace file viewer so a transient
content-load failure degrades to a pane-level message and Retry instead of bubbling
to the root boundary and blanking the page.
Generated-By: PostHog Code
Task-Id: d1f9df85-3348-44d1-8c3b-59f448133a7a
---
.../components/WorkspaceFileViewer.tsx | 9 ++-
.../WorkspaceFileViewerErrorBoundary.tsx | 61 +++++++++++++++++++
.../kernel/workspace-kernel-access.ts | 8 ++-
.../kernel/workspace-kernel-file-commands.ts | 32 ++++++++++
.../kernel/workspace-kernel-types.ts | 7 +++
.../workspaces/kernel/workspace-kernel.ts | 10 ++-
...aces.$workspaceId.files.$itemId.content.ts | 24 ++++----
7 files changed, 134 insertions(+), 17 deletions(-)
create mode 100644 src/features/workspaces/components/WorkspaceFileViewerErrorBoundary.tsx
diff --git a/src/features/workspaces/components/WorkspaceFileViewer.tsx b/src/features/workspaces/components/WorkspaceFileViewer.tsx
index 51855089..b2a1295f 100644
--- a/src/features/workspaces/components/WorkspaceFileViewer.tsx
+++ b/src/features/workspaces/components/WorkspaceFileViewer.tsx
@@ -2,6 +2,7 @@ import { type ComponentType, type LazyExoticComponent, lazy, Suspense } from "re
import { ContextMenu, ContextMenuTrigger } from "#/components/ui/context-menu";
import { Spinner } from "#/components/ui/spinner";
+import WorkspaceFileViewerErrorBoundary from "#/features/workspaces/components/WorkspaceFileViewerErrorBoundary";
import { WorkspaceItemActionsContextMenuContent } from "#/features/workspaces/components/WorkspaceItemActionsMenu";
import { useWorkspaceViewCapabilities } from "#/features/workspaces/components/workspace-view-policy";
import { getWorkspaceItemDisplay } from "#/features/workspaces/model/item-display";
@@ -47,9 +48,11 @@ export default function WorkspaceFileViewer({
const Viewer = descriptor ? workspaceFileViewers[descriptor.assetKind] : null;
const viewCapabilities = useWorkspaceViewCapabilities();
const viewerContent = Viewer ? (
- }>
-
-
+
+ }>
+
+
+
) : (
diff --git a/src/features/workspaces/components/WorkspaceFileViewerErrorBoundary.tsx b/src/features/workspaces/components/WorkspaceFileViewerErrorBoundary.tsx
new file mode 100644
index 00000000..e5efaca5
--- /dev/null
+++ b/src/features/workspaces/components/WorkspaceFileViewerErrorBoundary.tsx
@@ -0,0 +1,61 @@
+import { Component, type ErrorInfo, type ReactNode } from "react";
+
+import { Button } from "#/components/ui/button";
+import { capturePostHogClientException } from "#/integrations/posthog/provider";
+
+interface WorkspaceFileViewerErrorBoundaryProps {
+ fileName: string;
+ children: ReactNode;
+}
+
+interface WorkspaceFileViewerErrorBoundaryState {
+ error: Error | null;
+}
+
+/**
+ * Contains file-viewer failures to the viewer pane instead of letting them bubble to the
+ * root error boundary and take the whole workspace page down. This is the safety net for a
+ * WorkspaceKernel Durable Object reset dropping a content request mid-flight.
+ */
+export default class WorkspaceFileViewerErrorBoundary extends Component<
+ WorkspaceFileViewerErrorBoundaryProps,
+ WorkspaceFileViewerErrorBoundaryState
+> {
+ state: WorkspaceFileViewerErrorBoundaryState = {
+ error: null,
+ };
+
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ console.error("Workspace file viewer failed", error, errorInfo);
+ capturePostHogClientException(error, {
+ component_stack: errorInfo.componentStack,
+ error_boundary: "WorkspaceFileViewerErrorBoundary",
+ });
+ }
+
+ render() {
+ const { error } = this.state;
+
+ if (!error) {
+ return this.props.children;
+ }
+
+ return (
+
+
+
Unable to load this file
+
+ Something went wrong while opening “{this.props.fileName}”. Try again in a moment.
+
+
+
+
+ );
+ }
+}
diff --git a/src/features/workspaces/kernel/workspace-kernel-access.ts b/src/features/workspaces/kernel/workspace-kernel-access.ts
index 0d2c8f4c..b3f463e0 100644
--- a/src/features/workspaces/kernel/workspace-kernel-access.ts
+++ b/src/features/workspaces/kernel/workspace-kernel-access.ts
@@ -18,6 +18,7 @@ import type {
DeleteWorkspaceKernelItemsResult,
ListWorkspaceKernelItemRelationsArgs,
MoveWorkspaceKernelItemsResult,
+ ReadWorkspaceKernelFileContentStreamResult,
ReadWorkspaceKernelFilePreviewResult,
ReadWorkspaceKernelFileProjectionArgs,
ReadWorkspaceKernelFileProjectionResult,
@@ -100,6 +101,9 @@ export interface WorkspaceKernelClient {
fileName: string;
sizeBytes: number;
}>;
+ readFileContentStream(input: {
+ itemId: string;
+ }): Promise;
readFilePreview(input: { itemId: string }): Promise;
upsertFileProjection(input: UpsertWorkspaceKernelFileProjectionArgs): Promise;
readFileProjection(
@@ -114,7 +118,7 @@ export interface WorkspaceKernelClient {
purgeForDeletion(): Promise;
}
-export async function readWorkspaceKernelFileContent(input: {
+export async function readWorkspaceKernelFileContentStream(input: {
workspaceId: string;
userId: string;
itemId: string;
@@ -125,7 +129,7 @@ export async function readWorkspaceKernelFileContent(input: {
await assertCanReadWorkspace(dbContext.db, input);
const kernel = await getWorkspaceKernel(input.workspaceId);
- return await kernel.readFileContent({ itemId: input.itemId });
+ return await kernel.readFileContentStream({ itemId: input.itemId });
} finally {
await dbContext.dispose();
}
diff --git a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts
index e23f733c..e60c8415 100644
--- a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts
+++ b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts
@@ -17,6 +17,7 @@ import type {
CreateWorkspaceKernelFileFromUploadArgs,
ReadWorkspaceKernelFileContentArgs,
ReadWorkspaceKernelFileContentResult,
+ ReadWorkspaceKernelFileContentStreamResult,
ReadWorkspaceKernelFilePreviewResult,
ReadWorkspaceKernelFileProjectionArgs,
ReadWorkspaceKernelFileProjectionResult,
@@ -191,6 +192,37 @@ export class WorkspaceKernelFileCommands {
};
}
+ async readFileContentStream(
+ input: ReadWorkspaceKernelFileContentArgs,
+ ): Promise {
+ const row = this.store.assertActiveItem(input.itemId);
+
+ if (row.type !== "file") {
+ throw new Error("Workspace item is not a file.");
+ }
+
+ // Stream bytes straight from storage (R2 for large files) instead of buffering the
+ // whole blob into the Durable Object isolate, which trips Cloudflare's memory limit
+ // and resets the object on large files/PDFs.
+ const stream = await this.workspace.readFileStream(row.shell_path);
+
+ if (!stream) {
+ 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 {
+ stream,
+ contentType: contentType ?? "application/octet-stream",
+ fileName: originalName ?? item.name,
+ sizeBytes: sizeBytes ?? null,
+ };
+ }
+
async readFilePreview(
input: ReadWorkspaceKernelFileContentArgs,
): Promise {
diff --git a/src/features/workspaces/kernel/workspace-kernel-types.ts b/src/features/workspaces/kernel/workspace-kernel-types.ts
index 26a7c1f6..319c01bd 100644
--- a/src/features/workspaces/kernel/workspace-kernel-types.ts
+++ b/src/features/workspaces/kernel/workspace-kernel-types.ts
@@ -105,6 +105,13 @@ export interface ReadWorkspaceKernelFileContentResult {
sizeBytes: number;
}
+export interface ReadWorkspaceKernelFileContentStreamResult {
+ stream: ReadableStream;
+ contentType: string;
+ fileName: string;
+ sizeBytes: number | null;
+}
+
export type WorkspaceKernelFileProjectionFormat = "pages" | "preview";
export type WorkspaceKernelFileProjectionStatus =
diff --git a/src/features/workspaces/kernel/workspace-kernel.ts b/src/features/workspaces/kernel/workspace-kernel.ts
index cc45e3ef..7c929a5b 100644
--- a/src/features/workspaces/kernel/workspace-kernel.ts
+++ b/src/features/workspaces/kernel/workspace-kernel.ts
@@ -44,7 +44,11 @@ import type {
WorkspaceRealtimeServerMessage,
} from "#/features/workspaces/realtime/messages";
-const workspaceKernelInlineThresholdBytes = 1_500_000;
+// Keep only small files inline in the Durable Object's SQLite. Anything larger spills to R2 so
+// big files/PDFs never bloat a single SQLite row (base64 inflates ~33%), which was tripping
+// Cloudflare's storage-operation timeout and resetting the object. Existing files keep whatever
+// backend they were written with — this threshold only governs new writes.
+const workspaceKernelInlineThresholdBytes = 128 * 1024;
export { setWorkspaceKernelUserHeaders };
@@ -149,6 +153,10 @@ export class WorkspaceKernel extends Agent {
return await this.fileCommands.readFileContent(input);
}
+ async readFileContentStream(input: ReadWorkspaceKernelFileContentArgs) {
+ return await this.fileCommands.readFileContentStream(input);
+ }
+
async readFilePreview(input: ReadWorkspaceKernelFileContentArgs) {
return await this.fileCommands.readFilePreview(input);
}
diff --git a/src/routes/api/v1/workspaces.$workspaceId.files.$itemId.content.ts b/src/routes/api/v1/workspaces.$workspaceId.files.$itemId.content.ts
index 310b9ae8..4fafc76a 100644
--- a/src/routes/api/v1/workspaces.$workspaceId.files.$itemId.content.ts
+++ b/src/routes/api/v1/workspaces.$workspaceId.files.$itemId.content.ts
@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
-import { readWorkspaceKernelFileContent } from "#/features/workspaces/kernel/workspace-kernel-access";
+import { readWorkspaceKernelFileContentStream } from "#/features/workspaces/kernel/workspace-kernel-access";
import { WorkspaceForbiddenError } from "#/features/workspaces/server/permissions";
import { apiError, getRequestId } from "#/lib/api/http";
import { getSessionFromRequest } from "#/lib/auth-queries.server";
@@ -20,22 +20,24 @@ async function handleWorkspaceFileContent(request: Request, workspaceId: string,
);
}
- const content = await readWorkspaceKernelFileContent({
+ const content = await readWorkspaceKernelFileContentStream({
workspaceId,
userId: session.user.id,
itemId,
});
- const body = new Uint8Array(content.bytes).buffer;
- return new Response(body, {
- headers: {
- "cache-control": "private, max-age=60",
- "content-disposition": `inline; filename="${sanitizeHeaderFileName(content.fileName)}"`,
- "content-length": String(body.byteLength),
- "content-type": content.contentType,
- "x-request-id": requestId,
- },
+ const headers = new Headers({
+ "cache-control": "private, max-age=60",
+ "content-disposition": `inline; filename="${sanitizeHeaderFileName(content.fileName)}"`,
+ "content-type": content.contentType,
+ "x-request-id": requestId,
});
+
+ if (content.sizeBytes != null && content.sizeBytes >= 0) {
+ headers.set("content-length", String(content.sizeBytes));
+ }
+
+ return new Response(content.stream, { headers });
} catch (error) {
if (error instanceof WorkspaceForbiddenError) {
return apiError(