From 2922bca9b1c76f62e1bb2ae8e4841b7af2cf6021 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 30 Jul 2026 01:07:54 -0600 Subject: [PATCH] Fix autolog write recovery and pruning --- src/logging/AutoLogService.test.ts | 75 ++++++++++++++++++++++++++++-- src/logging/AutoLogService.ts | 23 +++++---- src/logging/AutoLogStore.test.ts | 19 ++++++++ src/logging/AutoLogStore.ts | 7 ++- 4 files changed, 108 insertions(+), 16 deletions(-) diff --git a/src/logging/AutoLogService.test.ts b/src/logging/AutoLogService.test.ts index 14b0b3cf..23221a60 100644 --- a/src/logging/AutoLogService.test.ts +++ b/src/logging/AutoLogService.test.ts @@ -7,8 +7,9 @@ import type { AutoLogEntry, AutoLogSession, AutoLogSessionDraft } from "./AutoLo class FakeAutoLogStore { sessions: AutoLogSession[] = []; entries: AutoLogEntry[] = []; - prunedTo: number[] = []; + prunedTo: Array<{ maxBytes: number; protectedSessionId?: string }> = []; ended: string[] = []; + failNextAppend = false; async createSession(draft: AutoLogSessionDraft): Promise { const session: AutoLogSession = { @@ -23,11 +24,16 @@ class FakeAutoLogStore { } async appendEntries(entries: AutoLogEntry[]): Promise { + if (this.failNextAppend) { + this.failNextAppend = false; + throw new Error("IndexedDB write failed"); + } + this.entries.push(...entries); } - async pruneToMaxBytes(maxBytes: number): Promise { - this.prunedTo.push(maxBytes); + async pruneToMaxBytes(maxBytes: number, protectedSessionId?: string): Promise { + this.prunedTo.push({ maxBytes, protectedSessionId }); } async endSession(sessionId: string): Promise { @@ -83,7 +89,10 @@ describe("AutoLogService", () => { sequence: 0, sourceContent: "hello", }); - expect(store.prunedTo).toContain(1000); + expect(store.prunedTo).toContainEqual({ + maxBytes: 1000, + protectedSessionId: "session-0", + }); service.dispose(); }); @@ -123,4 +132,62 @@ describe("AutoLogService", () => { expect(store.prunedTo).toEqual([]); service.dispose(); }); + + it("recovers the flush queue after an IndexedDB write fails", async () => { + const store = new FakeAutoLogStore(); + const service = new AutoLogService(store as unknown as AutoLogStore); + service.configureSession({ + title: "Test", + mode: "default", + sanitizedUrl: "https://example.test/", + }); + usePreferences.getState().setAutologging({ enabled: true, maxBytes: 1000 }); + + service.recordLine({ + type: "serverMessage", + sourceType: "ansi", + sourceContent: "failed", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + store.failNextAppend = true; + + await expect(service.flush()).rejects.toThrow("IndexedDB write failed"); + + service.recordLine({ + type: "serverMessage", + sourceType: "ansi", + sourceContent: "recovered", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await service.flush(); + + expect(store.entries.map((entry) => entry.sourceContent)).toEqual(["recovered"]); + service.dispose(); + }); + + it("releases the current session when its final flush fails", async () => { + const store = new FakeAutoLogStore(); + const service = new AutoLogService(store as unknown as AutoLogStore); + service.configureSession({ + title: "Test", + mode: "default", + sanitizedUrl: "https://example.test/", + }); + usePreferences.getState().setAutologging({ enabled: true, maxBytes: 1000 }); + await service.startSession(); + + service.recordLine({ + type: "serverMessage", + sourceType: "ansi", + sourceContent: "failed", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + store.failNextAppend = true; + + await expect(service.endSession()).rejects.toThrow("IndexedDB write failed"); + await service.startSession(); + + expect(store.sessions).toHaveLength(2); + service.dispose(); + }); }); diff --git a/src/logging/AutoLogService.ts b/src/logging/AutoLogService.ts index 648a25af..ea56188b 100644 --- a/src/logging/AutoLogService.ts +++ b/src/logging/AutoLogService.ts @@ -76,7 +76,7 @@ export class AutoLogService { console.error("Failed to end autolog session after disabling autologging:", error); }); } else { - this.store.pruneToMaxBytes(preferences.maxBytes).catch((error) => { + this.store.pruneToMaxBytes(preferences.maxBytes, this.currentSession?.id).catch((error) => { console.error("Failed to prune autolog sessions:", error); }); } @@ -142,23 +142,26 @@ export class AutoLogService { this.pendingEntries = []; const maxBytes = usePreferences.getState().autologging.maxBytes; - this.flushPromise = this.flushPromise + const flushPromise = this.flushPromise .then(() => this.store.appendEntries(entries)) - .then(() => this.store.pruneToMaxBytes(maxBytes)); + .then(() => this.store.pruneToMaxBytes(maxBytes, this.currentSession?.id)); - return this.flushPromise; + this.flushPromise = flushPromise.catch(() => {}); + return flushPromise; } async endSession(): Promise { const session = this.currentSession; - await this.flush(); + try { + await this.flush(); - if (session) { - await this.store.endSession(session.id); + if (session) { + await this.store.endSession(session.id); + } + } finally { + this.currentSession = null; + this.sequence = 0; } - - this.currentSession = null; - this.sequence = 0; } dispose(): void { diff --git a/src/logging/AutoLogStore.test.ts b/src/logging/AutoLogStore.test.ts index a71c5df8..2c2ea6d0 100644 --- a/src/logging/AutoLogStore.test.ts +++ b/src/logging/AutoLogStore.test.ts @@ -82,4 +82,23 @@ describe("AutoLogStore", () => { store.close(); }); + + it("preserves the active session while pruning and keeps later entries reachable", async () => { + const store = new AutoLogStore(); + const activeSession = await store.createSession({ ...draft, title: "Active" }, 100); + const completedSession = await store.createSession({ ...draft, title: "Completed" }, 200); + await store.appendEntries([makeEntry(activeSession.id, 0, "active ".repeat(100))]); + await store.appendEntries([makeEntry(completedSession.id, 0, "completed")]); + + await store.pruneToMaxBytes(1, activeSession.id); + await store.appendEntries([makeEntry(activeSession.id, 1, "still active")]); + + const sessions = await store.listSessions(); + expect(sessions.map((session) => session.title)).toEqual(["Active"]); + expect(sessions[0].lineCount).toBe(2); + expect(await store.getEntries(activeSession.id)).toHaveLength(2); + expect(await store.getEntries(completedSession.id)).toEqual([]); + + store.close(); + }); }); diff --git a/src/logging/AutoLogStore.ts b/src/logging/AutoLogStore.ts index 2d3f538f..3929e0ad 100644 --- a/src/logging/AutoLogStore.ts +++ b/src/logging/AutoLogStore.ts @@ -209,8 +209,8 @@ export class AutoLogStore { return sessions.reduce((total, session) => total + session.byteEstimate, 0); } - async pruneToMaxBytes(maxBytes: number): Promise { - if (maxBytes <= 0) { + async pruneToMaxBytes(maxBytes: number, protectedSessionId?: string): Promise { + if (maxBytes <= 0 && !protectedSessionId) { await this.deleteAll(); return; } @@ -223,6 +223,9 @@ export class AutoLogStore { if (total <= maxBytes) { return; } + if (session.id === protectedSessionId) { + continue; + } await this.deleteSession(session.id); total -= session.byteEstimate;