Skip to content

Commit 0cf7869

Browse files
claudemolefrog
authored andcommitted
Use nanosecond mtime for workspace file ETag
The `/fs/` validator was size + millisecond mtime (`Math.trunc`). This route serves agent-regenerated files at stable paths, so a same-length rewrite landing inside one millisecond tick kept an identical validator and the new `no-cache` 304 path would pin stale media in the browser. Switch to size + nanosecond mtime via `statSync(path, { bigint: true })`, which closes the realistic rapid-rewrite window without hashing the (possibly huge) file on every request. A tool that deliberately preserves mtime with same-size/different bytes could still collide — only a content hash defeats that, and re-reading a video per request isn't worth it when agents rewrite files normally. Addresses PR review feedback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGBXW5iHAsc8K2543Z7BU
1 parent f9bb935 commit 0cf7869

2 files changed

Lines changed: 41 additions & 5 deletions

File tree

server/applets.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// `%%MOI_APPLET_API_BASE%%` sentinel (for RPC + `fileUrl`), swapped to the real
1313
// `/api/workspaces/<id>` base when served — so the on-disk bundle is
1414
// workspace-agnostic.
15-
import { realpathSync } from 'node:fs'
15+
import { realpathSync, statSync } from 'node:fs'
1616
import { mkdir, readdir, rm } from 'node:fs/promises'
1717
import { dirname, join, resolve, sep } from 'path'
1818

@@ -303,8 +303,8 @@ export function resolveWorkspaceMediaFile(workspaceRoot: string, tail: string):
303303
// stored by a shared/edge cache sitting in front of moi, or one user's video
304304
// could be served to the next, and an edited file could be served stale from a
305305
// stable url (the applet-bundle incident, but with private bytes). `private`
306-
// keeps them off the edge; an ETag (size+mtime) + `no-cache` lets the browser
307-
// revalidate cheaply (304) while guaranteeing a re-fetch when the file changes.
306+
// keeps them off the edge; a strong ETag (size + nanosecond mtime) + `no-cache`
307+
// lets the browser revalidate cheaply (304) while re-fetching when it changes.
308308
export async function serveWorkspaceFile(
309309
workspaceRoot: string,
310310
tail: string,
@@ -315,10 +315,24 @@ export async function serveWorkspaceFile(
315315
if (realTarget instanceof Response) return realTarget
316316
const file = Bun.file(realTarget)
317317

318-
const size = file.size
318+
let stat: ReturnType<typeof statSync>
319+
try {
320+
stat = statSync(realTarget, { bigint: true })
321+
} catch {
322+
return new Response('Not found', { status: 404 })
323+
}
324+
const size = Number(stat.size)
319325
const type = file.type || 'application/octet-stream'
320326

321-
const etag = `"${size}-${Math.trunc(file.lastModified)}"`
327+
// Validator = size + NANOSECOND mtime. This route serves agent-regenerated
328+
// files at stable paths, so a weak validator that returns a false 304 pins
329+
// stale media in the browser. Truncating mtime to whole milliseconds (or
330+
// seconds) can collide when a same-length rewrite lands inside one tick; ns
331+
// resolution closes that window without hashing the (possibly huge) file on
332+
// every request. A tool that deliberately preserves mtime with same-size but
333+
// different bytes could still collide — only a content hash defeats that, and
334+
// it's not worth re-reading a video per request when agents rewrite normally.
335+
const etag = `"${size}-${stat.mtimeNs}"`
322336
const cache = { ETag: etag, 'Cache-Control': 'private, no-cache' }
323337
if (ifNoneMatch === etag) return new Response(null, { status: 304, headers: cache })
324338

server/test/applets.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,28 @@ describe('serveWorkspaceFile (fileUrl streaming)', () => {
237237
expect(await revalidated.text()).toBe('')
238238
})
239239

240+
test('workspace ETag uses nanosecond mtime — survives sub-millisecond rewrites', async () => {
241+
// The file is agent-regenerated at a stable path, so a coarse validator can
242+
// return a false 304 and pin stale media. Guard the resolution: a nanosecond
243+
// epoch timestamp is ~19 digits, a millisecond one ~13, so a revert to
244+
// `Math.trunc(mtimeMs)` (which could collide on a same-length rewrite inside
245+
// one tick) is caught here.
246+
seedFile('clips/a.mp4', new Uint8Array([1, 2, 3, 4]))
247+
const res = await serveWorkspaceFile(WS, 'clips/a.mp4')
248+
const mtimePart = res.headers.get('etag')!.replace(/"/g, '').split('-')[1]
249+
expect(mtimePart.length).toBeGreaterThanOrEqual(16)
250+
})
251+
252+
test('a same-length rewrite changes the ETag (no false 304)', async () => {
253+
seedFile('clips/a.mp4', new Uint8Array([1, 2, 3, 4]))
254+
const etag1 = (await serveWorkspaceFile(WS, 'clips/a.mp4')).headers.get('etag')!
255+
// Overwrite with DIFFERENT bytes of the SAME length — the reviewer's case.
256+
seedFile('clips/a.mp4', new Uint8Array([9, 8, 7, 6]))
257+
const res = await serveWorkspaceFile(WS, 'clips/a.mp4', undefined, etag1)
258+
expect(res.status).toBe(200) // the stale validator must not win a 304
259+
expect(res.headers.get('etag')).not.toBe(etag1)
260+
})
261+
240262
test('a range request still carries the private cache headers', async () => {
241263
seedFile('clips/a.mp4', new Uint8Array([10, 11, 12, 13, 14, 15]))
242264
const res = await serveWorkspaceFile(WS, 'clips/a.mp4', 'bytes=1-3')

0 commit comments

Comments
 (0)