From e18ae33ac0cc03b1617ae28774af2ec2b893ffaf Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 17 Oct 2026 21:01:38 +0800 Subject: [PATCH 1/5] fix(history): retain fresh snapshots past record expiry --- lib/history/store.ts | 9 +- test/e2e/history-quota.spec.ts | 109 +++++++++++++++++++++++ test/unit/history-handle-cleanup.test.ts | 25 ++++++ 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/lib/history/store.ts b/lib/history/store.ts index 8e9e883e..08b15225 100644 --- a/lib/history/store.ts +++ b/lib/history/store.ts @@ -180,7 +180,7 @@ async function trimRevisions( * caller already has: expiry has to be something the browser does on its own, * not something that waits for the user to visit a page. */ -async function sweepExpired(tx: IDBTransaction, now: number): Promise { +async function sweepExpired(tx: IDBTransaction, now: number, protectedId?: string): Promise { // A recent snapshot cannot expire, regardless of when it was last opened. // Read only old snapshot metadata, then keep the opening-time renewal rule. const docs = (await requestToPromise( @@ -191,7 +191,9 @@ async function sweepExpired(tx: IDBTransaction, now: number): Promise )) as HistoryDoc[]; // The forward index orders timestamp ties by ascending primary key, just // like getAll did. A stable sort preserves the returned expiry ID order. - const expired = docs.filter((doc) => expiresAt(doc) <= now).sort((a, b) => b.updatedAt - a.updatedAt); + const expired = docs + .filter((doc) => doc.id !== protectedId && expiresAt(doc) <= now) + .sort((a, b) => b.updatedAt - a.updatedAt); for (const doc of expired) { await deleteDocumentData(tx, doc.id); } @@ -290,7 +292,8 @@ async function writeSnapshot( // Expiry first: room freed by documents that were due to go anyway is room // the budget does not have to take from documents that are still current. - await sweepExpired(tx, now); + // Its old metadata can be expired while these new bytes are being written. + await sweepExpired(tx, now, doc.id); const trimmed = await trimRevisions(tx, doc.id, budget); doc.totalBytes -= trimmed.freed; diff --git a/test/e2e/history-quota.spec.ts b/test/e2e/history-quota.spec.ts index ad690f43..885ff43e 100644 --- a/test/e2e/history-quota.spec.ts +++ b/test/e2e/history-quota.spec.ts @@ -2,6 +2,115 @@ import { readHistoryDocs } from './lib/history-db'; import { buildDocx } from './lib/ooxml'; import { expect, test } from './lib/l0'; +test('an expired active document keeps the bytes from its next autosave', async ({ page }) => { + test.setTimeout(90_000); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + const id = new URL(page.url()).searchParams.get('saved')!; + const recovery = Array.from(buildDocx('Previous recovery')); + await page.evaluate( + async ({ id, bytes }) => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open('document-history', 3); + request.onupgradeneeded = () => { + request.transaction!.abort(); + reject(new Error('The editor must initialize its history database')); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(['docs', 'blobs'], 'readwrite'); + const old = Date.now() - 8 * 24 * 60 * 60 * 1000; + tx.objectStore('docs').put({ + id, + title: 'Untitled.docx', + titleLower: 'untitled.docx', + ext: 'docx', + origin: 'new', + size: bytes.length, + totalBytes: bytes.length, + createdAt: old, + updatedAt: old, + lastOpenedAt: old, + revCount: 1, + nextRev: 1, + }); + tx.objectStore('blobs').put({ + docId: id, + rev: 0, + savedAt: old, + bytes: new Uint8Array(bytes), + byteLength: bytes.length, + }); + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } + }, + { id, bytes: recovery }, + ); + const sdk = page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk'); + await sdk.click({ position: { x: 300, y: 200 } }); + await page.keyboard.type('A fresh edit after eight idle days'); + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await expect + .poll( + () => + page.evaluate( + (id) => + new Promise((resolve, reject) => { + const request = indexedDB.open('document-history', 3); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const read = db.transaction('docs').objectStore('docs').get(id); + read.onsuccess = () => { + db.close(); + resolve(read.result?.nextRev ?? 0); + }; + read.onerror = () => { + db.close(); + reject(read.error); + }; + }; + }), + id, + ), + { timeout: 25_000 }, + ) + .toBe(2); + const bytes = await page.evaluate( + (id) => + new Promise((resolve, reject) => { + const request = indexedDB.open('document-history', 3); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const read = db.transaction('blobs').objectStore('blobs').get([id, 1]); + read.onsuccess = () => { + db.close(); + resolve(read.result ? Array.from(read.result.bytes) : null); + }; + read.onerror = () => { + db.close(); + reject(read.error); + }; + }; + }), + id, + ); + expect(bytes).not.toBeNull(); + const { ooxmlText, zipEntryText } = await import('./lib/ooxml'); + expect(ooxmlText((await zipEntryText(new Uint8Array(bytes!), 'word/document.xml')) || '')).toContain('fresh edit'); +}); + test('a failed autosave quota retry preserves the previous recovery document', async ({ page }) => { test.setTimeout(90_000); const recovery = Array.from(buildDocx('The only recovery copy')); diff --git a/test/unit/history-handle-cleanup.test.ts b/test/unit/history-handle-cleanup.test.ts index b4b1e554..0f859142 100644 --- a/test/unit/history-handle-cleanup.test.ts +++ b/test/unit/history-handle-cleanup.test.ts @@ -6,6 +6,8 @@ import { clearAllHistory, deleteDoc, getDoc, + getLatestSnapshot, + historyUsage, pruneExpired, putSnapshot, resetHistoryClockForTests, @@ -62,3 +64,26 @@ it('also clears targets that have never had a recovery snapshot', async () => { await clearAllHistory(); expect(await targetIds()).toEqual([]); }); + +it('keeps an actively updated expired document and its file target while sweeping other expired documents', async () => { + await putSnapshot({ id: 'active', title: 'Active.docx', origin: 'local', bytes: new Uint8Array([1, 2]) }); + await putSnapshot({ id: 'stale', title: 'Stale.docx', origin: 'local', bytes: new Uint8Array([9]) }); + await addTarget('active'); + await addTarget('stale'); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + MAX_AGE_MS + 10); + + const updated = await putSnapshot({ + id: 'active', + title: 'Active.docx', + origin: 'local', + bytes: new Uint8Array([3, 4, 5]), + }); + + expect(Array.from((await getLatestSnapshot('active'))?.bytes ?? [])).toEqual([3, 4, 5]); + expect(updated).toMatchObject({ id: 'active', revCount: 2, totalBytes: 5, nextRev: 2 }); + expect(await getDoc('active')).toEqual(updated); + expect(await historyUsage()).toBe(5); + expect(await targetIds()).toEqual(['active']); + expect(await getDoc('stale')).toBeNull(); + expect(await getLatestSnapshot('stale')).toBeNull(); +}); From 3b9f9ace7103afeb6ce8eff9ceb96173d2541ad4 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 17 Oct 2026 21:01:44 +0800 Subject: [PATCH 2/5] fix(editor): preserve document identity and open ownership Keep disk routing independent of recovery, invalidate pending lock starts, propagate local open failures and bind readonly to the owned open. Avoid retaining redundant original-file Blob URLs. --- index.ts | 9 +- lib/document.ts | 28 ++-- lib/embed-api.ts | 5 +- lib/events.ts | 11 +- lib/history/autosave.ts | 23 +++ lib/history/index.ts | 15 +- lib/history/session.ts | 2 + lib/web-mcp.ts | 3 +- test/e2e/save-to-file.spec.ts | 17 ++ test/unit/document-entry-contracts.test.ts | 165 ++++++++++++++++++++ test/unit/document-open.test.ts | 2 +- test/unit/history-autosave-start.test.ts | 119 ++++++++++++++ test/unit/history-session-lifecycle.test.ts | 134 ++++++++++++++++ test/unit/local-source-open.test.ts | 8 +- test/unit/open-ownership.test.ts | 6 +- test/unit/save-stream-lifecycle.test.ts | 3 +- 16 files changed, 510 insertions(+), 40 deletions(-) create mode 100644 test/unit/document-entry-contracts.test.ts create mode 100644 test/unit/history-autosave-start.test.ts create mode 100644 test/unit/history-session-lifecycle.test.ts diff --git a/index.ts b/index.ts index 127e13dc..18bca1c1 100644 --- a/index.ts +++ b/index.ts @@ -167,7 +167,12 @@ void (async () => { // would produce, and it is the copy nobody else has. if (savedParam && !isEmbedded) { const { restoreSavedDocument } = await import('./lib/history/recovery'); - if (await restoreSavedDocument(savedParam, { readonly: isReadonly })) return; + try { + if (await restoreSavedDocument(savedParam, { readonly: isReadonly })) return; + } catch { + // Opening already reported failure, or another entry replaced it. + return; + } // No snapshot yet (nothing was edited before the reload) or it expired: // fall through and open the same document again under the same id. } @@ -206,7 +211,7 @@ void (async () => { cleaned.searchParams.delete('open'); window.history.replaceState(null, '', cleaned); if (file) { - await openLocalFile(file, { historyId: savedParam || undefined }); + await openLocalFile(file, { historyId: savedParam || undefined }).catch(() => {}); return; } // Stale deep link (reload, bookmarked URL): nothing pending -- back to the landing. diff --git a/lib/document.ts b/lib/document.ts index 54668582..6782681b 100644 --- a/lib/document.ts +++ b/lib/document.ts @@ -1,7 +1,6 @@ import { beginDocumentOpen, commitDocumentOpen, isMountedDocumentOpen } from './document-open'; import { fetchRemoteDocument } from './remote-document'; import { remoteFileName } from './remote-file-name'; -import { createObjectURL } from 'ranuts/utils'; import { View } from 'ranui/builder'; import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; import { t } from '@ranuts/shared/i18n'; @@ -9,7 +8,7 @@ import 'ranui/message'; import { handleDocumentOperation, loadEditorApi } from './converter'; import { showLoading } from './loading'; import { startDocumentSession, newDocumentId } from './history/session'; -import { stopAutosaveSession } from './history/autosave'; +import { clearDocumentSession } from './history/autosave'; import { rememberActiveSource, clearActiveSource } from './active-document-source'; // Import UI functions with type-only to avoid circular dependency @@ -58,7 +57,7 @@ export const onCreateNew = async (ext: string, options?: { docId?: string }): Pr const { fileName, file: fileBlob } = getDocmentObj(); await handleDocumentOperation({ file: fileBlob, fileName, isNew: !fileBlob, isCurrent: opening.isCurrent }); if (!commitDocumentOpen(operation)) return; - stopAutosaveSession(); + clearDocumentSession(); await clearActiveSource(); if (!isMountedDocumentOpen(operation)) return; // Recovery points from here on. A blank document is the case with the most @@ -92,9 +91,10 @@ export const openLocalFile = async ( if (hideControlPanelFn) { hideControlPanelFn(); } - const objectUrl = await createObjectURL(file); opening.assertCurrent(); - setDocmentObj({ fileName: file.name, file, url: objectUrl }); + // The editor owns the URL it creates from these bytes. This store needs + // only the File; a second Blob URL would retain every replaced original. + setDocmentObj({ fileName: file.name, file }); const { fileName, file: fileBlob } = getDocmentObj(); await handleDocumentOperation({ file: fileBlob, @@ -103,21 +103,22 @@ export const openLocalFile = async ( readonly: options?.readonly, isCurrent: opening.isCurrent, }); - if (!commitDocumentOpen(operation)) return; + if (!commitDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError'); // The replacement editor now owns exports; retire the previous identity // before asynchronous source storage, while delaying URL stamping until commit. - stopAutosaveSession(); + clearDocumentSession(); const docId = options?.historyId || newDocumentId(); await rememberActiveSource(docId, file); - if (!isMountedDocumentOpen(operation)) return; + if (!isMountedDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError'); startDocumentSession({ title: file.name, origin: 'local', docId }); } catch (error) { - if (!opening.isCurrent()) return; + if (!opening.isCurrent()) throw error; console.error('Error opening document:', error); // Ensure control panel is shown on error if (showControlPanelFn) { showControlPanelFn(); } + throw error; } finally { // Always remove loading, even if there's an error removeLoading(); @@ -139,7 +140,9 @@ export const onOpenDocument = (): void => { // Only process if a file was actually selected // If user cancelled, onchange won't fire, nothing happens if (file) { - await openLocalFile(file); + // The open path already reports failure. A DOM event has no caller to + // receive its rejection, unlike tools and recovery operations. + await openLocalFile(file).catch(() => {}); // Clear file selection so the same file can be selected again fileInput.value = ''; } @@ -209,9 +212,8 @@ export const openDocumentFromUrl = async ( const file = new File([remote.blob], finalFileName, { type: remote.blob.type }); // Set document object - const objectUrl = await createObjectURL(file); opening.assertCurrent(); - setDocmentObj({ fileName: finalFileName, file, url: objectUrl }); + setDocmentObj({ fileName: finalFileName, file }); // Initialize and open document const { fileName: docFileName, file: fileBlob } = getDocmentObj(); @@ -223,7 +225,7 @@ export const openDocumentFromUrl = async ( isCurrent: opening.isCurrent, }); if (!commitDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError'); - stopAutosaveSession(); + clearDocumentSession(); await clearActiveSource(); if (!isMountedDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError'); startDocumentSession({ title: docFileName, origin: 'url', docId: options?.docId }); diff --git a/lib/embed-api.ts b/lib/embed-api.ts index aee6aeb7..fadc730d 100644 --- a/lib/embed-api.ts +++ b/lib/embed-api.ts @@ -1,5 +1,5 @@ import { clearActiveSource } from './active-document-source'; -import { stopAutosaveSession } from './history/autosave'; +import { clearDocumentSession } from './history/autosave'; import { beginDocumentOpen, commitDocumentOpen, isMountedDocumentOpen } from './document-open'; import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; import { handleDocumentOperation, loadEditorApi } from './converter'; @@ -134,7 +134,6 @@ async function openFile(file: File, readonly = false): Promise { setDocmentObj({ fileName: file.name, file, - url: URL.createObjectURL(file), }); await handleDocumentOperation({ fileName: file.name, @@ -144,7 +143,7 @@ async function openFile(file: File, readonly = false): Promise { isCurrent: opening.isCurrent, }); if (!commitDocumentOpen(opening.id)) throw new DOMException('Document open superseded', 'AbortError'); - stopAutosaveSession(); + clearDocumentSession(); await clearActiveSource(); if (!isMountedDocumentOpen(opening.id)) throw new DOMException('Document open superseded', 'AbortError'); } diff --git a/lib/events.ts b/lib/events.ts index b04336b3..8b67652e 100644 --- a/lib/events.ts +++ b/lib/events.ts @@ -1,7 +1,7 @@ import { clearActiveSource } from './active-document-source'; import { beginDocumentOpen, cancelDocumentOpen, commitDocumentOpen } from './document-open'; -import { stopAutosaveSession } from './history/autosave'; -import { MessageCodec, Platform, createObjectURL } from 'ranuts/utils'; +import { clearDocumentSession } from './history/autosave'; +import { MessageCodec, Platform } from 'ranuts/utils'; import type { MessageHandler } from 'ranuts/utils'; import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; import { handleDocumentOperation } from './converter'; @@ -47,17 +47,14 @@ export const events: Record> = { try { const file = await MessageCodec.decodeFileChunked(chunks); opening.assertCurrent(); - const url = await createObjectURL(file); - opening.assertCurrent(); setDocmentObj({ fileName: file.name, file: file, - url, }); const { fileName, file: fileBlob } = getDocmentObj(); await handleDocumentOperation({ file: fileBlob, fileName, isNew: !fileBlob, isCurrent: opening.isCurrent }); if (commitDocumentOpen(opening.id)) { - stopAutosaveSession(); + clearDocumentSession(); await clearActiveSource(); } } catch (error) { @@ -70,7 +67,7 @@ export const events: Record> = { }, CLOSE_EDITOR: () => { cancelDocumentOpen(); - stopAutosaveSession(); + clearDocumentSession(); chunkOpening = null; fileChunks = []; if (window.editor && typeof window.editor.destroyEditor === 'function') { diff --git a/lib/history/autosave.ts b/lib/history/autosave.ts index 74ef21d4..9326838f 100644 --- a/lib/history/autosave.ts +++ b/lib/history/autosave.ts @@ -146,6 +146,9 @@ interface ActiveSession { } let session: ActiveSession | null = null; +let sessionGeneration = 0; +// Editing identity outlives the optional recovery scheduler. +let activeDocumentId: string | null = null; function notify(kind: 'warning' | 'error', text: string): void { (window as unknown as { message?: Record void) | undefined> }).message?.[kind]?.(text); @@ -287,6 +290,8 @@ function tick(): void { */ export async function beginAutosaveSession(input: AutosaveSessionInput): Promise { stopAutosaveSession(); + activeDocumentId = input.docId; + const generation = sessionGeneration; if (typeof window === 'undefined' || isEmbedMode() || !isAutosaveEnabled()) return; const ext = extensionOf(input.title); @@ -300,6 +305,12 @@ export async function beginAutosaveSession(input: AutosaveSessionInput): Promise // holds the write lock is our bookkeeping, not their business. This page // simply stops writing snapshots -- the other tab is taking them. const { held, release } = await acquireLock(lockName); + // Stopping or replacing a document also cancels starts still waiting for a + // lock. A late grant must release ownership before installing any resources. + if (generation !== sessionGeneration || !isAutosaveEnabled()) { + release?.(); + return; + } const onVisibility = (): void => { // The last chance that reliably gets time to run. beforeunload cannot be @@ -372,6 +383,7 @@ export async function beginAutosaveSession(input: AutosaveSessionInput): Promise } export function stopAutosaveSession(): void { + sessionGeneration += 1; const active = session; session = null; if (!active || active.stopped) return; @@ -394,3 +406,14 @@ export function getAutosaveDocId(): string | null { export function isAutosaveSessionActive(): boolean { return session !== null; } + +/** The currently opened document, including when recovery is disabled. */ +export function getActiveDocumentId(): string | null { + return activeDocumentId; +} + +/** Retire the opened document before closing or replacing its editor. */ +export function clearDocumentSession(): void { + stopAutosaveSession(); + activeDocumentId = null; +} diff --git a/lib/history/index.ts b/lib/history/index.ts index f90dda15..dc6a7d27 100644 --- a/lib/history/index.ts +++ b/lib/history/index.ts @@ -5,7 +5,7 @@ */ import { setDiskWriter, setSavedToDiskListener } from '../onlyoffice/save-stream'; import { saveToDiskFile } from '../save-target'; -import { getAutosaveDocId } from './autosave'; +import { getActiveDocumentId } from './autosave'; import { extensionOf, markSavedToDisk, pruneExpired } from './store'; import { getDocmentObj } from '@ranuts/shared/store'; @@ -19,7 +19,7 @@ export function initDocumentHistory(): void { // pointed at one. The document id is what links the two, which is why this // is wired here rather than inside the save channel. setDiskWriter(async (file) => { - const docId = getAutosaveDocId(); + const docId = getActiveDocumentId(); if (!docId) return 'unavailable'; const sourceName = getDocmentObj()?.fileName || file.name; return saveToDiskFile(docId, file, 'Document', { @@ -31,11 +31,18 @@ export function initDocumentHistory(): void { // The document now exists on disk, so its history row has nothing left to // offer back. Without this the recovery bar would keep flagging a document // the user already saved. - const id = getAutosaveDocId(); + const id = getActiveDocumentId(); if (id) void markSavedToDisk(id); }); } -export { beginAutosaveSession, isAutosaveEnabled, setAutosaveEnabled, stopAutosaveSession } from './autosave'; +export { + beginAutosaveSession, + clearDocumentSession, + getActiveDocumentId, + isAutosaveEnabled, + setAutosaveEnabled, + stopAutosaveSession, +} from './autosave'; export { clearAllHistory, deleteDoc, getLatestSnapshot, listDocs } from './store'; export type { HistoryDoc } from './types'; diff --git a/lib/history/session.ts b/lib/history/session.ts index 2edd88b5..e257666c 100644 --- a/lib/history/session.ts +++ b/lib/history/session.ts @@ -43,3 +43,5 @@ export function startDocumentSession(input: { title: string; origin: HistoryOrig void beginAutosaveSession({ docId, title: input.title, origin: input.origin }); return docId; } + +export { clearDocumentSession, getActiveDocumentId } from './autosave'; diff --git a/lib/web-mcp.ts b/lib/web-mcp.ts index 475d3e7f..c561d4de 100644 --- a/lib/web-mcp.ts +++ b/lib/web-mcp.ts @@ -155,8 +155,7 @@ export function buildTools(): WebMcpTool[] { if (!fileName.includes('.')) return fail('fileName must include an extension'); const bytes = decodeBase64(String(input.base64 || '')); if (!bytes.byteLength) return fail('base64 decoded to zero bytes'); - await openLocalFile(new File([bytes], fileName)); - if (input.readonly) setReadonlyMode(true); + await openLocalFile(new File([bytes], fileName), { readonly: Boolean(input.readonly) }); return ok({ ok: true, fileName, size: bytes.byteLength, readonly: getReadonlyMode() }); }, }, diff --git a/test/e2e/save-to-file.spec.ts b/test/e2e/save-to-file.spec.ts index 4d1d3428..81105d59 100644 --- a/test/e2e/save-to-file.spec.ts +++ b/test/e2e/save-to-file.spec.ts @@ -186,6 +186,23 @@ test.describe('saving into the document own file (real editor)', () => { ); }); + test('disabling recovery still reuses the authorized document file', async ({ page }) => { + await page.evaluate(() => localStorage.setItem('document-autosave-enabled', 'false')); + await page.goto('/editor?new=docx'); + await waitForEditorReady(page); + expect(new URL(page.url()).searchParams.get('saved')).toBeTruthy(); + const sdk = page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk'); + await sdk.click({ position: { x: 300, y: 200 } }); + await page.keyboard.type('First disk version'); + await page.keyboard.press('Control+s'); + await waitForWrites(page, 1); + await page.keyboard.type(' and newer edits'); + await page.keyboard.press('Control+s'); + await waitForWrites(page, 2); + expect(await pickerCalls(page)).toBe(1); + expect(await savedText(page)).toContain('and newer edits'); + }); + test('another edit after saving rearms the unsaved-changes warning', async ({ page }) => { await page.goto('/editor?new=docx'); await waitForEditorReady(page); diff --git a/test/unit/document-entry-contracts.test.ts b/test/unit/document-entry-contracts.test.ts new file mode 100644 index 00000000..965a9149 --- /dev/null +++ b/test/unit/document-entry-contracts.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const native = vi.hoisted(() => ({ + convert: vi.fn(async (_options: unknown) => {}), + remember: vi.fn(async () => true), + readonly: vi.fn(), + start: vi.fn(), + decode: vi.fn(async () => new File(['desktop'], 'Desktop.docx')), +})); +vi.mock('../../lib/converter', () => ({ + handleDocumentOperation: native.convert, + loadEditorApi: async () => {}, +})); +vi.mock('../../lib/history/session', () => ({ startDocumentSession: native.start, newDocumentId: () => 'local-id' })); +vi.mock('../../lib/history/autosave', () => ({ stopAutosaveSession: vi.fn(), clearDocumentSession: vi.fn() })); +vi.mock('../../lib/active-document-source', () => ({ + rememberActiveSource: native.remember, + clearActiveSource: async () => {}, +})); +vi.mock('../../lib/onlyoffice-editor', () => ({ + getReadonlyMode: () => false, + setReadonlyMode: native.readonly, + requestSaveDocument: vi.fn(), +})); +vi.mock('../../lib/agent-plugin/tools', () => ({ getDocumentTextTool: {} })); +vi.mock('ranuts/utils', async (original) => ({ + ...(await original()), + MessageCodec: { decodeFileChunked: native.decode }, +})); +import { onCreateNew, onOpenDocument, openDocumentFromUrl, openLocalFile } from '../../lib/document'; +import { initWebMcp, disposeWebMcp, type WebMcpTool } from '../../lib/web-mcp'; +import { events } from '../../lib/events'; +import { initEmbedApi } from '../../lib/embed-api'; + +const chunk = { chunkIndex: 0, data: '', lastModified: 0, name: 'Desktop.docx', size: 7, totalChunks: 1, type: '' }; +const held = new Map(); +let listeners: ReturnType; +beforeEach(() => { + native.convert.mockReset().mockResolvedValue(undefined); + native.remember.mockReset().mockResolvedValue(true); + native.start.mockClear(); + native.readonly.mockClear(); + held.clear(); + let sequence = 0; + vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + const url = `blob:source-${++sequence}`; + held.set(url, blob); + return url; + }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation((url) => { + held.delete(url); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + listeners = vi.spyOn(window, 'addEventListener'); + history.replaceState(null, '', '/editor'); +}); +afterEach(() => { + disposeWebMcp(); + delete (document as Document & { modelContext?: unknown }).modelContext; + for (const [type, listener, options] of listeners.mock.calls) window.removeEventListener(type, listener, options); + document.body.classList.remove('embed-mode'); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function registeredBufferTool(): WebMcpTool { + const tools = new Map(); + Object.assign(document, { + modelContext: { registerTool: (tool: WebMcpTool) => tools.set(tool.name, tool), unregisterTool: vi.fn() }, + }); + initWebMcp(); + return tools.get('open_document_buffer')!; +} + +describe('local open outcome ownership', () => { + it('rejects a failed native open so its caller cannot report success', async () => { + native.convert.mockRejectedValueOnce(new Error('conversion failed')); + await expect(openLocalFile(new File(['broken'], 'Broken.docx'))).rejects.toThrow('conversion failed'); + expect(native.start).not.toHaveBeenCalled(); + }); + + it('reports a registered buffer tool failure without changing the old editor readonly state', async () => { + native.convert.mockRejectedValueOnce(new Error('conversion failed')); + const result = await registeredBufferTool().execute({ + fileName: 'Broken.docx', + base64: btoa('broken'), + readonly: true, + }); + expect(result.isError).toBe(true); + expect(JSON.parse(result.content[0].text)).toMatchObject({ ok: false, error: 'conversion failed' }); + expect(native.readonly).not.toHaveBeenCalled(); + }); + + it('applies buffer readonly as part of the owned open rather than a later global mutation', async () => { + const result = await registeredBufferTool().execute({ + fileName: 'Read.docx', + base64: btoa('read'), + readonly: true, + }); + expect(result.isError).not.toBe(true); + expect(native.convert).toHaveBeenCalledWith(expect.objectContaining({ readonly: true })); + expect(native.readonly).not.toHaveBeenCalled(); + }); + + it('reports a superseded tool open as cancelled after a replacement commits', async () => { + let finish!: (saved: boolean) => void; + native.remember.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))); + const result = registeredBufferTool().execute({ fileName: 'Old.docx', base64: btoa('old'), readonly: true }); + await vi.waitFor(() => expect(native.remember).toHaveBeenCalled()); + await onCreateNew('.xlsx'); + finish(true); + expect((await result).isError).toBe(true); + expect(native.readonly).not.toHaveBeenCalled(); + expect(native.start).toHaveBeenCalledWith(expect.objectContaining({ title: 'Untitled.xlsx' })); + }); + + it('consumes an already-reported file picker failure at the event boundary', async () => { + native.convert.mockRejectedValueOnce(new Error('picker conversion failed')); + onOpenDocument(); + const input = document.querySelector('input[type="file"]')!; + Object.defineProperty(input, 'files', { configurable: true, value: [new File(['broken'], 'Broken.docx')] }); + input.dispatchEvent(new Event('change')); + await vi.waitFor(() => expect(native.convert).toHaveBeenCalled()); + await vi.waitFor(() => expect(input.value).toBe('')); + }); +}); + +describe('original document Blob ownership', () => { + it('leaves no original-file URLs behind across local opens and a blank replacement', async () => { + for (let i = 0; i < 3; i++) await openLocalFile(new File([`original-${i}`], `${i}.docx`)); + await onCreateNew('.docx'); + expect(held.size).toBe(0); + }); + + it('leaves no original URL behind after a remote or desktop replacement', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('remote')), + ); + await openDocumentFromUrl('https://files.example/Remote.docx'); + await events.RENDER_OFFICE(chunk); + events.CLOSE_EDITOR(undefined); + expect(held.size).toBe(0); + }); + + it('hands embedded bytes to the editor without retaining an extra source URL', async () => { + history.replaceState(null, '', '/editor?embed=1'); + const posted = vi.spyOn(window.parent, 'postMessage'); + initEmbedApi(); + window.dispatchEvent( + new MessageEvent('message', { + source: window.parent, + origin: 'https://host.example', + data: { id: 'source-url', type: 'document:open-file', payload: { file: new File(['host'], 'Host.docx') } }, + }), + ); + await vi.waitFor(() => + expect(posted).toHaveBeenCalledWith( + expect.objectContaining({ id: 'source-url', type: 'document:opened' }), + 'https://host.example', + ), + ); + expect(held.size).toBe(0); + }); +}); diff --git a/test/unit/document-open.test.ts b/test/unit/document-open.test.ts index b7f01f22..a28cb2be 100644 --- a/test/unit/document-open.test.ts +++ b/test/unit/document-open.test.ts @@ -7,7 +7,7 @@ const { convert, start, clear, remember } = vi.hoisted(() => ({ })); vi.mock('../../lib/converter', () => ({ handleDocumentOperation: convert, loadEditorApi: async () => {} })); vi.mock('../../lib/history/session', () => ({ startDocumentSession: start, newDocumentId: () => 'new-id' })); -vi.mock('../../lib/history/autosave', () => ({ stopAutosaveSession: vi.fn() })); +vi.mock('../../lib/history/autosave', () => ({ clearDocumentSession: vi.fn() })); vi.mock('../../lib/active-document-source', () => ({ clearActiveSource: clear, rememberActiveSource: remember })); vi.mock('../../lib/onlyoffice-editor', () => ({ getReadonlyMode: () => false })); import { openDocumentFromUrl, openLocalFile, onCreateNew } from '../../lib/document'; diff --git a/test/unit/history-autosave-start.test.ts b/test/unit/history-autosave-start.test.ts new file mode 100644 index 00000000..e9e98f01 --- /dev/null +++ b/test/unit/history-autosave-start.test.ts @@ -0,0 +1,119 @@ +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +const { requestSaveDocument } = vi.hoisted(() => ({ requestSaveDocument: vi.fn() })); +vi.mock('../../lib/onlyoffice/save-stream', () => ({ requestSaveDocument })); +vi.mock('../../lib/onlyoffice/readonly', () => ({ getReadonlyMode: () => false })); + +import { + beginAutosaveSession, + clearDocumentSession, + getActiveDocumentId, + getAutosaveDocId, + isAutosaveSessionActive, + setAutosaveEnabled, + stopAutosaveSession, + takeSnapshot, +} from '../../lib/history/autosave'; +import { resetHistoryDbForTests } from '../../lib/history/db'; +import { getLatestSnapshot, resetHistoryClockForTests } from '../../lib/history/store'; +import { markDocumentDirty, resetUnsavedGuardForTests } from '../../lib/unsaved-guard'; + +interface LockRequest { + name: string; + callback: (lock: unknown) => unknown; + resolve: () => void; +} +let requests: LockRequest[]; +let released: string[]; + +function grant(index: number): void { + const request = requests[index]; + const held = request.callback({ name: request.name, mode: 'exclusive' }); + void Promise.resolve(held).then(() => { + released.push(request.name); + request.resolve(); + }); +} + +beforeEach(() => { + clearDocumentSession(); + resetHistoryDbForTests(); + resetHistoryClockForTests(); + resetUnsavedGuardForTests(); + requests = []; + released = []; + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', { + locks: { + request: (name: string, _options: unknown, callback: (lock: unknown) => unknown) => + new Promise((resolve) => { + requests.push({ name, callback, resolve }); + }), + }, + }); + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + setAutosaveEnabled(true); + window.history.replaceState(null, '', '/editor'); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); + requestSaveDocument.mockReset().mockResolvedValue(new File([new Uint8Array([7, 8])], 'Report.docx')); +}); +afterEach(() => { + clearDocumentSession(); + resetHistoryDbForTests(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +it.each(['stop', 'clear', 'disable'])( + 'releases a late document lock after %s without reinstalling recovery', + async (mode) => { + const pending = beginAutosaveSession({ docId: 'old', title: 'Old.docx', origin: 'local' }); + if (mode === 'stop') stopAutosaveSession(); + if (mode === 'clear') clearDocumentSession(); + if (mode === 'disable') setAutosaveEnabled(false); + const listener = vi.spyOn(document, 'addEventListener'); + grant(0); + await pending; + + expect(isAutosaveSessionActive()).toBe(false); + expect(getActiveDocumentId()).toBe(mode === 'clear' ? null : 'old'); + expect(released).toEqual(['document-history:old']); + expect(vi.getTimerCount()).toBe(0); + expect(listener.mock.calls.filter(([type]) => type === 'visibilitychange')).toEqual([]); + markDocumentDirty(); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + document.dispatchEvent(new Event('visibilitychange')); + expect(await takeSnapshot()).toBeNull(); + expect(requestSaveDocument).not.toHaveBeenCalled(); + expect(await getLatestSnapshot('old')).toBeNull(); + }, +); + +it.each([ + [0, 1], + [1, 0], +])('keeps replacement B when pending locks resolve in order %i then %i', async (first, second) => { + const old = beginAutosaveSession({ docId: 'A', title: 'A.docx', origin: 'local' }); + const current = beginAutosaveSession({ docId: 'B', title: 'B.docx', origin: 'local' }); + grant(first); + await (first === 0 ? old : current); + expect(getActiveDocumentId()).toBe('B'); + if (first === 0) expect(isAutosaveSessionActive()).toBe(false); + grant(second); + await Promise.all([old, current]); + + expect(getActiveDocumentId()).toBe('B'); + expect(getAutosaveDocId()).toBe('B'); + expect(released).toEqual(['document-history:A']); + expect(vi.getTimerCount()).toBe(1); + await takeSnapshot(); + expect(await getLatestSnapshot('A')).toBeNull(); + expect(Array.from((await getLatestSnapshot('B'))!.bytes)).toEqual([7, 8]); + clearDocumentSession(); + await Promise.resolve(); + expect(released).toEqual(['document-history:A', 'document-history:B']); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/test/unit/history-session-lifecycle.test.ts b/test/unit/history-session-lifecycle.test.ts new file mode 100644 index 00000000..405e8ca3 --- /dev/null +++ b/test/unit/history-session-lifecycle.test.ts @@ -0,0 +1,134 @@ +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +const stream = vi.hoisted(() => ({ + requestSaveDocument: vi.fn(), + diskWriter: null as null | ((file: File) => Promise), + saved: null as null | (() => void), +})); +vi.mock('../../lib/onlyoffice/save-stream', () => ({ + requestSaveDocument: stream.requestSaveDocument, + setDiskWriter: (writer: (file: File) => Promise) => { + stream.diskWriter = writer; + }, + setSavedToDiskListener: (listener: () => void) => { + stream.saved = listener; + }, +})); +vi.mock('../../lib/onlyoffice/readonly', () => ({ getReadonlyMode: () => false })); + +import { + beginAutosaveSession, + clearDocumentSession, + getActiveDocumentId, + stopAutosaveSession, + setAutosaveEnabled, + takeSnapshot, + isAutosaveSessionActive, +} from '../../lib/history/autosave'; +import { initDocumentHistory } from '../../lib/history'; +import { resetHistoryDbForTests } from '../../lib/history/db'; +import { getDoc, putSnapshot, resetHistoryClockForTests } from '../../lib/history/store'; +import { resetSaveTargetsForTests, saveToDiskFile } from '../../lib/save-target'; +import { startDocumentSession } from '../../lib/history/session'; + +const file = () => new File([new Uint8Array([1, 2, 3])], 'Report.docx'); + +beforeEach(() => { + clearDocumentSession(); + resetHistoryDbForTests(); + resetHistoryClockForTests(); + resetSaveTargetsForTests(); + vi.stubGlobal('indexedDB', new IDBFactory()); + stream.requestSaveDocument.mockReset(); + setAutosaveEnabled(true); + window.history.replaceState(null, '', '/editor'); + initDocumentHistory(); +}); +afterEach(() => { + clearDocumentSession(); + resetHistoryDbForTests(); + resetSaveTargetsForTests(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +async function authorizeTarget(docId: string): Promise { + const writes: Blob[] = []; + vi.stubGlobal('showSaveFilePicker', async () => ({ + name: 'Report.docx', + createWritable: async () => ({ + write: async (data: Blob) => { + writes.push(data); + }, + close: async () => {}, + }), + })); + expect(await saveToDiskFile(docId, file(), 'Document')).toBe('written'); + vi.stubGlobal('showSaveFilePicker', () => { + throw new Error('An existing authorized target should be reused'); + }); + return writes; +} + +it.each(['disabled-before-open', 'disabled-after-open', 'quota-stopped'])( + 'writes the authorized active file when recovery is %s', + async (mode) => { + if (mode === 'disabled-before-open') setAutosaveEnabled(false); + startDocumentSession({ docId: 'active', title: 'Report.docx', origin: 'local' }); + const writes = await authorizeTarget('active'); + if (mode === 'disabled-after-open') setAutosaveEnabled(false); + if (mode === 'quota-stopped') { + vi.stubGlobal('navigator', { storage: { estimate: async () => ({ quota: 2 }) } }); + stream.requestSaveDocument.mockResolvedValue(file()); + expect(await takeSnapshot()).toBeNull(); + } + expect(isAutosaveSessionActive()).toBe(false); + + const updated = file(); + expect(await stream.diskWriter!(updated)).toBe('written'); + expect(writes).toEqual([expect.any(Blob), updated]); + }, +); + +it('routes direct autosave callers to the newest identity while recovery is disabled', async () => { + setAutosaveEnabled(false); + await beginAutosaveSession({ docId: 'old', title: 'Report.docx', origin: 'local' }); + const oldWrites = await authorizeTarget('old'); + await beginAutosaveSession({ docId: 'new', title: 'Report.docx', origin: 'local' }); + const newWrites = await authorizeTarget('new'); + expect(await stream.diskWriter!(file())).toBe('written'); + expect(oldWrites).toHaveLength(1); + expect(newWrites).toHaveLength(2); +}); + +it('marks the active recovery row saved after autosave is stopped', async () => { + await beginAutosaveSession({ docId: 'active', title: 'Report.docx', origin: 'local' }); + await putSnapshot({ id: 'active', title: 'Report.docx', origin: 'local', bytes: new Uint8Array([1]) }); + setAutosaveEnabled(false); + stream.saved!(); + await vi.waitFor(async () => expect((await getDoc('active'))?.savedToDiskAt).toEqual(expect.any(Number))); +}); + +it('stops recovery without losing file identity until the document is explicitly closed', async () => { + await beginAutosaveSession({ docId: 'active', title: 'Report.docx', origin: 'local' }); + const writes = await authorizeTarget('active'); + stopAutosaveSession(); + expect(getActiveDocumentId()).toBe('active'); + expect(await stream.diskWriter!(file())).toBe('written'); + expect(writes).toHaveLength(2); + + clearDocumentSession(); + window.history.replaceState(null, '', '/editor?saved=active'); + expect(getActiveDocumentId()).toBeNull(); + expect(await stream.diskWriter!(file())).toBe('unavailable'); + expect(writes).toHaveLength(2); +}); + +it('does not authorize disk routing from an unvalidated saved URL', async () => { + window.history.replaceState(null, '', '/editor?saved=untrusted'); + const writes = await authorizeTarget('untrusted'); + expect(await stream.diskWriter!(file())).toBe('unavailable'); + expect(writes).toHaveLength(1); +}); diff --git a/test/unit/local-source-open.test.ts b/test/unit/local-source-open.test.ts index 390231fc..0899261f 100644 --- a/test/unit/local-source-open.test.ts +++ b/test/unit/local-source-open.test.ts @@ -94,7 +94,7 @@ describe('local open retains its reload source', () => { window.history.replaceState(null, '', '/editor?saved=old'); convert.mockRejectedValueOnce(new Error('conversion failed')); vi.spyOn(console, 'error').mockImplementation(() => {}); - await openLocalFile(new File(['other'], 'Other.docx')); + await expect(openLocalFile(new File(['other'], 'Other.docx'))).rejects.toThrow('conversion failed'); expect(await (await readActiveSource('old'))?.text()).toBe('original bytes'); expect(new URL(location.href).searchParams.get('saved')).toBe('old'); }); @@ -112,7 +112,7 @@ describe('local open retains its reload source', () => { await vi.waitFor(() => expect(read).toHaveBeenCalled()); convert.mockRejectedValueOnce(new Error('conversion failed')); vi.spyOn(console, 'error').mockImplementation(() => {}); - await openLocalFile(new File(['failed'], 'Failed.docx')); + await expect(openLocalFile(new File(['failed'], 'Failed.docx'))).rejects.toThrow('conversion failed'); finish(new Uint8Array([4, 5]).buffer); await first; const id = new URL(location.href).searchParams.get('saved'); @@ -150,7 +150,7 @@ describe('local open retains its reload source', () => { ), ); finish(new TextEncoder().encode('original bytes').buffer); - await first; + await expect(first).rejects.toMatchObject({ name: 'AbortError' }); expect(sessionStorage.getItem('document-active-source')).toBeNull(); } finally { for (const [type, listener, options] of listeners.mock.calls) window.removeEventListener(type, listener, options); @@ -172,7 +172,7 @@ describe('local open retains its reload source', () => { await openLocalFile(new File(['newer'], 'Newer.docx')); const currentId = new URL(location.href).searchParams.get('saved')!; finish(new TextEncoder().encode('original bytes').buffer); - await first; + await expect(first).rejects.toMatchObject({ name: 'AbortError' }); expect(new URL(location.href).searchParams.get('saved')).toBe(currentId); expect(await (await readActiveSource(currentId))?.text()).toBe('newer'); }); diff --git a/test/unit/open-ownership.test.ts b/test/unit/open-ownership.test.ts index c4e60be4..9e61a066 100644 --- a/test/unit/open-ownership.test.ts +++ b/test/unit/open-ownership.test.ts @@ -11,7 +11,7 @@ vi.mock('ranuts/utils', async (original) => ({ })); vi.mock('../../lib/converter', () => ({ handleDocumentOperation: convert, loadEditorApi: async () => {} })); vi.mock('../../lib/history/session', () => ({ startDocumentSession: start, newDocumentId: () => 'source-id' })); -vi.mock('../../lib/history/autosave', () => ({ stopAutosaveSession: vi.fn() })); +vi.mock('../../lib/history/autosave', () => ({ clearDocumentSession: vi.fn() })); vi.mock('../../lib/active-document-source', () => ({ rememberActiveSource: remember, clearActiveSource: async () => {}, @@ -63,7 +63,7 @@ describe('open ownership across entry points', () => { ), ); finish(true); - await local; + await expect(local).rejects.toMatchObject({ name: 'AbortError' }); expect(start).not.toHaveBeenCalled(); } finally { for (const [type, listener, options] of listeners.mock.calls) window.removeEventListener(type, listener, options); @@ -83,7 +83,7 @@ describe('open ownership across entry points', () => { await vi.waitFor(() => expect(remember).toHaveBeenCalled()); await events.RENDER_OFFICE(chunk); finish(true); - await local; + await expect(local).rejects.toMatchObject({ name: 'AbortError' }); expect(start).not.toHaveBeenCalled(); }); it.each(['open', 'close'])('cancels a remote fetch on desktop %s', async (action) => { diff --git a/test/unit/save-stream-lifecycle.test.ts b/test/unit/save-stream-lifecycle.test.ts index 862cfb64..b7c50b92 100644 --- a/test/unit/save-stream-lifecycle.test.ts +++ b/test/unit/save-stream-lifecycle.test.ts @@ -2,7 +2,8 @@ import 'fake-indexeddb/auto'; import { IDBFactory } from 'fake-indexeddb'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; vi.mock('../../lib/history/autosave', () => ({ - getAutosaveDocId: () => 'save-document', + getActiveDocumentId: () => 'save-document', + clearDocumentSession: vi.fn(), beginAutosaveSession: vi.fn(), isAutosaveEnabled: () => true, setAutosaveEnabled: vi.fn(), From fb5c213a15996d9aaef211774b49d1984929e8b4 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 17 Oct 2026 21:01:49 +0800 Subject: [PATCH 3/5] fix(agent): bound Anthropic requests through response consumption --- lib/agent-plugin/ui/request-error.ts | 3 +- packages/agent-core/README.md | 16 +- packages/agent-core/src/llm/anthropic.ts | 110 ++++-- .../unit/agent-llm-anthropic-lifetime.test.ts | 358 ++++++++++++++++++ test/unit/agent-llm-anthropic.test.ts | 6 +- 5 files changed, 459 insertions(+), 34 deletions(-) create mode 100644 test/unit/agent-llm-anthropic-lifetime.test.ts diff --git a/lib/agent-plugin/ui/request-error.ts b/lib/agent-plugin/ui/request-error.ts index 88705072..a1c9fe30 100644 --- a/lib/agent-plugin/ui/request-error.ts +++ b/lib/agent-plugin/ui/request-error.ts @@ -11,7 +11,7 @@ export function requestErrorKey( if (error instanceof DOMException && error.name === 'TimeoutError') return 'agentServiceTimedOut'; if (!(error instanceof Error) && typeof error !== 'string') return; const message = error instanceof Error ? error.message : error; - if (message === 'AI request timed out') return 'agentServiceTimedOut'; + if (message === 'AI request timed out' || message === 'Request timed out.') return 'agentServiceTimedOut'; if (error instanceof Error && error.name === 'TimeoutError') return 'agentServiceTimedOut'; if ( ['Failed to fetch', 'NetworkError when attempting to fetch resource.', 'Load failed', 'Connection error.'].includes( @@ -23,6 +23,7 @@ export function requestErrorKey( error instanceof Error && 'status' in error && typeof error.status === 'number' ? error.status : Number(/^(?:OpenAI|Ollama|Gemini) request failed: (\d{3})(?:\s|$)/.exec(message)?.[1]); + if (error instanceof Error && 'type' in error && error.type === 'overloaded_error') return 'agentServiceUnavailable'; if (status === 401 || status === 403) return 'agentServiceAuthFailed'; if (status === 429) return 'agentServiceRateLimited'; if (status === 408 || status === 504) return 'agentServiceTimedOut'; diff --git a/packages/agent-core/README.md b/packages/agent-core/README.md index eee305be..4f4546bf 100644 --- a/packages/agent-core/README.md +++ b/packages/agent-core/README.md @@ -39,9 +39,9 @@ only knows the `LLMProvider` interface and the `AgentTool` registry. The document product uses a default-off assistant switch. Opening the panel never downloads a model or connects a service. Runtime configuration is reached through the usage settings; generated selection edits require explicit review and confirmation. Browser-agent tools are documented separately in the developer reference. -## Fetch provider request bounds +## Remote provider request bounds -OpenAI-compatible, Gemini, Ollama and Loopback providers use a five-minute total deadline +Anthropic, OpenAI-compatible, Gemini, Ollama and Loopback providers use a five-minute total deadline per request, covering headers and complete JSON or incremental response reads. Set `timeoutMs` in their constructor options or `createProvider` options to override it. The duration must be positive, finite and at most 2,147,483,647 milliseconds. @@ -50,13 +50,17 @@ Caller cancellation retains its original reason; deadline expiry rejects with when an injected transport ignores cancellation. A late response cannot become a successful result. Timers and caller subscriptions are released on settlement. -HTTP failures carry a numeric `status` and a safe provider/status message. +Fetch-based HTTP failures carry a numeric `status` and a safe provider/status message. Upstream error bodies are cancelled without being read or included in errors. Hosts can classify structured errors directly; serialized messages retain the -same provider/status category. No request is retried automatically. These bounds -do not alter the Anthropic SDK or browser-local inference lifetimes. +same provider/status category. Fetch-based providers do not retry requests automatically. -`maxResponseBytes` bounds actual response bytes, including streaming frames, to +Anthropic retains the official SDK protocol and retry policy; its provider owns the total deadline across SDK requests +and JSON or streamed message consumption, and stops the SDK stream on cancellation or +consumer failure. Known SDK timeout and overload failures map to safe product categories. +Browser-local inference lifetimes are separate. + +For fetch-based providers, `maxResponseBytes` bounds actual response bytes, including streaming frames, to 8 MiB by default. It must be a positive safe integer. Declared oversized bodies are rejected before reading; actual bytes are checked even if Content-Length is missing or incorrect. Native response readers are cancelled on overflow, Stop, diff --git a/packages/agent-core/src/llm/anthropic.ts b/packages/agent-core/src/llm/anthropic.ts index be6c0570..c76c088a 100644 --- a/packages/agent-core/src/llm/anthropic.ts +++ b/packages/agent-core/src/llm/anthropic.ts @@ -38,6 +38,9 @@ interface AnthropicResponse { export interface AnthropicStream { on(event: 'text', listener: (textDelta: string) => void): unknown; finalMessage(): Promise; + /** Optional SDK lifecycle methods; minimal injected clients need not implement them. */ + abort?(): void; + off?(event: 'text', listener: (textDelta: string) => void): unknown; } /** The slice of the Anthropic client this provider uses (eases test mocking). */ @@ -105,6 +108,8 @@ export interface AnthropicProviderOptions { model?: string; systemPrompt?: string; maxTokens?: number; + /** Total request deadline, including SDK response consumption. Defaults to five minutes. */ + timeoutMs?: number; /** Inject a client (tests); otherwise one is built lazily from the API key. */ client?: AnthropicLike; } @@ -116,6 +121,7 @@ export class AnthropicProvider implements LLMProvider { private readonly model: string; private readonly systemPrompt: string; private readonly maxTokens: number; + private readonly timeoutMs: number; private client?: AnthropicLike; constructor(options: AnthropicProviderOptions = {}) { @@ -123,6 +129,7 @@ export class AnthropicProvider implements LLMProvider { this.model = options.model ?? DEFAULT_MODEL; this.systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT; this.maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS; + this.timeoutMs = options.timeoutMs ?? 300_000; this.client = options.client; } @@ -149,16 +156,83 @@ export class AnthropicProvider implements LLMProvider { }; } + private async request( + messages: LLMMessage[], + tools: LLMToolDef[], + owner?: AbortSignal, + onDelta?: (textDelta: string) => void, + ): Promise { + owner?.throwIfAborted(); + if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 2_147_483_647) + throw new RangeError('Provider timeout must be a positive timer duration'); + const controller = new AbortController(); + const forwardAbort = () => controller.abort(owner?.reason); + owner?.addEventListener('abort', forwardAbort, { once: true }); + let stream: AnthropicStream | undefined; + let streamStopped = false; + let active = true; + const stopStream = () => { + if (!stream || streamStopped) return; + streamStopped = true; + // Host clients may throw during cleanup; retain the initiating failure. + try { + stream.abort?.(); + } catch {} + }; + let onAbort: () => void = () => {}; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => { + stopStream(); + reject(controller.signal.reason); + }; + controller.signal.addEventListener('abort', onAbort, { once: true }); + }); + const timer = setTimeout( + () => controller.abort(new DOMException('AI request timed out', 'TimeoutError')), + this.timeoutMs, + ); + const listener = (delta: string) => { + if (!active || controller.signal.aborted) return; + try { + onDelta!(delta); + } catch (error) { + controller.abort(error); + } + }; + try { + const pending = (async () => { + const client = this.getClient(); + const body = this.requestBody(messages, tools); + if (!onDelta || !client.messages.stream) return client.messages.create(body, { signal: controller.signal }); + // The SDK's own timeout covers headers, not finalMessage() consumption. + stream = client.messages.stream(body, { signal: controller.signal }); + if (controller.signal.aborted) stopStream(); + controller.signal.throwIfAborted(); + // Observe rejection before attaching callbacks: minimal host clients may + // emit synchronously, and callbacks can stop or throw reentrantly. + const final = stream.finalMessage(); + stream.on('text', listener); + return final; + })(); + const response = await Promise.race([pending, aborted]); + controller.signal.throwIfAborted(); + return parseAnthropicResponse(response); + } catch (error) { + if (!controller.signal.aborted) controller.abort(error); + throw controller.signal.reason; + } finally { + active = false; + clearTimeout(timer); + owner?.removeEventListener('abort', forwardAbort); + controller.signal.removeEventListener('abort', onAbort); + try { + stream?.off?.('text', listener); + } catch {} + } + } + async chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise { - signal?.throwIfAborted(); - const response = await this.getClient() - .messages.create(this.requestBody(messages, tools), signal ? { signal } : undefined) - .catch((error: unknown) => { - signal?.throwIfAborted(); - throw error; - }); - signal?.throwIfAborted(); - return parseAnthropicResponse(response); + return this.request(messages, tools, signal); } async chatStream( @@ -167,22 +241,6 @@ export class AnthropicProvider implements LLMProvider { onDelta: (textDelta: string) => void, signal?: AbortSignal, ): Promise { - signal?.throwIfAborted(); - const client = this.getClient(); - // The SDK's messages.stream() emits incremental "text" events and resolves - // the full message via finalMessage(), which parses exactly like create(). - if (!client.messages.stream) { - return this.chat(messages, tools, signal); - } - const stream = client.messages.stream(this.requestBody(messages, tools), signal ? { signal } : undefined); - stream.on('text', (delta) => { - if (!signal?.aborted) onDelta(delta); - }); - const response = await stream.finalMessage().catch((error: unknown) => { - signal?.throwIfAborted(); - throw error; - }); - signal?.throwIfAborted(); - return parseAnthropicResponse(response); + return this.request(messages, tools, signal, onDelta); } } diff --git a/test/unit/agent-llm-anthropic-lifetime.test.ts b/test/unit/agent-llm-anthropic-lifetime.test.ts new file mode 100644 index 00000000..281eb502 --- /dev/null +++ b/test/unit/agent-llm-anthropic-lifetime.test.ts @@ -0,0 +1,358 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import Anthropic, { + APIConnectionTimeoutError, + APIError, +} from '../../packages/agent-core/node_modules/@anthropic-ai/sdk'; +import { AnthropicProvider, type AnthropicLike } from '../../packages/agent-core/src/llm/anthropic'; +import { createProvider } from '../../packages/agent-core/src/llm/factory'; +import { requestErrorKey } from '../../lib/agent-plugin/ui/request-error'; + +const response = { content: [{ type: 'text', text: 'done' }], stop_reason: 'end_turn' }; +afterEach(() => vi.useRealTimers()); + +describe('Anthropic total request lifetime', () => { + it.each(['chat', 'stream', 'fallback'] as const)( + 'bounds a noncooperative %s request and releases subscriptions', + async (mode) => { + vi.useFakeTimers(); + const owner = new AbortController(); + const remove = vi.spyOn(owner.signal, 'removeEventListener'); + let transportSignal: AbortSignal | undefined; + let listener: ((text: string) => void) | undefined; + const abort = vi.fn(); + const off = vi.fn(); + const stalled = new Promise(() => {}); + const client: AnthropicLike = { + messages: { + create: (_body, options) => { + transportSignal = options?.signal; + return stalled; + }, + ...(mode === 'stream' + ? { + stream: (_body: Record, options?: { signal?: AbortSignal }) => { + transportSignal = options?.signal; + return { + on: (_event: 'text', next: (text: string) => void) => { + listener = next; + }, + finalMessage: () => stalled, + abort, + off, + }; + }, + } + : {}), + }, + }; + const provider = createProvider('anthropic', { client, timeoutMs: 25 }); + const deltas: string[] = []; + let outcome: unknown; + const pending = ( + mode === 'chat' + ? provider.chat([], [], owner.signal) + : provider.chatStream!([], [], (text) => deltas.push(text), owner.signal) + ).catch((error) => { + outcome = error; + }); + await vi.advanceTimersByTimeAsync(25); + expect(outcome).toMatchObject({ name: 'TimeoutError', message: 'AI request timed out' }); + expect(transportSignal?.aborted).toBe(true); + listener?.('late'); + expect(deltas).toEqual([]); + if (mode === 'stream') { + expect(abort).toHaveBeenCalledOnce(); + expect(off).toHaveBeenCalledWith('text', listener); + } + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + expect(vi.getTimerCount()).toBe(0); + await pending; + }, + ); + + it.each(['chat', 'stream'] as const)('retains caller reasons when a %s client ignores cancellation', async (mode) => { + vi.useFakeTimers(); + const owner = new AbortController(); + const reason = new Error('caller stopped'); + const stalled = new Promise(() => {}); + const provider = new AnthropicProvider({ + client: { + messages: { + create: () => stalled, + ...(mode === 'stream' ? { stream: () => ({ on: () => {}, finalMessage: () => stalled }) } : {}), + }, + }, + }); + let outcome: unknown; + const pending = ( + mode === 'chat' ? provider.chat([], [], owner.signal) : provider.chatStream([], [], () => {}, owner.signal) + ).catch((error) => { + outcome = error; + }); + owner.abort(reason); + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBe(reason); + expect(vi.getTimerCount()).toBe(0); + await pending; + }); + + it.each(['throw', 'stop'] as const)( + 'terminates a stream after consumer %s, including reentrant deltas', + async (mode) => { + vi.useFakeTimers(); + const owner = new AbortController(); + const reason = new Error('consumer stopped'); + const abort = vi.fn(); + const off = vi.fn(); + const provider = new AnthropicProvider({ + client: { + messages: { + create: async () => response, + stream: () => ({ + on: (_event, listener) => { + listener('first'); + listener('late'); + }, + finalMessage: () => new Promise(() => {}), + abort, + off, + }), + }, + }, + }); + const deltas: string[] = []; + let outcome: unknown; + const pending = provider + .chatStream( + [], + [], + (delta) => { + deltas.push(delta); + if (mode === 'throw') throw reason; + owner.abort(reason); + }, + owner.signal, + ) + .catch((error) => { + outcome = error; + }); + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBe(reason); + expect(deltas).toEqual(['first']); + expect(abort).toHaveBeenCalledOnce(); + expect(off).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + await pending; + }, + ); + + it.each(['chat', 'stream'] as const)('releases its deadline after successful %s completion', async (mode) => { + vi.useFakeTimers(); + const owner = new AbortController(); + const remove = vi.spyOn(owner.signal, 'removeEventListener'); + const abort = vi.fn(); + const off = vi.fn(); + const provider = new AnthropicProvider({ + timeoutMs: 25, + client: { + messages: { + create: async () => response, + stream: () => ({ on: () => {}, finalMessage: async () => response, abort, off }), + }, + }, + }); + const result = await (mode === 'chat' + ? provider.chat([], [], owner.signal) + : provider.chatStream([], [], () => {}, owner.signal)); + expect(result.text).toBe('done'); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + expect(vi.getTimerCount()).toBe(0); + expect(abort).not.toHaveBeenCalled(); + if (mode === 'stream') expect(off).toHaveBeenCalledOnce(); + }); + + it.each([0, -1, Infinity, NaN, 2_147_483_648])( + 'rejects invalid total timeout %s before starting transport', + async (timeoutMs) => { + const create = vi.fn(async () => response); + const provider = new AnthropicProvider({ timeoutMs, client: { messages: { create } } }); + await expect(provider.chat([], [])).rejects.toThrow(RangeError); + expect(create).not.toHaveBeenCalled(); + }, + ); + + it('ignores post-completion text from minimal clients without listener removal', async () => { + let listener!: (text: string) => void; + const deltas: string[] = []; + const provider = new AnthropicProvider({ + client: { + messages: { + create: async () => response, + stream: () => ({ + on: (_event, next) => { + listener = next; + }, + finalMessage: async () => response, + }), + }, + }, + }); + expect((await provider.chatStream([], [], (text) => deltas.push(text))).text).toBe('done'); + listener('late'); + expect(deltas).toEqual([]); + }); + + it('bounds the default lifetime and discards a noncooperative late response', async () => { + vi.useFakeTimers(); + let complete!: (value: typeof response) => void; + const provider = new AnthropicProvider({ + client: { + messages: { + create: () => + new Promise((resolve) => { + complete = resolve; + }), + }, + }, + }); + let outcome: unknown; + const pending = provider.chat([], []).then( + (value) => { + outcome = value; + }, + (error) => { + outcome = error; + }, + ); + await vi.advanceTimersByTimeAsync(299_999); + expect(outcome).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(outcome).toMatchObject({ name: 'TimeoutError' }); + complete(response); + await pending; + expect(outcome).toMatchObject({ name: 'TimeoutError' }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not start a request or timer for a pre-aborted caller', async () => { + vi.useFakeTimers(); + const owner = new AbortController(); + owner.abort('already stopped'); + const create = vi.fn(async () => response); + const provider = new AnthropicProvider({ client: { messages: { create } } }); + await expect(provider.chat([], [], owner.signal)).rejects.toBe('already stopped'); + expect(create).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('retains a stream failure when abort and listener cleanup throw', async () => { + vi.useFakeTimers(); + const reason = new Error('upstream failed'); + const owner = new AbortController(); + const remove = vi.spyOn(owner.signal, 'removeEventListener'); + const provider = new AnthropicProvider({ + client: { + messages: { + create: async () => response, + stream: () => ({ + on: () => {}, + finalMessage: async () => { + throw reason; + }, + abort: () => { + throw new Error('cleanup failed'); + }, + off: () => { + throw new Error('cleanup failed'); + }, + }), + }, + }, + }); + await expect(provider.chatStream([], [], () => {}, owner.signal)).rejects.toBe(reason); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(['chat', 'stream'] as const)( + 'bounds real SDK %s body consumption after headers, beyond its SDK timeout', + async (mode) => { + let fetched = false; + let cancelled = false; + let fetchSignal: AbortSignal | undefined; + const client = new Anthropic({ + apiKey: 'test', + dangerouslyAllowBrowser: true, + maxRetries: 0, + timeout: 10, + fetch: async (_url, init) => { + fetched = true; + fetchSignal = init?.signal ?? undefined; + return new Response( + new ReadableStream({ + start(controller) { + init?.signal?.addEventListener( + 'abort', + () => controller.error(new DOMException('aborted', 'AbortError')), + { once: true }, + ); + if (mode === 'stream') + controller.enqueue( + new TextEncoder().encode( + 'event: message_start\ndata: {"type":"message_start","message":{"id":"test","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}}\n\n', + ), + ); + }, + cancel() { + cancelled = true; + }, + }), + { headers: { 'content-type': mode === 'stream' ? 'text/event-stream' : 'application/json' } }, + ); + }, + }); + const owner = new AbortController(); + const provider = new AnthropicProvider({ + timeoutMs: 40, + client: client as unknown as AnthropicLike, + model: 'test', + maxTokens: 100, + }); + const pending = ( + mode === 'chat' ? provider.chat([], [], owner.signal) : provider.chatStream([], [], () => {}, owner.signal) + ).catch((error) => error); + let watchdog: ReturnType | undefined; + try { + const result = await Promise.race([ + pending, + new Promise((resolve) => { + watchdog = setTimeout(() => resolve('still pending'), 250); + }), + ]); + expect(fetched).toBe(true); + expect(result).toMatchObject({ name: 'TimeoutError' }); + expect(fetchSignal?.aborted || cancelled).toBe(true); + } finally { + clearTimeout(watchdog); + owner.abort(); + await pending; + } + }, + ); +}); + +it('classifies actual SDK timeout and SSE overload errors without reading private response text', () => { + expect(requestErrorKey(new APIConnectionTimeoutError({}))).toBe('agentServiceTimedOut'); +}); + +it('classifies actual SDK SSE overload errors without exposing private response text', () => { + const overloaded = new APIError( + undefined, + { error: { type: 'overloaded_error', message: 'private key and destination' } }, + undefined, + new Headers(), + 'overloaded_error', + ); + expect(requestErrorKey(overloaded)).toBe('agentServiceUnavailable'); + expect(requestErrorKey(new Error('private overloaded_error destination'))).toBeUndefined(); +}); diff --git a/test/unit/agent-llm-anthropic.test.ts b/test/unit/agent-llm-anthropic.test.ts index 339ee82d..152312c0 100644 --- a/test/unit/agent-llm-anthropic.test.ts +++ b/test/unit/agent-llm-anthropic.test.ts @@ -155,8 +155,9 @@ it.each(['chat', 'stream', 'fallback'] as const)( 'cancels a pending Anthropic %s request through the SDK signal', async (mode) => { const abort = new AbortController(); + let transportSignal: AbortSignal | undefined; const request = (_body: Record, options?: { signal?: AbortSignal }) => { - expect(options?.signal).toBe(abort.signal); + transportSignal = options?.signal; return new Promise((_resolve, reject) => { options!.signal!.addEventListener('abort', () => reject(new APIUserAbortError()), { once: true }); }); @@ -179,7 +180,10 @@ it.each(['chat', 'stream', 'fallback'] as const)( const typed = provider as import('@ranuts/agent-core/llm/types').LLMProvider; const pending = mode === 'chat' ? typed.chat([], [], abort.signal) : typed.chatStream!([], [], () => {}, abort.signal); + expect(transportSignal).toBeInstanceOf(AbortSignal); + expect(transportSignal?.aborted).toBe(false); abort.abort(); + expect(transportSignal?.aborted).toBe(true); await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); }, ); From 5edb3a350c697ff69b3d81d58cb71369e96572b8 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 17 Oct 2026 21:01:54 +0800 Subject: [PATCH 4/5] perf(offline): reuse unchanged prepared runtime assets --- public/sw.js | 29 ++- test/unit/sw-assistant-reuse.test.ts | 285 +++++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 test/unit/sw-assistant-reuse.test.ts diff --git a/public/sw.js b/public/sw.js index 0707a9c1..cf3cf5ac 100644 --- a/public/sw.js +++ b/public/sw.js @@ -315,6 +315,24 @@ async function restorePreparedAssistant() { await Promise.all([...capabilities].map(prepareAssistantAssets)); } +// Only this build's capability allowlist reaches here. An exact same-origin +// URL identifies reusable bytes; never consult provider/model or vendor caches. +async function reuseAssistantAsset(cache, asset, earlierCoreNames) { + if (new URL(asset, self.location.origin).origin !== self.location.origin) return false; + for (const name of earlierCoreNames) { + try { + const response = await (await caches.open(name)).match(asset); + if (response?.status === 200) { + await cache.put(asset, response.clone()); + return true; + } + } catch { + // A retired/unreadable entry is only a missed optimization. Fetch it below. + } + } + return false; +} + function prepareAssistantAssets(capability) { if (!Object.prototype.hasOwnProperty.call(ASSISTANT_CAPABILITY_ASSETS, capability)) return Promise.reject(new Error('Unknown assistant capability')); @@ -323,8 +341,15 @@ function prepareAssistantAssets(capability) { .open(CORE_CACHE) .then(async (cache) => { const missing = []; - for (const asset of ASSISTANT_CAPABILITY_ASSETS[capability]) - if (!(await cache.match(asset))) missing.push(asset); + let earlierCoreNames; + for (const asset of ASSISTANT_CAPABILITY_ASSETS[capability]) { + if (await cache.match(asset)) continue; + earlierCoreNames ??= await caches + .keys() + .then((names) => names.filter((name) => name.startsWith('document-editor-core-') && name !== CORE_CACHE)) + .catch(() => []); + if (!(await reuseAssistantAsset(cache, asset, earlierCoreNames))) missing.push(asset); + } if (missing.length) await cache.addAll(missing); const marker = () => new Response('1', { headers: { 'X-Document-Assistant-Prepared': '1' } }); await cache.put(preparedKey(capability), marker()); diff --git a/test/unit/sw-assistant-reuse.test.ts b/test/unit/sw-assistant-reuse.test.ts new file mode 100644 index 00000000..421a0d0f --- /dev/null +++ b/test/unit/sw-assistant-reuse.test.ts @@ -0,0 +1,285 @@ +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { expect, it, vi } from 'vitest'; + +const origin = 'https://editor.example'; +const currentCore = 'document-editor-core-200'; +const oldCore = 'document-editor-core-100'; +const runtimeAsset = '/assets/cpu-abc123.wasm'; +const helperAsset = '/assets/cpu-helper-def456.js'; +const markerKey = '/__document-assistant-prepared__/cpu'; +const stateCache = 'document-assistant-capabilities'; +const url = (path: string) => new URL(path, origin).href; +const prepared = () => new Response('1', { headers: { 'X-Document-Assistant-Prepared': '1' } }); + +function worker( + initial: Record = {}, + options: { + fetchAssets?: (assets: string[]) => Promise; + unreadable?: 'open' | 'match' | 'clone'; + } = {}, +) { + const stores = new Map( + Object.entries(initial).map(([name, entries]) => [ + name, + new Map(entries.map(([path, response]) => [url(path), response])), + ]), + ); + const listeners = new Map void>(); + const downloaded: string[] = []; + const matched: Response[] = []; + const claim = vi.fn(); + const open = async (name: string) => { + if (name === oldCore && options.unreadable === 'open') throw Error('cache unavailable'); + if (!stores.has(name)) stores.set(name, new Map()); + const store = stores.get(name)!; + return { + match: async (path: string) => { + if (name === oldCore && path.includes('/assets/')) { + if (options.unreadable === 'match') throw Error('entry unavailable'); + if (options.unreadable === 'clone') + return { + status: 200, + clone: () => { + throw Error('unreadable body'); + }, + }; + } + const response = store.get(url(path))?.clone(); + if (response) matched.push(response); + return response; + }, + put: async (path: string, response: Response) => { + store.set(url(path), response); + }, + addAll: async (paths: string[]) => { + const assets = paths.filter((path) => path.startsWith('/assets/')); + downloaded.push(...assets); + if (assets.length) await options.fetchAssets?.(assets); + for (const path of paths) + store.set(url(path), new Response(path.includes('/assets/') ? 'network bytes' : '')); + }, + keys: async () => [...store.keys()].map((path) => new Request(path)), + delete: async (request: Request) => store.delete(request.url), + }; + }; + const source = readFileSync('public/sw.js', 'utf8') + .replaceAll('SW_VERSION_PLACEHOLDER', '200') + .replaceAll('VENDOR_VERSION_PLACEHOLDER', 'same') + .replace( + 'const ASSISTANT_CAPABILITY_ASSETS = {};', + `const ASSISTANT_CAPABILITY_ASSETS = ${JSON.stringify({ cpu: [runtimeAsset, helperAsset] })};`, + ); + runInNewContext(source, { + URL, + Promise, + Date, + Response, + Request, + Headers, + Set, + Map, + self: { + location: { origin }, + addEventListener: (type: string, listener: (event: any) => void) => listeners.set(type, listener), + skipWaiting: () => {}, + clients: { matchAll: async () => [], claim }, + }, + caches: { + open, + keys: async () => [...stores.keys()], + has: async (name: string) => stores.has(name), + delete: async (name: string) => stores.delete(name), + }, + }); + async function event(type: string, data?: unknown) { + let job: Promise | undefined; + const reply = vi.fn(); + listeners.get(type)!({ + data, + ports: [{ postMessage: reply }], + waitUntil: (promise: Promise) => { + job = promise; + }, + }); + await job; + return reply; + } + return { event, stores, downloaded, matched, claim }; +} + +it('reuses identical build assets from an earlier owned core when the network is unavailable', async () => { + const w = worker( + { + [stateCache]: [[markerKey, prepared()]], + [oldCore]: [ + [ + runtimeAsset, + new Response(new Uint8Array([0, 97, 115, 109]), { + headers: { 'Content-Type': 'application/wasm', 'Cross-Origin-Resource-Policy': 'same-origin' }, + }), + ], + [helperAsset, new Response('cached helper')], + ], + }, + { + fetchAssets: async () => { + throw Error('offline'); + }, + }, + ); + await w.event('install'); + const copied = w.stores.get(currentCore)!.get(url(runtimeAsset))!; + expect([...new Uint8Array(await copied.clone().arrayBuffer())]).toEqual([0, 97, 115, 109]); + expect(copied.headers.get('Content-Type')).toBe('application/wasm'); + expect(copied.headers.get('Cross-Origin-Resource-Policy')).toBe('same-origin'); + expect(w.matched).not.toContain(copied); + expect(w.stores.get(oldCore)!.get(url(runtimeAsset))!.bodyUsed).toBe(false); + expect(w.downloaded).toEqual([]); + expect(w.stores.get(stateCache)!.has(url(markerKey))).toBe(true); +}); + +it('keeps current-core hits without downloading or reading an earlier core', async () => { + const w = worker( + { + [currentCore]: [ + [runtimeAsset, new Response('current runtime')], + [helperAsset, new Response('current helper')], + ], + }, + { unreadable: 'open' }, + ); + const reply = await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + expect(reply).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: true }); + expect(w.downloaded).toEqual([]); + expect(await w.stores.get(currentCore)!.get(url(runtimeAsset))!.text()).toBe('current runtime'); +}); + +it.each([ + ['a changed content hash', oldCore, '/assets/cpu-oldhash.wasm'], + ['a different query', oldCore, `${runtimeAsset}?old=1`], + ['another origin', oldCore, `https://outside.example${runtimeAsset}`], + ['an unrelated cache', 'model-provider-cache', runtimeAsset], + ['a vendor runtime cache', 'document-editor-runtime-same', runtimeAsset], +])('downloads the exact allowlisted URL instead of trusting %s', async (_label, name, path) => { + const w = worker({ [name]: [[path, new Response('untrusted or stale bytes')]] }); + await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + expect(w.downloaded).toEqual([runtimeAsset, helperAsset]); + expect(await w.stores.get(currentCore)!.get(url(runtimeAsset))!.text()).toBe('network bytes'); +}); + +it.each([undefined, 'open', 'match', 'clone'] as const)( + 'fetches safely when an earlier cache is missing or unreadable: %s', + async (unreadable) => { + const w = worker({ [oldCore]: [] }, { unreadable }); + const reply = await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + expect(reply).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: true }); + expect(w.downloaded).toEqual([runtimeAsset, helperAsset]); + }, +); + +it('does not let a caller add URLs to the build allowlist', async () => { + const w = worker(); + await w.event('message', { + type: 'PREPARE_ASSISTANT', + capability: 'cpu', + urls: ['/private/file', 'https://outside.example/weights'], + }); + const rejected = await w.event('message', { type: 'PREPARE_ASSISTANT', capability: '/private/file' }); + expect(rejected).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: false }); + expect(w.downloaded).toEqual([runtimeAsset, helperAsset]); +}); + +it('shares concurrent preparation and waits for every asset before committing markers', async () => { + let release!: () => void; + let started!: () => void; + const fetching = new Promise((resolve) => { + started = resolve; + }); + const pending = new Promise((resolve) => { + release = resolve; + }); + const w = worker( + { [oldCore]: [[runtimeAsset, new Response('cached runtime')]] }, + { + fetchAssets: async () => { + started(); + await pending; + }, + }, + ); + const first = w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + const second = w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + await fetching; + expect(w.downloaded).toEqual([helperAsset]); + expect(w.stores.get(currentCore)!.has(url(markerKey))).toBe(false); + expect(w.stores.get(stateCache)?.has(url(markerKey)) ?? false).toBe(false); + release(); + const replies = await Promise.all([first, second]); + for (const reply of replies) expect(reply).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: true }); + expect(w.stores.get(currentCore)!.has(url(markerKey))).toBe(true); +}); + +it('preserves earlier caches and rejects activation when any prepared asset is unavailable', async () => { + const w = worker( + { + [oldCore]: [ + [markerKey, prepared()], + [runtimeAsset, new Response('cached runtime')], + ], + }, + { + fetchAssets: async () => { + throw Error('offline'); + }, + }, + ); + await expect(w.event('activate')).rejects.toThrow('offline'); + expect(w.stores.has(oldCore)).toBe(true); + expect(w.stores.get(currentCore)!.has(url(markerKey))).toBe(false); + expect(w.claim).not.toHaveBeenCalled(); + expect(w.downloaded).toEqual([helperAsset]); +}); + +it('does not reuse an unsuccessful response from an earlier core', async () => { + const w = worker({ [oldCore]: [[runtimeAsset, new Response('not available', { status: 404 })]] }); + await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + expect(w.downloaded).toEqual([runtimeAsset, helperAsset]); + expect(await w.stores.get(currentCore)!.get(url(runtimeAsset))!.text()).toBe('network bytes'); +}); + +it('finishes the prepared graph before retiring an earlier core and claiming clients', async () => { + let release!: () => void; + let started!: () => void; + const fetching = new Promise((resolve) => { + started = resolve; + }); + const pending = new Promise((resolve) => { + release = resolve; + }); + const w = worker( + { + [oldCore]: [ + [markerKey, prepared()], + [runtimeAsset, new Response('cached runtime')], + ], + }, + { + fetchAssets: async () => { + started(); + await pending; + }, + }, + ); + const activation = w.event('activate'); + await fetching; + expect(w.stores.has(oldCore)).toBe(true); + expect(w.stores.get(currentCore)!.has(url(markerKey))).toBe(false); + expect(w.claim).not.toHaveBeenCalled(); + release(); + await activation; + expect(w.downloaded).toEqual([helperAsset]); + expect(w.stores.has(oldCore)).toBe(false); + expect(w.stores.get(currentCore)!.has(url(markerKey))).toBe(true); + expect(w.claim).toHaveBeenCalledOnce(); +}); From 04558686ff729a072cd7de0fd7fa17e819c3d2f7 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 17 Oct 2026 21:02:18 +0800 Subject: [PATCH 5/5] docs: record document lifecycle regression verification --- CHANGELOG.md | 13 ++ ...2026-10-17-document-lifecycle-followups.md | 117 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 docs/explorations/2026-10-17-document-lifecycle-followups.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 626da204..0db5de7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ notes. Entries describe what users experience, not internal refactors. ### Fixed +- Editing a document after more than seven idle days keeps the new recovery + bytes instead of leaving an empty history record. +- Turning off recovery, or stopping it after a storage failure, keeps ordinary + saves linked to the document's authorized file. +- Closing or replacing a document cancels recovery startup still waiting for a + browser lock, so late callbacks cannot revive the previous document's session. +- Anthropic requests now time out even when response data stops after headers; + timeouts and service overloads show the corresponding recovery guidance. +- Browser tools report failed or superseded local opens as failures and apply + read-only mode only as part of the document being opened. +- Repeated document opens no longer retain extra Blob URLs for previous source + files. App updates reuse unchanged prepared assistant runtime assets already + held in earlier app caches. - First-visit editor preparation waits for the service worker to control the page, so warmed font resources remain available in its offline cache. - A failed recovery snapshot no longer removes an older document when storage diff --git a/docs/explorations/2026-10-17-document-lifecycle-followups.md b/docs/explorations/2026-10-17-document-lifecycle-followups.md new file mode 100644 index 00000000..45056cc2 --- /dev/null +++ b/docs/explorations/2026-10-17-document-lifecycle-followups.md @@ -0,0 +1,117 @@ +# Document lifecycle follow-ups + +Baseline: `6b7fbdfd`. The user explicitly approved all five demonstrated defects +and both optimizations from the post-PR-279 audit, regression tests, separate +commits, a new PR and following its latest CI head until every check passes. + +## Scope lock and evidence + +| Task | Verified baseline evidence | Change boundary | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Expired active document | Real store/database with fake IndexedDB returns successful metadata but no latest bytes after seven days. | `lib/history/store.ts`, history store/browser regressions. | +| Independent document identity | Real session/autosave/disk routing returns unavailable with an authorized target after autosave is disabled. | `lib/history/{session,index,autosave}.ts`, document/close callers as needed, related tests. | +| Pending lock cancellation | Delayed Web Locks callback recreates a stopped autosave session. | `lib/history/autosave.ts`, scheduler regressions. | +| Anthropic lifetime/errors | Actual SDK with a short timeout stays pending after headers; real SDK timeout/overload errors miss UI classification. | Anthropic provider/factory options and request error normalization, focused tests. | +| Local open error ownership | Actual entry/tool code reports success and applies readonly after injected conversion failure. | `lib/{document,web-mcp}.ts`, entry/recovery callers and tests. | +| Original Blob URLs | Actual entry source opens three files; all abandoned URLs remain readable after creating a blank document. | `lib/{document,embed-api,events}.ts`, URL lifetime tests. | +| Runtime cache reuse | Worker install requests an identical asset held by an owned previous core and fails when its redundant network request fails. | `public/sw.js`, worker/offline regressions. | + +Use existing modules and explicit lifecycle ownership. Keep identity independent +of the recovery timer, invalidate pending asynchronous starts, and reuse only +exact same-origin build-owned URLs from owned caches. Preserve complete runtime +preparation before activation. Remove redundant original-file URL allocation; +the actual editor document URL retains its existing owner. + +No schema migration, new dependency, vendored/runtime edits, immutable archive +changes, model-default changes, arbitrary cache discovery or new data destination. +Keep user cancellation reasons, save target consent, retry ordering, offline +availability and document mutation approval intact. Add an Anthropic-owned total +deadline covering SDK consumption; do not rely on the SDK header-only timeout. + +## Execution and verification + +1. Add failing behavioral regressions before implementation in each owned scope. +2. Implement history, Anthropic and offline work independently; parent owns local + open/URL callers, integration, documentation, Git and PR operations. +3. Review each scope against all consumers, then review the complete branch. +4. Run full unit coverage, repository formatting/lint/types, production build and + targeted real Chromium regressions. Preserve existing test deadlines. +5. Commit by topic, push normally, create a new English PR and attach it to the + task. Preserve multiple commits; no squash or force-push. Follow only the + newest PR head until all shards and required summaries succeed. Do not merge. + +Ruling: reuse this clean checkout on a dedicated `codex/` branch. Existing +unrelated worktrees are untouched. The user's explicit implementation request +approves the seven concrete audit outcomes; no additional approval stage is +needed for routine implementation choices within these boundaries. + +## Implementation evidence + +- Snapshot expiry excludes the document whose fresh bytes are being written. + Its new bytes, revision counters and authorized target survive while other + expired rows still retire atomically. The regression failed before the fix. +- Active document identity now outlives the recovery scheduler. Recovery-only + stops keep it; actual close/replacement clears it. Disk routing and saved + metadata use the active identity without trusting a URL. Real repository and + file-target tests cover disabled-before/after-open and storage-stop behavior. +- A generation invalidates every pending recovery start, including stops before + a session exists. Late lock grants release before timers/listeners or writes. + Both A/B completion orders, disable and explicit close have regressions. +- Anthropic owns a configurable five-minute deadline across SDK request/body + consumption. Controlled cancellation, callback failures and reentrant Stop + settle promptly and clean up timers/listeners/streams. Tests exercise actual + SDK stalled JSON/SSE responses and actual timeout/overload error classes. +- Local opens reject failures and obsolete completions. Picker/bootstrap callers + consume already-reported errors; tool/recovery callers receive the rejection. + Buffer read-only mode travels with the owned open rather than a later global + mutation. Cross-entry replacement and failed-source preservation remain tested. +- Standalone, embedded and desktop opens retain the File without allocating an + unused second original-file URL. The actual editor URL keeps its existing + allocation/revocation owner. A tracked URL registry exposed seven baseline + entry/outcome failures before the fix; all eight new entry cases now pass. +- Runtime preparation copies exact successful same-origin allowlisted responses + from earlier owned core caches before fetching misses. Sixteen regressions + cover headers/bytes, changed hashes, failures, unrelated caches, concurrency + and complete-before-activation. The original avoidable install failure now + succeeds without requesting the already-held runtime again. + +Four independent scoped reviews reported no actionable findings. The full unit +coverage run passes **6,349 tests in 243 files**, with 89.67% lines and 83.19% +branches; every repository coverage gate passes. + +Both new real-editor browser regressions failed against the exact baseline +production Docker image: the expired document's new revision was absent, and +two ordinary saves with recovery disabled opened two pickers. These are actual +IndexedDB/editor/file-system flows, with prior timestamps and a genuine OPFS +handle used as controlled fixtures. + +## Assembled verification + +- All **33 Chromium cases pass** against a fresh production Docker image in + 2.6 minutes, including both new regressions, source entry paths, queue/Stop, + service errors/streaming, offline capability upgrades, actual recovery, + authorized disk writes, save ordering, PDF export and reload target reuse. +- Repository formatting, archive integrity (1,511 immutable artifacts), oxlint, + refreshed workspace declarations and application TypeScript checks pass. +- Production Docker build passes; existing bundler size warnings remain. +- Independent complete-branch review approves all seven changes and their + consumers, with no unresolved actionable findings. + +```sh +pnpm run test:coverage --maxWorkers=2 +pnpm run lint:ts +pnpm run format:check +docker build -t document:lifecycle-followups . +docker run -d --rm --name document-followups-preview -p 127.0.0.1:4202:80 document:lifecycle-followups +E2E_BASE_URL=http://127.0.0.1:4202 E2E_PORT=4202 pnpm exec playwright test \ + test/e2e/history-quota.spec.ts test/e2e/save-to-file.spec.ts \ + test/e2e/entry-paths.spec.ts test/e2e/autosave-recovery.spec.ts \ + test/e2e/agent-error-recovery.spec.ts test/e2e/agent-request-queue.spec.ts \ + test/e2e/assistant-offline-assets.spec.ts test/e2e/agent-cloud-streaming.spec.ts \ + test/e2e/landing-prefetch.spec.ts --workers=1 --reporter=line +docker stop document-followups-preview +``` + +These checks use deterministic failure/storage/lock fixtures and actual +Chromium/IndexedDB/OPFS/worker/editor behavior. They do not certify live provider +accounts, physical disk exhaustion or every browser/runtime inference backend.