Skip to content

Commit 78c16ec

Browse files
committed
fix(files): move the unrenderable error out of the 'use server' module
file-utils.server.ts carries 'use server', whose exports must all be async functions, so exporting an error class from it failed the production build with 67 cascading errors. The class now lives in the plain file-utils.ts beside the other shared file helpers, which also lets the hydration path import it directly instead of through a dynamic import. Also bounds how long a failed render is remembered. The isolated-vm engine cannot tell a bad source from a sandbox outage, so a permanent entry let one transient failure block re-rendering that source for the life of the process. Entries now expire after five minutes: long enough to stop a read loop spending a sandbox run per read, short enough that an outage self-heals without a deploy.
1 parent abe134a commit 78c16ec

6 files changed

Lines changed: 70 additions & 56 deletions

File tree

apps/sim/lib/copilot/tools/server/files/doc-compile.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,16 @@ function compiledCacheSet(key: string, buffer: Buffer): void {
500500
* {@link compiledDocCache}, so an edit to the file produces a new key and gets a
501501
* fresh attempt.
502502
*/
503-
const unrenderableSources = new Set<string>()
503+
const unrenderableSources = new Map<string, number>()
504+
505+
/**
506+
* How long a failed render is remembered. Bounded rather than permanent because the
507+
* isolated-vm engine cannot tell a bad source from a sandbox outage, so an infra
508+
* blip would otherwise strand a perfectly renderable document for the life of the
509+
* process. Long enough to stop a read loop spending a sandbox run per read, short
510+
* enough that a transient failure self-heals without a deploy.
511+
*/
512+
const UNRENDERABLE_TTL_MS = 5 * 60 * 1000
504513

505514
/**
506515
* Renders in flight, keyed identically to {@link compiledDocCache}. An artifact
@@ -538,9 +547,17 @@ function coalesceRender(
538547

539548
function markUnrenderable(key: string): void {
540549
if (unrenderableSources.size >= MAX_COMPILED_DOC_CACHE) {
541-
unrenderableSources.delete(unrenderableSources.values().next().value as string)
550+
unrenderableSources.delete(unrenderableSources.keys().next().value as string)
542551
}
543-
unrenderableSources.add(key)
552+
unrenderableSources.set(key, Date.now() + UNRENDERABLE_TTL_MS)
553+
}
554+
555+
function isKnownUnrenderable(key: string): boolean {
556+
const expiresAt = unrenderableSources.get(key)
557+
if (expiresAt === undefined) return false
558+
if (expiresAt > Date.now()) return true
559+
unrenderableSources.delete(key)
560+
return false
544561
}
545562

546563
/**
@@ -645,7 +662,7 @@ export async function resolveServableDocBytes(args: {
645662
return passthrough()
646663
}
647664

648-
if (unrenderableSources.has(renderKey)) {
665+
if (isKnownUnrenderable(renderKey)) {
649666
return unrendered('previous render attempt for these bytes failed')
650667
}
651668

apps/sim/lib/execution/sandbox/bundles/docx.cjs

Lines changed: 13 additions & 13 deletions
Large diffs are not rendered by default.

apps/sim/lib/uploads/utils/file-utils.server.ts

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
processSingleFileToUserFile,
2626
type RawFileInput,
2727
resolveTrustedFileContext,
28+
UnrenderableDocumentError,
2829
} from '@/lib/uploads/utils/file-utils'
2930
import { verifyFileAccess } from '@/app/api/files/authorization'
3031
import type { UserFile } from '@/executor/types'
@@ -356,31 +357,6 @@ export interface ServableFile {
356357
unrendered?: boolean
357358
}
358359

359-
/**
360-
* Thrown when a file's stored bytes are not the format its name claims and could
361-
* not be rendered into it.
362-
*
363-
* Every caller of {@link downloadServableFileFromStorage} hands the bytes to
364-
* something that expects the real document — an email attachment, a cloud upload,
365-
* a zip entry, a provider attachment — and none of them can tell a rendered
366-
* artifact from raw generation source once it is just a Buffer. Returning the
367-
* bytes with an honest content type would still be wrong, because the filename
368-
* travels separately and downstream re-infers the type from it. Failing here is
369-
* the only place that reliably prevents source text going out under a `.pdf`.
370-
*
371-
* The file-serve route deliberately does NOT go through this helper: it resolves
372-
* bytes directly and keeps the graceful passthrough, because a human downloading
373-
* the file and seeing what it actually is has a use for it.
374-
*/
375-
export class UnrenderableDocumentError extends Error {
376-
constructor(fileName: string) {
377-
super(
378-
`File ${fileName} could not be rendered; its stored bytes are not the format its name claims.`
379-
)
380-
this.name = 'UnrenderableDocumentError'
381-
}
382-
}
383-
384360
/**
385361
* Downloads a workspace file and resolves it to its SERVABLE bytes — the variant
386362
* every tool that hands a file to an external service (email attachments, chat

apps/sim/lib/uploads/utils/file-utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,3 +1031,32 @@ export function getViewerUrl(fileKey: string, workspaceId?: string): string | nu
10311031

10321032
return `/workspace/${resolvedWorkspaceId}/files/${fileKey}`
10331033
}
1034+
1035+
/**
1036+
* Thrown when a file's stored bytes are not the format its name claims and could
1037+
* not be rendered into it.
1038+
*
1039+
* Every caller of `downloadServableFileFromStorage` hands the bytes to something
1040+
* that expects the real document — an email attachment, a cloud upload, a zip
1041+
* entry, a provider attachment — and none of them can tell a rendered artifact
1042+
* from raw generation source once it is just a Buffer. Returning the bytes under
1043+
* an honest content type would still be wrong, because the filename travels
1044+
* separately and downstream re-infers the type from it. Failing at the download
1045+
* boundary is what reliably keeps source text from going out under a `.pdf`.
1046+
*
1047+
* Lives here rather than beside that function because `file-utils.server.ts` is a
1048+
* `'use server'` module, whose exports must all be async functions — a class
1049+
* export there fails the build.
1050+
*
1051+
* The file-serve route deliberately does NOT go through that helper: it resolves
1052+
* bytes directly and keeps the graceful passthrough, which is the one place a
1053+
* human downloading the file has a use for them.
1054+
*/
1055+
export class UnrenderableDocumentError extends Error {
1056+
constructor(fileName: string) {
1057+
super(
1058+
`File ${fileName} could not be rendered; its stored bytes are not the format its name claims.`
1059+
)
1060+
this.name = 'UnrenderableDocumentError'
1061+
}
1062+
}

apps/sim/lib/uploads/utils/user-file-base64.server.test.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,6 @@ const RENDERED_PDF_BUFFER = Buffer.from('%PDF-1.4 rendered', 'utf8')
4747

4848
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
4949
downloadFileFromStorage: mockDownloadFile,
50-
// Mirrors the real module: resolveBase64 narrows on this class to decide whether a
51-
// strict caller sees the failure, so omitting it from the mock would break that
52-
// check rather than exercise it.
53-
UnrenderableDocumentError: class UnrenderableDocumentError extends Error {
54-
constructor(fileName: string) {
55-
super(`File ${fileName} could not be rendered`)
56-
this.name = 'UnrenderableDocumentError'
57-
}
58-
},
5950
downloadServableFileFromStorage: async (
6051
file: UserFile,
6152
requestId: string,

apps/sim/lib/uploads/utils/user-file-base64.server.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
ExecutionResourceLimitError,
2828
isExecutionResourceLimitError,
2929
} from '@/lib/execution/resource-errors'
30+
import { UnrenderableDocumentError } from '@/lib/uploads/utils/file-utils'
3031
import type { UserFile } from '@/executor/types'
3132

3233
const INLINE_BASE64_JSON_OVERHEAD_BYTES = 512 * 1024
@@ -453,11 +454,11 @@ async function resolveBase64(
453454
// graph (remote sandbox, sandbox task runner, execution limits), and a static
454455
// import here would load all of it for every hydration consumer — mirroring
455456
// the deliberate dynamic import in file-utils.server.ts.
456-
const [{ isDocNotReadyError }, { UnrenderableDocumentError }] = await Promise.all([
457-
import('@/lib/uploads/utils/servable-file-response'),
458-
import('@/lib/uploads/utils/file-utils.server'),
459-
])
460-
if (isDocNotReadyError(error) || error instanceof UnrenderableDocumentError) {
457+
if (error instanceof UnrenderableDocumentError) {
458+
throw error
459+
}
460+
const { isDocNotReadyError } = await import('@/lib/uploads/utils/servable-file-response')
461+
if (isDocNotReadyError(error)) {
461462
throw error
462463
}
463464
}

0 commit comments

Comments
 (0)