fix(app): harden document recovery lifecycle

- Serialize recovery cleanup with active snapshot writes

- Preserve version ordering across autosave, restore, and cleanup

- Fall back to memory when IndexedDB operations fail
This commit is contained in:
Danila Poyarkov 2026-08-13 12:51:44 +03:00
parent 16b74a2087
commit 7b8e5fbfbe
9 changed files with 164 additions and 17 deletions

View file

@ -5,7 +5,7 @@
"recoverUnsavedWork": "Récupérer le travail non enregistré",
"recoverUnsavedWorkDescription": "OpenPencil a trouvé des documents d’une session précédente.",
"restore": "Restaurer",
"discard": "Ignorer",
"discard": "Supprimer",
"recoveryFailed": "Impossible de restaurer ce document.",
"rename": "Renommer",
"renameLayers": "Renommer {count} calques",

View file

@ -87,7 +87,9 @@ export function createDocumentSourceActions({
getSavedVersion,
hasWritableSource: () => !!getFileHandle() || !!getFilePath() || !!getStorageBinding(),
saveCurrentDocument: async () => {
await writeFile(await buildFigFile())
const version = state.sceneVersion
const data = await buildFigFile()
await writeFile(data, version)
}
})

View file

@ -27,7 +27,11 @@ export function createDocumentWriter({
}: DocumentWriterOptions) {
async function finishWrite(version: number): Promise<true> {
setSavedVersion(version)
await onWriteSuccess?.(version)
try {
await onWriteSuccess?.(version)
} catch (error) {
console.warn('[Recovery] Cleanup after document write failed:', error)
}
return true
}

View file

@ -35,6 +35,7 @@ export function createDocumentRecovery({
}: DocumentRecoveryOptions): DocumentRecoveryController {
let id = recoveryId
let protectedVersion = state.sceneVersion
let persistedVersion: number | null = null
let requestedVersion = protectedVersion
let lifecycleGeneration = 0
let writing: Promise<void> | null = null
@ -52,6 +53,7 @@ export function createDocumentRecovery({
sceneVersion: version,
figBytes: bytes
})
persistedVersion = version
if (generation !== lifecycleGeneration) return
protectedVersion = version
if (requestedVersion !== version) await runWrites(generation)
@ -80,25 +82,35 @@ export function createDocumentRecovery({
{ debounce: 3000, maxWait: 10000 }
)
async function invalidateActiveWrite(): Promise<void> {
lifecycleGeneration++
await writing
}
return {
getRecoveryId: () => id,
adoptRecoverySnapshot(nextId, sceneVersion) {
lifecycleGeneration++
id = nextId
protectedVersion = sceneVersion
persistedVersion = sceneVersion
requestedVersion = sceneVersion
disposed = false
},
persistNow,
async markProtectedVersion(version) {
lifecycleGeneration++
await invalidateActiveWrite()
protectedVersion = version
requestedVersion = version
await store.remove(id)
requestedVersion = state.sceneVersion
if (persistedVersion == null || persistedVersion <= version) {
await store.remove(id)
persistedVersion = null
}
},
async discardRecovery() {
lifecycleGeneration++
await invalidateActiveWrite()
protectedVersion = state.sceneVersion
persistedVersion = null
requestedVersion = state.sceneVersion
await store.remove(id)
},

View file

@ -1,20 +1,54 @@
import { createIdbRecoveryStore } from '@/app/document/recovery/idb'
import { createMemoryRecoveryStore } from '@/app/document/recovery/memory'
import type { RecoveryStore } from '@/app/document/recovery/types'
import type {
RecoverySnapshot,
RecoverySnapshotInput,
RecoverySnapshotMeta,
RecoveryStore
} from '@/app/document/recovery/types'
let singleton: RecoveryStore | null = null
let memoryFallback = false
function warnMemoryFallback(error?: unknown): void {
if (memoryFallback) return
console.warn('[Recovery] IndexedDB unavailable; crash recovery is limited to this session', error)
memoryFallback = true
}
function createResilientRecoveryStore(primary: RecoveryStore): RecoveryStore {
let current = primary
async function run<T>(operation: (store: RecoveryStore) => Promise<T>): Promise<T> {
try {
return await operation(current)
} catch (error) {
if (current !== primary) throw error
warnMemoryFallback(error)
current = createMemoryRecoveryStore()
return operation(current)
}
}
return {
list: () => run((store) => store.list()),
read: (id: string): Promise<RecoverySnapshot | null> => run((store) => store.read(id)),
write: (input: RecoverySnapshotInput): Promise<RecoverySnapshotMeta> =>
run((store) => store.write(input)),
remove: (id: string): Promise<void> => run((store) => store.remove(id)),
clear: (): Promise<void> => run((store) => store.clear())
}
}
export function getRecoveryStore(): RecoveryStore {
if (singleton) return singleton
if (typeof indexedDB !== 'undefined') {
singleton = createIdbRecoveryStore()
memoryFallback = false
} else {
console.warn('[Recovery] IndexedDB unavailable; crash recovery is limited to this session')
if (typeof indexedDB === 'undefined') {
warnMemoryFallback()
singleton = createMemoryRecoveryStore()
memoryFallback = true
return singleton
}
memoryFallback = false
singleton = createResilientRecoveryStore(createIdbRecoveryStore())
return singleton
}

View file

@ -36,6 +36,7 @@ function generateTabId(): string {
}
const tabsRef = shallowRef<Tab[]>([])
const pendingRecoveryDeletions = new Set<Promise<void>>()
const activeTabId = shallowRef('')
export const activeTab = computed(() => tabsRef.value.find((t) => t.id === activeTabId.value))
@ -97,8 +98,11 @@ export function closeTab(tabId: string) {
const closingTab = tabsRef.value[idx]
const wasActive = activeTabId.value === tabId
const deletion = closingTab.store.discardRecovery().finally(() => {
pendingRecoveryDeletions.delete(deletion)
})
pendingRecoveryDeletions.add(deletion)
closingTab.store.dispose()
void closingTab.store.discardRecovery()
tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId)
if (tabsRef.value.length === 0) {
@ -292,7 +296,7 @@ export async function restoreRecoverySnapshot(id: string): Promise<void> {
store.replaceGraph(imported)
store.undo.clear()
store.state.documentName = snapshot.documentName
store.adoptRecoverySnapshot(id, store.state.sceneVersion)
store.adoptRecoverySnapshot(id, snapshot.sceneVersion)
store.clearSelection()
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
await store.switchPage(pageId)
@ -300,7 +304,10 @@ export async function restoreRecoverySnapshot(id: string): Promise<void> {
}
export async function prepareForReload(): Promise<void> {
await Promise.all(tabsRef.value.map((tab) => tab.store.persistRecoveryNow()))
await Promise.all([
...pendingRecoveryDeletions,
...tabsRef.value.map((tab) => tab.store.persistRecoveryNow())
])
}
export function tabCount(): number {

View file

@ -28,6 +28,7 @@ async function restore(snapshot: RecoverySnapshotMeta): Promise<void> {
try {
await restoreRecoverySnapshot(snapshot.id)
snapshots.value = snapshots.value.filter((candidate) => candidate.id !== snapshot.id)
if (snapshots.value.length === 0) open.value = false
} catch (error) {
toast.error(error instanceof Error ? error.message : dialogs.value.recoveryFailed)
} finally {
@ -40,6 +41,7 @@ async function discard(snapshot: RecoverySnapshotMeta): Promise<void> {
try {
await discardRecoverySnapshot(snapshot.id)
snapshots.value = snapshots.value.filter((candidate) => candidate.id !== snapshot.id)
if (snapshots.value.length === 0) open.value = false
} finally {
busyId.value = null
}

View file

@ -6,6 +6,22 @@ import { createDefaultEditorState } from '@open-pencil/core/editor'
import { createDocumentRecovery } from '@/app/document/recovery/controller'
import { createMemoryRecoveryStore } from '@/app/document/recovery/memory'
import type { RecoverySnapshotInput, RecoveryStore } from '@/app/document/recovery/types'
function deferredWriteStore() {
const memory = createMemoryRecoveryStore()
let release: (() => void) | null = null
const store: RecoveryStore = {
...memory,
async write(input: RecoverySnapshotInput) {
await new Promise<void>((resolve) => {
release = resolve
})
return memory.write(input)
}
}
return { store, release: () => release?.() }
}
function setup(buildFigFile = async () => new Uint8Array([1, 2, 3])) {
const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Agent draft' })
@ -80,4 +96,57 @@ describe('document recovery controller', () => {
expect(await store.list()).toEqual([])
recovery.disposeRecovery()
})
test('save waits for an active write before deleting its snapshot', async () => {
const deferred = deferredWriteStore()
const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Draft' })
const recovery = createDocumentRecovery({
state,
store: deferred.store,
recoveryId: 'recovery-1',
hasWritableSource: () => false,
buildFigFile: () => new Uint8Array([1])
})
state.sceneVersion = 1
const write = recovery.persistNow()
await Promise.resolve()
const cleanup = recovery.markProtectedVersion(1)
deferred.release()
await Promise.all([write, cleanup])
expect(await deferred.store.list()).toEqual([])
recovery.disposeRecovery()
})
test('discard waits for an active write before deleting its snapshot', async () => {
const deferred = deferredWriteStore()
const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Draft' })
const recovery = createDocumentRecovery({
state,
store: deferred.store,
recoveryId: 'recovery-1',
hasWritableSource: () => false,
buildFigFile: () => new Uint8Array([1])
})
state.sceneVersion = 1
const write = recovery.persistNow()
await Promise.resolve()
const discard = recovery.discardRecovery()
deferred.release()
await Promise.all([write, discard])
expect(await deferred.store.list()).toEqual([])
recovery.disposeRecovery()
})
test('preserves a snapshot newer than the saved version', async () => {
const { state, store, recovery } = setup()
state.sceneVersion = 2
await recovery.persistNow()
await recovery.markProtectedVersion(1)
expect((await store.read('recovery-1'))?.sceneVersion).toBe(2)
recovery.disposeRecovery()
})
})

View file

@ -56,6 +56,23 @@ describe('Tauri document IO helpers', () => {
expect(savedVersions).toEqual([42])
})
test('keeps a completed write successful when recovery cleanup fails', async () => {
await mockTauriIPC(() => null)
const savedVersions: number[] = []
const write = createDocumentWriter({
state: { sceneVersion: 42 } as Parameters<typeof createDocumentWriter>[0]['state'],
getFilePath: () => '/tmp/document.fig',
getFileHandle: () => null,
getStorageBinding: () => null,
setSavedVersion: (version) => savedVersions.push(version),
setLastWriteTime: () => undefined,
onWriteSuccess: () => Promise.reject(new Error('IndexedDB blocked'))
})
await expect(write(new Uint8Array([1]), 17)).resolves.toBe(true)
expect(savedVersions).toEqual([17])
})
test('chooses a Tauri save path through plugin-dialog', async () => {
await mockTauriIPC((cmd, args) => {
expect(cmd).toBe('plugin:dialog|save')