Skip to content

Commit f47a8df

Browse files
committed
Fix scratchpad asset sweep data loss and hardening from code review
Addresses correctness findings in the file-backed scratchpad assets work: - Sweep race / clear resurrection (data loss): the orphan sweep keyed its grace window on file mtime, so a long-referenced image got zero grace the instant it became unreferenced (e.g. `clear` racing an open tab's pending autosave), letting the sweep permanently delete a file the surviving snapshot still pointed at. Anchor the grace to when a file is FIRST observed unreferenced instead, tracked in-memory. This also subsumes the dedup mtime-not-refreshed case (mtime no longer factors into the decision). - `delete`/re-add leaked asset files: `delete` removed only the shape and its bindings, leaving the asset record to pin its file against the sweep forever (asymmetric with the `clear` fix). Drop the asset record when the deleted or replaced shape was its last user (shared assets are preserved). - Atomic asset writes: write via a temp sibling + rename so a crash or full disk mid-write can't leave a truncated file at the content-addressed path that the exists() skip would then serve forever. - Stored-XSS hardening: serve assets with Content-Disposition: attachment and X-Content-Type-Options: nosniff so a pasted/uploaded SVG can't execute as script in the app origin on direct navigation (img/video/fetch ignore these). - assetStore held in a ref, not useMemo (which React may discard): a fresh `assets` identity makes <Tldraw> rebuild its store and remount the editor, dropping unsaved edits and resetting the camera. - Flag `missing` in `read` for shapes whose asset RECORD is absent, not only when the file is gone, so the agent isn't handed a healthy-looking image. - Sync the CLI's read-image extension map with the server (avif/apng/video), so those assets save with a real extension instead of `.bin`. - Derive ASSET_FILE_RE and the .bak scanner from one shared pattern so they can't drift and delete backup-referenced files; add image/apng to MIME_EXT. Tests updated for the new grace semantics; new regression tests cover the clear-race data loss and the delete reclaim path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCbMMxrNwyPAtJP5qryT6W
1 parent bf6b170 commit f47a8df

8 files changed

Lines changed: 184 additions & 57 deletions

File tree

client/components/Scratchpad.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,4 @@
1-
import {
2-
Fragment,
3-
type ReactElement,
4-
useCallback,
5-
useEffect,
6-
useMemo,
7-
useRef,
8-
useState
9-
} from 'react'
1+
import { Fragment, type ReactElement, useCallback, useEffect, useRef, useState } from 'react'
102

113
import {
124
type Editor,
@@ -785,8 +777,14 @@ export function Scratchpad() {
785777
// Reactive handle to the mounted editor, used to render the custom tool bar.
786778
const [editor, setEditor] = useState<Editor | null>(null)
787779
const { loaded, snapshot, skew, flagSkew } = useScratchpadSnapshot(workspaceId)
788-
// Stable per workspace — a new identity would remount tldraw's asset handling.
789-
const assetStore = useMemo(() => makeAssetStore(workspaceId), [workspaceId])
780+
// Stable identity per workspace, held in a ref rather than useMemo (which React
781+
// may discard): a fresh `assets` identity makes <Tldraw> rebuild its store and
782+
// remount the editor, dropping unsaved edits and resetting the camera.
783+
const assetStoreRef = useRef<{ id: string; store: TLAssetStore } | null>(null)
784+
if (assetStoreRef.current?.id !== workspaceId) {
785+
assetStoreRef.current = { id: workspaceId, store: makeAssetStore(workspaceId) }
786+
}
787+
const assetStore = assetStoreRef.current.store
790788

791789
const save = useCallback(() => {
792790
const editor = editorRef.current

server/api.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,12 @@ one.get('/scratchpad/assets/:file', async c => {
417417
return new Response(resolved.file, {
418418
headers: {
419419
'Content-Type': resolved.mimeType,
420-
'Cache-Control': 'public, max-age=31536000, immutable'
420+
'Cache-Control': 'public, max-age=31536000, immutable',
421+
// Assets are only ever consumed via <img>/<video>/fetch (which all ignore
422+
// this), so force a download on direct navigation and block MIME sniffing:
423+
// a pasted/uploaded SVG must not execute as script in the app origin.
424+
'Content-Disposition': 'attachment',
425+
'X-Content-Type-Options': 'nosniff'
421426
}
422427
})
423428
})

server/cli.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,14 +1310,21 @@ const scratchView = defineCommand({
13101310
}
13111311
})
13121312

1313-
// Image data URL mime → file extension, for naming the saved file.
1313+
// Image/video data URL mime → file extension, for naming the saved file. Kept in
1314+
// sync with the server's MIME_EXT (scratchpad-assets.ts) so avif/apng/video assets
1315+
// round-trip through `read-image` with a real extension instead of `.bin`.
13141316
const IMAGE_EXT: Record<string, string> = {
13151317
'image/png': 'png',
13161318
'image/jpeg': 'jpg',
13171319
'image/jpg': 'jpg',
13181320
'image/webp': 'webp',
13191321
'image/gif': 'gif',
1320-
'image/svg+xml': 'svg'
1322+
'image/svg+xml': 'svg',
1323+
'image/avif': 'avif',
1324+
'image/apng': 'apng',
1325+
'video/mp4': 'mp4',
1326+
'video/webm': 'webm',
1327+
'video/quicktime': 'mov'
13211328
}
13221329

13231330
const scratchReadImage = defineCommand({

server/scratchpad-assets.ts

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readdir, stat, unlink } from 'node:fs/promises'
1+
import { readdir, rename, stat, unlink } from 'node:fs/promises'
22
import { join } from 'node:path'
33

44
import type { ScratchpadDoc } from './scratchpad'
@@ -30,8 +30,11 @@ const ASSET_SRC_PREFIX = 'asset:'
3030
// The only file names we ever create or serve: `asset-<sha256 hex>.<short ext>`.
3131
// The `asset-` prefix marks the files as canvas assets, keeping the sidecar dir
3232
// unambiguous if it ever holds anything else. Anything else (traversal attempts
33-
// included) is rejected at the boundary.
34-
const ASSET_FILE_RE = /^asset-[0-9a-f]{64}\.[a-z0-9]{1,8}$/
33+
// included) is rejected at the boundary. The core pattern is shared with the
34+
// `.bak` scanner below (BAK_SRC_RE) so the two can never drift — a drift would
35+
// let the sweep delete files the schema-change backup still references.
36+
const ASSET_NAME_PATTERN = 'asset-[0-9a-f]{64}\\.[a-z0-9]{1,8}'
37+
const ASSET_FILE_RE = new RegExp(`^${ASSET_NAME_PATTERN}$`)
3538

3639
const MIME_EXT: Record<string, string> = {
3740
'image/png': 'png',
@@ -40,6 +43,7 @@ const MIME_EXT: Record<string, string> = {
4043
'image/gif': 'gif',
4144
'image/svg+xml': 'svg',
4245
'image/avif': 'avif',
46+
'image/apng': 'apng',
4347
'video/mp4': 'mp4',
4448
'video/webm': 'webm',
4549
'video/quicktime': 'mov'
@@ -82,8 +86,17 @@ export async function storeScratchpadAsset(
8286
hasher.update(bytes)
8387
const ext = MIME_EXT[mimeType.split(';')[0].trim().toLowerCase()] ?? 'bin'
8488
const fileName = `asset-${hasher.digest('hex')}.${ext}`
85-
const path = join(getScratchpadAssetsDir(workspacePath), fileName)
86-
if (!(await Bun.file(path).exists())) await Bun.write(path, bytes)
89+
const dir = getScratchpadAssetsDir(workspacePath)
90+
const path = join(dir, fileName)
91+
// Content-addressed, so an existing file already holds these exact bytes.
92+
// Write through a temp sibling + atomic rename: a crash or full disk mid-write
93+
// must never leave a truncated file at `path`, whose name would then claim a
94+
// hash its bytes don't match and be served forever by the exists() skip above.
95+
if (!(await Bun.file(path).exists())) {
96+
const tmp = join(dir, `.tmp-${crypto.randomUUID()}`)
97+
await Bun.write(tmp, bytes)
98+
await rename(tmp, path)
99+
}
87100
return { src: `${ASSET_SRC_PREFIX}${fileName}` }
88101
}
89102

@@ -124,20 +137,30 @@ export async function extractInlineAssets(
124137
return rewritten ? { ...document, store: rewritten } : document
125138
}
126139

127-
// Files uploaded but not yet referenced by a saved snapshot: the browser's
128-
// TLAssetStore uploads first, and the asset record only lands on disk with the
129-
// next autosave (~500ms-1s later). The grace keeps the sweep from eating that
130-
// window; anything unreferenced *and* old is a genuine orphan (deleted image,
131-
// cleared canvas, abandoned upload).
140+
// A file is reclaimed only after it has stayed unreferenced for this long. The
141+
// clock starts when the sweep FIRST observes the file unreferenced — NOT when
142+
// the file was written — so every reference drop gets the same recovery window a
143+
// fresh upload gets. (Keying on file mtime instead gave a long-referenced image
144+
// zero grace the instant it went unreferenced — e.g. `clear` racing an open
145+
// tab's pending autosave — letting the sweep permanently delete a file the
146+
// surviving snapshot still pointed at.) The window covers the upload→autosave
147+
// gap and a stale tab that re-PUTs an old, still-referencing document alike.
132148
const SWEEP_GRACE_MS = 5 * 60_000
133149

150+
// When each still-unreferenced asset file was first seen unreferenced, keyed by
151+
// absolute path. In-memory and per-process: a restart just restarts the clock
152+
// (delaying cleanup, never losing data). Entries drop as soon as a file is
153+
// referenced again or unlinked, so this only ever holds currently-unreferenced
154+
// files within their grace window.
155+
const firstUnreferencedAt = new Map<string, number>()
156+
134157
// Asset file names referenced by the schema-change backup (`.scratchpad.json.bak`
135158
// — see backupOnSchemaChange in scratchpad.ts). The sweep must not eat those:
136159
// the .bak is the manual escape hatch after a downgrade, and restoring it with
137160
// its images gone would defeat the point. A raw regex scan (no JSON parse — the
138161
// .bak may be a huge legacy file), cached by mtime since the file only changes
139-
// on a schema upgrade. The pattern is `asset:` + ASSET_FILE_RE.
140-
const BAK_SRC_RE = /asset:(asset-[0-9a-f]{64}\.[a-z0-9]{1,8})/g
162+
// on a schema upgrade. Built from the same ASSET_NAME_PATTERN as ASSET_FILE_RE.
163+
const BAK_SRC_RE = new RegExp(`asset:(${ASSET_NAME_PATTERN})`, 'g')
141164
const bakRefsCache = new Map<string, { mtimeMs: number; refs: Set<string> }>()
142165
async function bakReferencedAssets(bakFile: string): Promise<Set<string>> {
143166
try {
@@ -159,7 +182,8 @@ async function bakReferencedAssets(bakFile: string): Promise<Set<string>> {
159182
export async function sweepOrphanAssets(
160183
workspacePath: string,
161184
document: ScratchpadDoc,
162-
bakFile?: string
185+
bakFile?: string,
186+
now: number = Date.now()
163187
): Promise<void> {
164188
try {
165189
const dir = getScratchpadAssetsDir(workspacePath)
@@ -176,14 +200,24 @@ export async function sweepOrphanAssets(
176200
}
177201
if (bakFile) for (const name of await bakReferencedAssets(bakFile)) referenced.add(name)
178202

179-
const now = Date.now()
180203
for (const name of files) {
181-
if (!ASSET_FILE_RE.test(name) || referenced.has(name)) continue
204+
if (!ASSET_FILE_RE.test(name)) continue
182205
const path = join(dir, name)
183-
try {
184-
const { mtimeMs } = await stat(path)
185-
if (now - mtimeMs > SWEEP_GRACE_MS) await unlink(path)
186-
} catch {}
206+
if (referenced.has(name)) {
207+
firstUnreferencedAt.delete(path) // referenced (again) — reset its clock
208+
continue
209+
}
210+
const since = firstUnreferencedAt.get(path)
211+
if (since === undefined) {
212+
firstUnreferencedAt.set(path, now) // first seen unreferenced — start clock
213+
continue
214+
}
215+
if (now - since > SWEEP_GRACE_MS) {
216+
try {
217+
await unlink(path)
218+
firstUnreferencedAt.delete(path)
219+
} catch {}
220+
}
187221
}
188222
} catch {}
189223
}

server/scratchpad-executor.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,14 @@ async function applyAddImage(
214214
op: Extract<ScratchOp, { kind: 'add-image' }>
215215
): Promise<ScratchOpResult> {
216216
const pageId = firstPageId(store)
217+
// Re-adding under an existing id replaces that shape; note its current asset so
218+
// we can drop it below once nothing else references it (else its file leaks).
219+
const prev = store.get(createShapeId(op.name)) as unknown as
220+
| { props?: { assetId?: unknown } }
221+
| undefined
222+
const prevAssetId = typeof prev?.props?.assetId === 'string' ? prev.props.assetId : undefined
217223
const { data, w, h, mimeType, name } = await processCanvasImage(op.path, op.quality ?? 'lo')
218-
const { src } = await storeScratchpadAsset(workspacePath, new Uint8Array(data), mimeType)
224+
const { src } = await storeScratchpadAsset(workspacePath, data, mimeType)
219225
const assetId = AssetRecordType.createId()
220226
store.put([
221227
{
@@ -237,6 +243,11 @@ async function applyAddImage(
237243
props: { ...defaultProps('image'), w, h, assetId }
238244
})
239245
])
246+
// The replaced shape's old asset is now unreachable — drop it so the sweep can
247+
// reclaim its file.
248+
if (prevAssetId && prevAssetId !== assetId && !anyShapeUsesAsset(store, prevAssetId)) {
249+
store.remove([prevAssetId as unknown as TLRecord['id']])
250+
}
240251
return { name: op.name }
241252
}
242253

@@ -346,10 +357,17 @@ function applyOp(store: TLStore, op: ScratchOp): ScratchOpResult {
346357
}
347358
case 'delete': {
348359
const id = createShapeId(op.name)
360+
const shape = store.get(id) as unknown as { props?: { assetId?: unknown } } | undefined
361+
const assetId = typeof shape?.props?.assetId === 'string' ? shape.props.assetId : undefined
349362
// Remove the shape plus any binding that references it, or the leftover
350363
// binding would dangle and invalidate the snapshot.
351364
const ids = [id, ...bindingsTouching(store, op.name)]
352365
store.remove(ids)
366+
// If that was the last shape using the image's asset, drop the asset record
367+
// too so the post-save sweep can reclaim its file (mirrors `clear`).
368+
if (assetId && !anyShapeUsesAsset(store, assetId)) {
369+
store.remove([assetId as unknown as TLRecord['id']])
370+
}
353371
return { ok: true }
354372
}
355373
case 'clear': {
@@ -369,6 +387,18 @@ function applyOp(store: TLStore, op: ScratchOp): ScratchOpResult {
369387
}
370388
}
371389

390+
// True if any shape still references the given asset id. tldraw lets several
391+
// shapes share one asset (e.g. a duplicated image), so delete/replace only drops
392+
// the asset record when its last user is gone — leaving the file for the sweep.
393+
function anyShapeUsesAsset(store: TLStore, assetId: string): boolean {
394+
for (const record of store.allRecords()) {
395+
if (record.typeName !== 'shape') continue
396+
const props = (record as unknown as { props?: { assetId?: unknown } }).props
397+
if (props?.assetId === assetId) return true
398+
}
399+
return false
400+
}
401+
372402
// Ids of bindings whose start/end is the named shape.
373403
function bindingsTouching(store: TLStore, name: string): TLRecord['id'][] {
374404
const target = createShapeId(name)

server/scratchpad.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ export type ScratchShape = {
4747
// as-is. A legacy inline base64 blob is omitted (see omitBase64) — the agent
4848
// calls `moi scratch read-image`/`view` to actually see pixels.
4949
src?: string
50-
// True when `src` is an `asset:` reference whose backing file is gone from
51-
// .moi/.scratchpad (sidecar dir lost or pruned) — `read-image` will error and
52-
// the canvas shows a broken image. Absent otherwise.
50+
// True when the shape references an asset that can't be resolved to pixels: an
51+
// `asset:` file gone from .moi/.scratchpad (sidecar dir lost or pruned), or the
52+
// asset record absent entirely — `read-image` will error and the canvas shows a
53+
// broken image. Absent otherwise.
5354
missing?: true
5455
}
5556

@@ -236,6 +237,11 @@ export async function readScratchpadShapes(workspacePath: string): Promise<Scrat
236237
const h = typeof r.props?.h === 'number' ? r.props.h : undefined
237238
const assetId = typeof r.props?.assetId === 'string' ? r.props.assetId : undefined
238239
const rawSrc = assetId !== undefined ? assetSrc.get(assetId) : undefined
240+
// Flag `missing` when a shape references an asset we can't resolve to pixels:
241+
// its backing file is gone (assetGone), or the asset record is absent / has a
242+
// non-string src (rawSrc undefined). Either way `read-image` can't return
243+
// bytes, so warn from `read` rather than let it fail later.
244+
const missing = assetId !== undefined && (assetGone.has(assetId) || rawSrc === undefined)
239245
shapes.push({
240246
id: (r.id ?? '').replace(/^shape:/, ''),
241247
type: r.type ?? 'unknown',
@@ -248,7 +254,7 @@ export async function readScratchpadShapes(workspacePath: string): Promise<Scrat
248254
return text !== undefined ? { text: omitBase64(text) } : {}
249255
})(),
250256
...(rawSrc !== undefined ? { src: omitBase64(rawSrc) } : {}),
251-
...(assetId !== undefined && assetGone.has(assetId) ? { missing: true as const } : {})
257+
...(missing ? { missing: true as const } : {})
252258
})
253259
}
254260
return shapes

server/test/scratchpad-assets.test.ts

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
22
import { mkdtempSync, rmSync } from 'node:fs'
3-
import { readdir, utimes } from 'node:fs/promises'
3+
import { readdir } from 'node:fs/promises'
44
import { join } from 'path'
55

66
import {
@@ -161,34 +161,62 @@ describe('missing asset files (dangling references)', () => {
161161
})
162162

163163
describe('sweepOrphanAssets', () => {
164-
test('deletes old unreferenced files, keeps referenced and recent ones', async () => {
164+
test('reclaims a file only after it has stayed unreferenced past the grace window', async () => {
165165
const { src } = await storeScratchpadAsset(WS, PNG_BYTES, 'image/png')
166166
const referenced = assetSrcFileName(src)!
167167
const { src: orphanSrc } = await storeScratchpadAsset(
168168
WS,
169169
new Uint8Array([1, 2, 3]),
170170
'image/png'
171171
)
172-
const oldOrphan = assetSrcFileName(orphanSrc)!
173-
const { src: freshSrc } = await storeScratchpadAsset(WS, new Uint8Array([4, 5, 6]), 'image/png')
174-
const freshOrphan = assetSrcFileName(freshSrc)!
175-
176-
// Age everything past the grace window, then re-touch the fresh orphan.
172+
const orphan = assetSrcFileName(orphanSrc)!
177173
const dir = getScratchpadAssetsDir(WS)
178-
const past = new Date(Date.now() - 60 * 60_000)
179-
for (const name of [referenced, oldOrphan]) await utimes(join(dir, name), past, past)
180-
181174
const doc: ScratchpadDoc = {
182175
store: {
183176
'asset:a1': { id: 'asset:a1', typeName: 'asset', type: 'image', props: { src } }
184177
}
185178
}
186-
await sweepOrphanAssets(WS, doc)
187179

180+
const t0 = 1_000_000
181+
// First sweep only starts the orphan's clock; nothing is deleted yet.
182+
await sweepOrphanAssets(WS, doc, undefined, t0)
183+
expect(await readdir(dir)).toContain(orphan)
184+
185+
// Still within the grace window — kept.
186+
await sweepOrphanAssets(WS, doc, undefined, t0 + 60_000)
187+
expect(await readdir(dir)).toContain(orphan)
188+
189+
// Past the window, the still-unreferenced orphan is reclaimed; the referenced
190+
// file was never on the clock and survives.
191+
await sweepOrphanAssets(WS, doc, undefined, t0 + 6 * 60_000)
188192
const left = await readdir(dir)
189193
expect(left).toContain(referenced)
190-
expect(left).toContain(freshOrphan)
191-
expect(left).not.toContain(oldOrphan)
194+
expect(left).not.toContain(orphan)
195+
})
196+
197+
test('a long-referenced file gets a fresh grace window when it becomes unreferenced', async () => {
198+
const { src } = await storeScratchpadAsset(WS, PNG_BYTES, 'image/png')
199+
const name = assetSrcFileName(src)!
200+
const dir = getScratchpadAssetsDir(WS)
201+
const withImg: ScratchpadDoc = {
202+
store: { 'asset:a1': { id: 'asset:a1', typeName: 'asset', type: 'image', props: { src } } }
203+
}
204+
const empty: ScratchpadDoc = { store: {} }
205+
206+
const t0 = 5_000_000
207+
// Referenced across sweeps spanning an hour.
208+
await sweepOrphanAssets(WS, withImg, undefined, t0)
209+
await sweepOrphanAssets(WS, withImg, undefined, t0 + 60 * 60_000)
210+
211+
// It becomes unreferenced (e.g. `clear`). Despite the file being an hour old,
212+
// the next sweep must NOT delete it — the clock starts here, not at write time.
213+
// This is the sweep-race data-loss regression the grace anchoring fixes.
214+
await sweepOrphanAssets(WS, empty, undefined, t0 + 60 * 60_000)
215+
expect(await readdir(dir)).toContain(name)
216+
217+
// Only after the grace elapses from the un-reference moment is it reclaimed.
218+
await sweepOrphanAssets(WS, empty, undefined, t0 + 60 * 60_000 + 6 * 60_000)
219+
expect(await readdir(dir)).not.toContain(name)
192220
})
193221

194222
test('keeps files the .bak backup still references', async () => {
@@ -209,14 +237,13 @@ describe('sweepOrphanAssets', () => {
209237
)
210238

211239
const dir = getScratchpadAssetsDir(WS)
212-
const past = new Date(Date.now() - 60 * 60_000)
213-
for (const name of [bakKept, orphan]) await utimes(join(dir, name), past, past)
214-
215-
// Current document references neither file.
216-
await sweepOrphanAssets(WS, { store: {} }, bakFile)
240+
// Current document references neither file; run twice past the grace window.
241+
const t0 = 2_000_000
242+
await sweepOrphanAssets(WS, { store: {} }, bakFile, t0)
243+
await sweepOrphanAssets(WS, { store: {} }, bakFile, t0 + 6 * 60_000)
217244

218245
const left = await readdir(dir)
219-
expect(left).toContain(bakKept)
246+
expect(left).toContain(bakKept) // pinned by the .bak, never on the clock
220247
expect(left).not.toContain(orphan)
221248
})
222249

0 commit comments

Comments
 (0)