From cd2c08187e7ea14e6b9e8d5eeb5c385c4c70b448 Mon Sep 17 00:00:00 2001 From: Jozkah <38704522+Jozkah@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:57:58 +0000 Subject: [PATCH 1/6] fix: keep a kept project file from replacing the session's own A project could carry a file of a part no section owns under the name the scan, reference or image is saved as. Writing it after the session's own files let it win in the archive. Kept files now go in only under names nothing else in the session writes. --- src/app/project.ts | 6 +++++- tests/projectSections.test.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/project.ts b/src/app/project.ts index 9e161e03..5649dbbb 100644 --- a/src/app/project.ts +++ b/src/app/project.ts @@ -167,12 +167,16 @@ export function collectProject( // a section in use writes its part over a kept one of the same key. const kept = keptParts() Object.assign(manifest, kept.parts) - members.push(...kept.members) const ctx = { sources, scanSaved: manifest.scan !== null, addMember: (name: string, bytes: Uint8Array) => members.push({ name, bytes }) } for (const section of projectSections()) { const part = section.collect(ctx) if (part !== undefined) manifest[section.key] = part } + // A kept file goes in only under a name nothing in this session writes: + // the project it came from may have used a name the scan, reference or + // image is saved under, and the archive keeps the last file of a name. + const taken = new Set(members.map((m) => m.name)) + members.push(...kept.members.filter((m) => !taken.has(m.name))) return { manifest, members } } diff --git a/tests/projectSections.test.ts b/tests/projectSections.test.ts index cf8f1826..c159d5b1 100644 --- a/tests/projectSections.test.ts +++ b/tests/projectSections.test.ts @@ -37,6 +37,16 @@ describe('parts of a project no plugin here owns', () => { expect(back.members.map((m) => m.name)).toContain('future/widget.bin') }) + it('never write over a file the session saves itself', () => { + // A project with no scan of its own can carry a file under the name the + // scan is saved as; once the user loads a scan, the scan must win. + const manifest = { ...saved({ future: { widgets: [] } }), scan: null } + keepUnownedParts(manifest, new Map([['scan.stl', new Uint8Array([6, 6, 6])]])) + const back = collectProject(sources(), null, 'test') + const scans = back.members.filter((m) => m.name === 'scan.stl') + expect(scans.map((m) => [...m.bytes])).toEqual([[1, 2, 3]]) + }) + it('count as something to save, even with nothing else', () => { expect(projectHasContent(emptySources())).toBe(false) keepUnownedParts(saved({ future: { widgets: [] } }), new Map()) From ab4d13242776d8b9a5e4b1c7093193ab323e7a82 Mon Sep 17 00:00:00 2001 From: Jozkah <38704522+Jozkah@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:00:18 +0000 Subject: [PATCH 2/6] fix: cap the size of a file opened from the address A ?open= link read the whole response into memory whatever its size, so a link could fill the tab or hand a huge file to a parser. The stated length is checked first and the body is read in pieces, stopping with an error once it passes 1 GiB. --- src/app/addressOpen.ts | 29 ++++++++++++++++++++++++++--- tests/addressOpen.test.ts | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/app/addressOpen.ts b/src/app/addressOpen.ts index 7ef360ff..7fd056f6 100644 --- a/src/app/addressOpen.ts +++ b/src/app/addressOpen.ts @@ -78,10 +78,14 @@ function nameOf(url: URL): string { } } +/** The most a file named in the address may hold: a link anyone can send + * must not be able to fill the tab's memory, and no project is larger. */ +export const MAX_ADDRESS_BYTES = 1024 * 1024 * 1024 + /** Fetch the file. The errors say what to do: the status for a refusal, * and for a failed request — most often a server that does not let this * site read it — the server to ask. */ -export async function fetchAddressFile({ url, name }: AddressOpen): Promise { +export async function fetchAddressFile({ url, name }: AddressOpen, maxBytes = MAX_ADDRESS_BYTES): Promise { let res: Response try { res = await fetch(url, { mode: 'cors' }) @@ -90,6 +94,25 @@ export async function fetchAddressFile({ url, name }: AddressOpen): Promise new Error(`${name} is too large to open from a link: the limit is ${Math.round(maxBytes / 1024 / 1024)} MB. Download it and open it from disk instead.`) + const length = Number(res.headers.get('content-length')) + if (length > maxBytes) throw tooLarge() + // The length a server states can be missing or wrong, so the body is read + // a piece at a time and given up on once it runs past the limit. + const chunks: Uint8Array[] = [] + let total = 0 + if (res.body) { + const reader = res.body.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + void reader.cancel().catch(() => {}) + throw tooLarge() + } + chunks.push(value) + } + } + return new File(chunks as BlobPart[], name, { type: res.headers.get('content-type') ?? '' }) } diff --git a/tests/addressOpen.test.ts b/tests/addressOpen.test.ts index 2ae51543..35b806b3 100644 --- a/tests/addressOpen.test.ts +++ b/tests/addressOpen.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { describe, expect, it } from 'vitest' -import { openFromAddress, workspaceFromAddress } from '../src/app/addressOpen' +import { afterEach, describe, expect, it } from 'vitest' +import { fetchAddressFile, openFromAddress, workspaceFromAddress } from '../src/app/addressOpen' const PAGE = 'https://scanruler.example/' const STORE = 'https://files.example/' @@ -82,3 +82,36 @@ describe('workspaceFromAddress', () => { expect(workspaceFromAddress({ search: '?open=https://h.example/a.stl&workspace=%20Thickness' })).toBe('thickness') }) }) + +describe('fetchAddressFile', () => { + const realFetch = globalThis.fetch + afterEach(() => { globalThis.fetch = realFetch }) + const target = { url: 'https://host.example/big.stl', name: 'big.stl' } + + it('refuses a file the server says is too large, before reading it', async () => { + globalThis.fetch = async () => new Response(new Uint8Array(8), { headers: { 'content-length': '2000' } }) + await expect(fetchAddressFile(target, 1000)).rejects.toThrow(/too large/) + }) + + it('stops reading a body that runs past the cap', async () => { + // A body with no length that keeps coming: the read stops at the cap + // rather than at the end, which an endless one never reaches. + let sent = 0 + const long = new ReadableStream({ + pull: (c) => { + sent += 100 + c.enqueue(new Uint8Array(100)) + if (sent >= 100_000) c.close() + }, + }) + globalThis.fetch = async () => new Response(long) + await expect(fetchAddressFile(target, 1000)).rejects.toThrow(/too large/) + expect(sent).toBeLessThan(5000) + }) + + it('reads a file within the cap', async () => { + globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3])) + const file = await fetchAddressFile(target, 1000) + expect([...new Uint8Array(await file.arrayBuffer())]).toEqual([1, 2, 3]) + }) +}) From 3d4ba5228ace726bc40d6a081eac579f0214f96f Mon Sep 17 00:00:00 2001 From: Jozkah <38704522+Jozkah@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:01:19 +0000 Subject: [PATCH 3/6] fix: refuse images whose header declares too many pixels Images were decoded on the page's thread whatever size their header declared, so a small file could claim billions of pixels and freeze or crash the tab. The PNG or JPEG header is now read first, and an image of more than 200 million pixels is refused with a message saying why. --- src/app/imports.ts | 3 ++- src/app/scanFiles.ts | 2 ++ src/core/flat/image.ts | 44 +++++++++++++++++++++++++++++++++++++++++ tests/flatImage.test.ts | 16 ++++++++++++++- 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/app/imports.ts b/src/app/imports.ts index c8024193..043d3831 100644 --- a/src/app/imports.ts +++ b/src/app/imports.ts @@ -11,7 +11,7 @@ import type { CreaseSetting } from '../core/geometry/crease' import type { Rigid } from '../core/deviation/rigid' import type { SceneManager } from '../viewer/SceneManager' import { grayscaleOf } from '../core/flat/edgeClient' -import { imagePixelsPerMm } from '../core/flat/image' +import { assertImageDecodable, imagePixelsPerMm } from '../core/flat/image' import { is3mfFile, isModelFile, isReferenceFile, isTextureFile } from '../core/formats' import type { MeshUnits } from '../core/meshUnits' import { useStore } from '../state/store' @@ -106,6 +106,7 @@ export async function prepareNominal(client: MeshWorkerClient, scene: SceneManag export async function prepareImage(file: File): Promise { const bytes = new Uint8Array(await file.arrayBuffer()) + assertImageDecodable(file.name, bytes) const meta = imagePixelsPerMm(bytes) const bitmap = await createImageBitmap(file, { imageOrientation: 'flipY' }) try { diff --git a/src/app/scanFiles.ts b/src/app/scanFiles.ts index 655e6db5..f73e1898 100644 --- a/src/app/scanFiles.ts +++ b/src/app/scanFiles.ts @@ -11,6 +11,7 @@ import { unzipSync } from 'fflate' import { is3mfFile, isModelFile, isTextureFile } from '../core/formats' +import { assertImageDecodable } from '../core/flat/image' import type { ObjCompanions, TextureImage } from '../core/parsers/objTexture' /** What came with a model file, by the file. */ @@ -113,6 +114,7 @@ export async function scanCompanions(name: string, bytes: Uint8Array, extras: re async function decodeImage(name: string, bytes: Uint8Array): Promise { // Colours as the file has them: no colour management, and no // premultiplying, which would darken whatever the alpha channel holds. + assertImageDecodable(name, bytes) const bitmap = await createImageBitmap(new Blob([bytes as BlobPart]), { colorSpaceConversion: 'none', premultiplyAlpha: 'none' }) try { const { width, height } = bitmap diff --git a/src/core/flat/image.ts b/src/core/flat/image.ts index 6067707b..7d46b00c 100644 --- a/src/core/flat/image.ts +++ b/src/core/flat/image.ts @@ -92,3 +92,47 @@ function jpegPixelsPerMm(b: Uint8Array): PixelsPerMm | null { } return null } + +/** The most pixels an image may hold to be decoded. A header of a few bytes + * can declare a picture of billions of pixels, which the browser would try + * to decode on the page's own thread; a flatbed scan of a whole A4 sheet at + * 1200 dpi is about 140 Mpx, so this leaves room for any real one. */ +export const MAX_IMAGE_PIXELS = 200_000_000 + +/** Throws when the image's header declares more pixels than can safely be + * decoded. PNG and JPEG are read; another format, or a header that cannot + * be read, is left to the browser's own limits. */ +export function assertImageDecodable(name: string, bytes: Uint8Array, maxPixels = MAX_IMAGE_PIXELS): void { + const size = isPng(bytes) ? pngSize(bytes) : isJpeg(bytes) ? jpegSize(bytes) : null + if (size && size.width * size.height > maxPixels) { + throw new Error(`${name} is too large to open: ${size.width} x ${size.height} pixels, more than the ${Math.round(maxPixels / 1e6)} million this app can decode.`) + } +} + +/** The size in the IHDR chunk, which a PNG is required to start with. */ +function pngSize(b: Uint8Array): { width: number; height: number } | null { + if (b.length < 24 || String.fromCharCode(b[12], b[13], b[14], b[15]) !== 'IHDR') return null + return { width: u32(b, 16), height: u32(b, 20) } +} + +/** The size in the first start-of-frame segment: any SOFn marker but DHT + * (C4), JPG (C8) and DAC (CC), which share the range. */ +function jpegSize(b: Uint8Array): { width: number; height: number } | null { + let at = 2 + while (at + 4 <= b.length) { + if (b[at] !== 0xff) return null + const marker = b[at + 1] + if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7)) { + at += 2 + continue + } + if (marker === 0xda || marker === 0xd9) return null + const length = u16(b, at + 2) + if (length < 2) return null + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return at + 9 <= b.length ? { width: u16(b, at + 7), height: u16(b, at + 5) } : null + } + at += 2 + length + } + return null +} diff --git a/tests/flatImage.test.ts b/tests/flatImage.test.ts index 1a81c355..ffa5ae77 100644 --- a/tests/flatImage.test.ts +++ b/tests/flatImage.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { describe, expect, it } from 'vitest' -import { imagePixelsPerMm } from '../src/core/flat/image' +import { assertImageDecodable, imagePixelsPerMm } from '../src/core/flat/image' // Containers built by hand, chunk by chunk — the parser reads metadata only, // so no pixel data (or valid CRC) is needed. @@ -88,3 +88,17 @@ describe('anything else', () => { expect(imagePixelsPerMm(new Uint8Array(0))).toBeNull() }) }) + +describe('assertImageDecodable', () => { + it('refuses an image whose header declares too many pixels to decode', () => { + // A few dozen bytes can declare 30000 x 30000: 3.6 GB once decoded. + const huge = png([chunk('IHDR', [...u32(30000), ...u32(30000), 8, 6, 0, 0, 0])]) + expect(() => assertImageDecodable('texture.png', huge)).toThrow(/too large/) + const sof = [0xff, 0xc0, ...u16(17), 8, ...u16(30000), ...u16(30000), 3, 1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1] + expect(() => assertImageDecodable('photo.jpg', new Uint8Array([0xff, 0xd8, ...sof]))).toThrow(/too large/) + }) + + it('lets an image of ordinary size through', () => { + expect(() => assertImageDecodable('texture.png', png([IHDR]))).not.toThrow() + }) +}) From cb06cc10a3ad9d9e89e3aaf03a5c986c4510fffa Mon Sep 17 00:00:00 2001 From: Jozkah <38704522+Jozkah@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:02:40 +0000 Subject: [PATCH 4/6] fix: let go of the store when an edge detection wait is given up The detect_edges command waited on a store subscription that only ended when the edge version changed, so a detection that never finished, or whose image was replaced, left the listener on the store. The wait now also ends when the image changes and when the command's signal aborts, removing the listener either way. --- src/App.tsx | 14 ++++------- src/commands/core/flat.ts | 4 ++-- src/commands/host.ts | 5 ++-- src/state/flatStore.ts | 24 +++++++++++++++++++ tests/edgeWait.test.ts | 49 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 14 deletions(-) create mode 100644 tests/edgeWait.test.ts diff --git a/src/App.tsx b/src/App.tsx index 0128fbee..a1ecadb3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -82,7 +82,7 @@ import { lengthInputText, unitWord, type DisplayUnits } from './core/units' import { useShell } from './state/shellStore' import { useMark } from './state/markStore' import { useThickness } from './state/thicknessStore' -import { imageScaleX, useFlat } from './state/flatStore' +import { edgesSettled, imageScaleX, useFlat } from './state/flatStore' import { sectionViewFrames, useSectionView } from './state/sectionViewStore' import type { FieldScale } from './core/field/colormap' import { deviationScale } from './core/deviation/deviation' @@ -1325,20 +1325,14 @@ export default function App() { flat: { openImage, closeImage: () => commitImage(null), - detectEdges: async (sensitivity) => { + detectEdges: async (sensitivity, signal) => { const flat = useFlat.getState() if (flat.subject.kind !== 'image' || !flatGrayRef.current) return flat.edgeCount - const before = flat.edgeVersion + const settled = edgesSettled(flat, signal) // A new sensitivity detects again by itself (the effect above). if (sensitivity !== flat.edgeSensitivity) flat.setEdgeSensitivity(sensitivity) else void runEdgeDetect() - await new Promise((resolve) => { - const stop = useFlat.subscribe((s) => { - if (s.edgeVersion === before) return - stop() - resolve() - }) - }) + await settled return useFlat.getState().edgeCount }, edgeIndex: () => activeEdgeIndex(), diff --git a/src/commands/core/flat.ts b/src/commands/core/flat.ts index 4f8a486e..4ff3d5c9 100644 --- a/src/commands/core/flat.ts +++ b/src/commands/core/flat.ts @@ -237,12 +237,12 @@ const detectEdges: Command<{ sensitivity?: number }> = { 'Find the edges in the image again at a sensitivity from 0 (only the strongest) to 1 (every faint one) — what the edge methods of flat.fit and the snapping of every place follow. The chains are numbered afresh. Needs the browser. Returns how many edge chains were found.', input: obj({ sensitivity: num('0 to 1.', { minimum: 0, maximum: 1 }) }), label: () => 'detect edges', - run: async ({ sensitivity }) => { + run: async ({ sensitivity }, ctx) => { const flat = commandHost().flat if (!flat) throw new CommandError('unavailable', 'Edge detection needs the browser.') requireSheet() showWorkspace('flat') - const chains = await flat.detectEdges(sensitivity ?? useFlat.getState().edgeSensitivity) + const chains = await flat.detectEdges(sensitivity ?? useFlat.getState().edgeSensitivity, ctx.signal) return { chains } }, } diff --git a/src/commands/host.ts b/src/commands/host.ts index ac1e54a2..8ef39e91 100644 --- a/src/commands/host.ts +++ b/src/commands/host.ts @@ -51,8 +51,9 @@ export interface FlatHost { openImage(file: File): Promise /** Take the image off the sheet, as a project without one does. */ closeImage(): void - /** Find the image's edges again at this sensitivity, 0 to 1. */ - detectEdges(sensitivity: number): Promise + /** Find the image's edges again at this sensitivity, 0 to 1. The wait for + * them is given up on when `signal` aborts. */ + detectEdges(sensitivity: number, signal?: AbortSignal): Promise /** What a pick on the sheet snaps to — the subject's own edges. */ edgeIndex(): EdgeIndex | null /** The sheet on the stage, or null with nothing on it. */ diff --git a/src/state/flatStore.ts b/src/state/flatStore.ts index 402e6b3e..e6e70c20 100644 --- a/src/state/flatStore.ts +++ b/src/state/flatStore.ts @@ -1167,3 +1167,27 @@ export const useFlat = create()((set, get) => ({ return { profiles } }), })) + +/** Settles once the edge detection running from `from` is over: its edges + * found or failed, or its image replaced, which leaves its answer to no one. + * A detection can also never finish, so the wait is given up on, and the + * store let go of, when `signal` aborts. */ +export function edgesSettled(from: Pick, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason) + const done = () => { + stop() + signal?.removeEventListener('abort', abort) + } + const abort = () => { + done() + reject(signal!.reason) + } + const stop = useFlat.subscribe((s) => { + if (s.edgeVersion === from.edgeVersion && s.imageVersion === from.imageVersion) return + done() + resolve() + }) + signal?.addEventListener('abort', abort) + }) +} diff --git a/tests/edgeWait.test.ts b/tests/edgeWait.test.ts new file mode 100644 index 00000000..ceb276e2 --- /dev/null +++ b/tests/edgeWait.test.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Waiting on an edge detection lets go of the store whatever becomes of the +// detection: done, failed, given up on by its caller, or its image gone. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { edgesSettled, useFlat } from '../src/state/flatStore' + +function countListeners() { + let live = 0 + const subscribe = useFlat.subscribe + const spy = vi.spyOn(useFlat, 'subscribe').mockImplementation((listener) => { + live++ + const stop = subscribe(listener) + return () => { + live-- + stop() + } + }) + return { live: () => live, spy } +} + +describe('edgesSettled', () => { + afterEach(() => vi.restoreAllMocks()) + + it('settles and lets go when the edges are found', async () => { + const { live } = countListeners() + const wait = edgesSettled(useFlat.getState()) + useFlat.getState().resolveEdges(3) + await wait + expect(live()).toBe(0) + }) + + it('lets go when its caller gives up, though the edges never come', async () => { + const { live } = countListeners() + const abort = new AbortController() + const wait = edgesSettled(useFlat.getState(), abort.signal) + expect(live()).toBe(1) + abort.abort() + await expect(wait).rejects.toThrow() + expect(live()).toBe(0) + }) + + it('settles when another image replaces the one being detected', async () => { + const { live } = countListeners() + const wait = edgesSettled(useFlat.getState()) + useFlat.setState((s) => ({ imageVersion: s.imageVersion + 1 })) + await wait + expect(live()).toBe(0) + }) +}) From f0a0e7314d66d776987e945c7603c299eec09d3a Mon Sep 17 00:00:00 2001 From: Jozkah <38704522+Jozkah@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:03:13 +0000 Subject: [PATCH 5/6] fix: drop the 2D rubber band when its gesture is cancelled The rubber band listened only for pointerup, so a touch or pen gesture the browser cancelled left its listeners and box on the page, and the next release anywhere made a selection. A pointercancel now removes the listeners and the box without selecting. --- src/viewer/FlatScene.ts | 13 ++++++++++++- tests/flatBand.test.ts | 42 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/flatBand.test.ts diff --git a/src/viewer/FlatScene.ts b/src/viewer/FlatScene.ts index a1c54885..e89e8481 100644 --- a/src/viewer/FlatScene.ts +++ b/src/viewer/FlatScene.ts @@ -970,13 +970,24 @@ export class FlatScene { this.container.appendChild(div) this.bandDiv = div const move = (ev: PointerEvent) => this.layoutBand(ev.clientX, ev.clientY) - const up = (ev: PointerEvent) => { + const stop = () => { document.removeEventListener('pointermove', move) document.removeEventListener('pointerup', up) + document.removeEventListener('pointercancel', cancel) + } + const up = (ev: PointerEvent) => { + stop() this.finishBand(ev.clientX, ev.clientY, ev) } + // A gesture the browser takes over selects nothing: left listening, the + // band would box-select at whatever release came next, anywhere. + const cancel = () => { + stop() + this.dropBand() + } document.addEventListener('pointermove', move) document.addEventListener('pointerup', up) + document.addEventListener('pointercancel', cancel) this.layoutBand(e.clientX, e.clientY) } diff --git a/tests/flatBand.test.ts b/tests/flatBand.test.ts new file mode 100644 index 00000000..34277387 --- /dev/null +++ b/tests/flatBand.test.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The 2D sheet's rubber band, driven through its own methods on a stand-in +// for the scene: a gesture the browser cancels draws no region, and leaves +// nothing listening for the next release. A real FlatScene needs WebGL, so +// what is checked here is the band's handling of the document's events. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FlatScene } from '../src/viewer/FlatScene' + +type Band = { beginBand(e: PointerEvent): void; bandDiv: unknown } + +function pointer(type: string, x = 0, y = 0): PointerEvent { + return Object.assign(new Event(type), { clientX: x, clientY: y, altKey: false }) as PointerEvent +} + +describe('the rubber band', () => { + const realDocument = globalThis.document + afterEach(() => { + globalThis.document = realDocument + }) + + it('is dropped on pointercancel, and the next release selects nothing', () => { + const doc = Object.assign(new EventTarget(), { + createElement: () => ({ style: {}, className: '', remove: vi.fn() }), + }) + globalThis.document = doc as unknown as Document + const proto = FlatScene.prototype as unknown as Record + const scene = { + container: { appendChild: () => {}, getBoundingClientRect: () => ({ x: 0, y: 0 }) }, + bandDiv: null, + bandStart: null, + layoutBand: proto.layoutBand, + dropBand: proto.dropBand, + finishBand: vi.fn(), + } + ;(proto.beginBand as Band['beginBand']).call(scene, pointer('pointerdown', 10, 10)) + expect(scene.bandDiv).not.toBeNull() + doc.dispatchEvent(pointer('pointercancel', 40, 40)) + expect(scene.bandDiv).toBeNull() + doc.dispatchEvent(pointer('pointerup', 80, 80)) + expect(scene.finishBand).not.toHaveBeenCalled() + }) +}) From 46d968c08139eb31c4a040e6452c92e053a18e86 Mon Sep 17 00:00:00 2001 From: Jozkah <38704522+Jozkah@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:04:13 +0000 Subject: [PATCH 6/6] fix: prune local recovery checkpoints Every page wrote a checkpoint slot with a copy of its source files, and only an explicit Discard ever removed one, so storage grew with each session and saved work was offered back as recovery. A page now removes its own slot when the project is saved, removes the slot it restored from, and on opening lets go of checkpoints older than 14 days or beyond the newest 10. --- scripts/e2e-recovery.mjs | 6 ++++++ src/app/recoveryStorage.ts | 13 +++++++++++++ src/app/useRecovery.ts | 29 ++++++++++++++++++++++++----- tests/recoveryPrune.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 tests/recoveryPrune.test.ts diff --git a/scripts/e2e-recovery.mjs b/scripts/e2e-recovery.mjs index 907025c0..0b0b0a3b 100644 --- a/scripts/e2e-recovery.mjs +++ b/scripts/e2e-recovery.mjs @@ -206,6 +206,12 @@ try { }) for (const [ok, name] of results) check(ok, name) + // Saving removed this page's checkpoint, so leave an unsaved edit behind for the reloaded page to offer. + await page.evaluate(async () => { + const { useStore } = await import('/src/state/store.ts') + useStore.setState({ showBackfaces: !useStore.getState().showBackfaces }) + }) + await waitStatus('Local checkpoint saved') await page.reload({ waitUntil: 'networkidle0' }) await page.waitForSelector('[data-test="recovery-bar"]') const before = await page.$$eval('[data-test="recovery-bar"] option', (options) => options.length) diff --git a/src/app/recoveryStorage.ts b/src/app/recoveryStorage.ts index 330c39e7..7ca91093 100644 --- a/src/app/recoveryStorage.ts +++ b/src/app/recoveryStorage.ts @@ -15,6 +15,19 @@ interface Checkpoint extends CheckpointInfo { manifest: ProjectManifest } const lockName = (id: string) => `scanruler-recovery:${id}` +/** How many checkpoints left by closed pages are kept, and for how long. + * Every page writes a slot of its own, each with a copy of its source + * files, so without a limit the browser's storage fills session by session. */ +export const RECOVERY_KEEP = 10 +export const RECOVERY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000 + +/** The checkpoints to let go of: those past the age limit, and all but the + * newest few of the rest. */ +export function staleCheckpoints(rows: readonly CheckpointInfo[], now: number, keep = RECOVERY_KEEP, maxAgeMs = RECOVERY_MAX_AGE_MS): string[] { + const newest = [...rows].sort((a, b) => b.savedAt - a.savedAt) + return newest.filter((row, i) => i >= keep || now - row.savedAt > maxAgeMs).map((row) => row.id) +} + /** Each page owns a separate slot. A tab can never overwrite another tab's * checkpoint. Metadata reads deliberately do not load the large source files. */ export class RecoveryStorage { diff --git a/src/app/useRecovery.ts b/src/app/useRecovery.ts index 403e52d6..9fcc0a30 100644 --- a/src/app/useRecovery.ts +++ b/src/app/useRecovery.ts @@ -17,7 +17,7 @@ import { projectSections } from './projectSections' import { watchUnsaved } from './project' import { useShell } from '../state/shellStore' import { ProjectClient } from '../core/project/projectClient' -import { RecoveryStorage, type CheckpointInfo } from './recoveryStorage' +import { RecoveryStorage, staleCheckpoints, type CheckpointInfo } from './recoveryStorage' import { ProjectSnapshotCache, type ProjectSnapshot } from './projectSnapshot' type Snapshot = ProjectSnapshot @@ -46,6 +46,7 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope const saved = useRef(null) const hadContent = useRef(false) const refresh = useRef(() => {}) + const forgetCheckpoint = useRef(() => {}) const storage = useRef(null) const [state, setState] = useState({ dirty: false, status: '', available: [] as CheckpointInfo[], working: false }) const update = (patch: Partial) => setState((s) => @@ -62,7 +63,12 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope const reportError = (error: unknown) => { if (!disposed) update({ status: `Local recovery unavailable: ${error instanceof Error ? error.message : String(error)}. Use Save Project.` }) } - void db.left().then((available) => { if (!disposed) update({ available }) }, reportError) + // What closed pages left behind, less what is too old or too many to keep. + void db.left().then(async (rows) => { + const stale = new Set(staleCheckpoints(rows, Date.now())) + for (const id of stale) await db.remove(id) + if (!disposed) update({ available: rows.filter((row) => !stale.has(row.id)) }) + }).catch(reportError) // What was last found, for a question asked while the session is busy. let dirty = false @@ -118,6 +124,7 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope scheduleInspect() } refresh.current = scheduleInspect + forgetCheckpoint.current = () => { checkpointKey = '' } // Zustand stores use immutable updates; watch only project fields. Picking, // progress and pointer motion must not serialize a large measurement set. const watch = (store: { subscribe: (fn: (s: T, prev: T) => void) => () => void }, keys: (keyof T)[], notify = changed) => @@ -156,23 +163,35 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope document.removeEventListener('visibilitychange', visibility) window.removeEventListener('beforeunload', beforeUnload) refresh.current = () => {} + forgetCheckpoint.current = () => {} storage.current = null release() db.close() } }, [cache]) - const markSaved = (snapshot: Snapshot) => { saved.current = cache.key(snapshot); refresh.current() } + const markSaved = (snapshot: Snapshot) => { + saved.current = cache.key(snapshot) + refresh.current() + // Saved work needs no checkpoint: a later change writes a new one, even + // back to a state checkpointed before, since the slot is gone. + forgetCheckpoint.current() + const db = storage.current + void db?.remove(db.id).catch(() => {}) + } const restored = () => { saved.current = null; update({ available: [] }); refresh.current() } const restore = async (id: string) => { - if (!storage.current || callbacks.current.blocked()) return + const db = storage.current + if (!db || callbacks.current.blocked()) return update({ working: true }) let worker: ProjectClient | null = null try { - const { manifest, members } = await storage.current.read(id) + const { manifest, members } = await db.read(id) worker = new ProjectClient() const bytes = await worker.pack(manifest, members) await callbacks.current.open(new File([bytes as BlobPart], 'recovered.scanruler')) + // The work is this page's now, checkpointed in this page's own slot. + await db.remove(id).catch(() => {}) } catch (error) { update({ status: `Recovery failed: ${error instanceof Error ? error.message : String(error)}` }) } finally { worker?.dispose(); update({ working: false }) } diff --git a/tests/recoveryPrune.test.ts b/tests/recoveryPrune.test.ts new file mode 100644 index 00000000..04d3c493 --- /dev/null +++ b/tests/recoveryPrune.test.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Which checkpoints left by closed pages are let go of. The storage itself is +// IndexedDB, which the tests do not have; the choice is checked on its own. +import { describe, expect, it } from 'vitest' +import { RECOVERY_KEEP, RECOVERY_MAX_AGE_MS, staleCheckpoints } from '../src/app/recoveryStorage' + +const DAY = 24 * 60 * 60 * 1000 +const NOW = 1_800_000_000_000 +const row = (id: string, ageMs: number) => ({ id, name: id, savedAt: NOW - ageMs }) + +describe('staleCheckpoints', () => { + it('lets go of checkpoints older than the age limit', () => { + const rows = [row('fresh', DAY), row('old', RECOVERY_MAX_AGE_MS + DAY)] + expect(staleCheckpoints(rows, NOW)).toEqual(['old']) + }) + + it('keeps only the newest few, however recent the rest', () => { + const rows = Array.from({ length: RECOVERY_KEEP + 3 }, (_, i) => row(`p${i}`, i * 1000)) + expect(staleCheckpoints(rows, NOW).sort()).toEqual(rows.slice(RECOVERY_KEEP).map((r) => r.id).sort()) + }) + + it('keeps everything recent and few', () => { + expect(staleCheckpoints([row('a', 0), row('b', DAY)], NOW)).toEqual([]) + }) +})