From 2e9190bb7d011a5dc8d2ccece30e11d9e52b597a Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:13:41 +0900 Subject: [PATCH 1/9] feat: auto-save and full session restore on macOS system shutdown (#73) macOS shutdown/restart/logout no longer gets cancelled by the save dialog: - powerMonitor 'shutdown' latches an explicit shutdown intent; before-quit joins the same quit-approval controller (user-initiated quit unchanged) - shutdown branch saves dirty path files without any dialog, keeps untitled content in the session snapshot (empty windows excluded), and commits cleanExit:false plus a one-shot restoreReason:'shutdown' marker atomically - next launch auto-applies the shutdown snapshot without the recovery banner; crash restore keeps the existing banner flow (no regression) - failed file saves fall back to the session snapshot (no data loss); a shutdown commit after an approved quit supersedes the quit fence instead of silently no-oping; a denied shutdown releases the latch so later Cmd+Q behaves exactly as before - close-dialog smoke: legacy 7 scenarios isolated per-scenario userData in the same order, plus shutdown-restore and file-failure-restore pairs driven through the real beginSystemShutdown() path --- scripts/close-dialog-smoke-runner.mjs | 223 +++++++++++++++--- src/__tests__/app-windows-discard.test.ts | 131 ++++++++++ src/__tests__/close-coordinator.test.ts | 34 +++ src/__tests__/main-lifecycle-contract.test.ts | 135 +++++++---- src/__tests__/session-ipc-fence.test.ts | 26 ++ src/__tests__/session-schema.test.ts | 21 ++ src/__tests__/session-store-queue.test.ts | 136 ++++++++++- src/main/app-windows.ts | 136 ++++++++++- src/main/close-coordinator.ts | 5 +- src/main/ipc/file-ipc.ts | 6 + src/main/ipc/session-ipc.ts | 22 +- src/main/lifecycle-flags.ts | 57 +++++ src/main/main.ts | 105 +++++++-- src/main/preload.ts | 23 ++ src/main/session-queue.ts | 3 +- src/main/session-schema.ts | 30 +++ src/main/session-store.ts | 41 +++- src/main/window-registry.ts | 2 + src/renderer/api-types.ts | 31 +++ src/renderer/doc-lifecycle.test.ts | 13 + src/renderer/doc-lifecycle.ts | 5 + src/renderer/main.ts | 45 ++++ src/renderer/session-snapshot.test.ts | 116 +++++++++ src/renderer/session-snapshot.ts | 39 +-- 24 files changed, 1232 insertions(+), 153 deletions(-) diff --git a/scripts/close-dialog-smoke-runner.mjs b/scripts/close-dialog-smoke-runner.mjs index 4d002dc..0f3f3cf 100644 --- a/scripts/close-dialog-smoke-runner.mjs +++ b/scripts/close-dialog-smoke-runner.mjs @@ -1,6 +1,6 @@ import electron from 'electron'; import { createRequire } from 'node:module'; -import { mkdtempSync, writeFileSync, existsSync } from 'node:fs'; +import { mkdtempSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -10,22 +10,62 @@ const require = createRequire(import.meta.url); const electronBinary = require('electron'); const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const scenario = process.env.NOTEPAD_AI_CLOSE_SMOKE_SCENARIO; +const shutdownPhase = process.env.NOTEPAD_AI_CLOSE_SMOKE_SHUTDOWN_PHASE; const documentPath = process.env.NOTEPAD_AI_CLOSE_SMOKE_DOCUMENT; const secondDocumentPath = process.env.NOTEPAD_AI_CLOSE_SMOKE_SECOND_DOCUMENT; +const shutdownPathContent = '# Shutdown path document\n\nLatest path revision.\n'; +const shutdownUntitledContent = '# Shutdown untitled document\n\nLatest untitled revision.\n'; + const delay = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); async function waitFor(name, predicate, timeoutMs = 15_000) { const until = Date.now() + timeoutMs; + let lastError; while (Date.now() < until) { - const value = await predicate(); - if (value) return value; + try { + const value = await predicate(); + if (value) return value; + } catch (error) { + lastError = error; + } await delay(50); } - throw new Error(`${name} timed out`); + throw new Error(`${name} timed out${lastError ? `: ${lastError}` : ''}`); +} + +function editorText(win) { + return win.webContents.executeJavaScript(`Array.from(document.querySelectorAll('.cm-line')).map((line) => line.textContent || '').join('\\n')`); +} + +async function replaceEditorText(win, content) { + await waitFor('editor', () => win.webContents.executeJavaScript(`Boolean(document.querySelector('.cm-content'))`)); + return waitFor('CodeMirror edit', async () => { + win.focus(); + win.webContents.focus(); + win.webContents.selectAll(); + await delay(30); + win.webContents.insertText(content); + await delay(30); + return (await editorText(win)) === content; + }, 60_000); +} + +function createFixture(userData) { + const doc = join(userData, 'close-smoke.md'); + const secondDoc = join(userData, 'close-smoke-second.md'); + const largeDoc = join(userData, 'close-smoke-large.md'); + writeFileSync(doc, '# Close smoke\n', 'utf8'); + writeFileSync(secondDoc, '# Close smoke second\n', 'utf8'); + // Keep the large body intact — just dirty it so the close path renders/ + // preview-syncs the full ~90KB document, which is what used to loop. + const largeBody = '# Close smoke large\n\n' + Array.from({ length: 1400 }, (_v, i) => + `- [ ] item ${i} — the quick brown fox jumps over the lazy dog, 다람쥐 헌 쳇바퀴에 타고파.`).join('\n') + '\n'; + writeFileSync(largeDoc, largeBody, 'utf8'); + return { doc, secondDoc, largeDoc }; } async function worker() { - const { app, BrowserWindow } = electron; + const { app, BrowserWindow, Menu } = electron; require(resolve(REPO, 'dist/main/main.js')); const base = scenario.replace(/-large$/, ''); app.emit('open-file', { preventDefault() {} }, documentPath); @@ -40,8 +80,6 @@ async function worker() { ))); for (const dirtyWindow of dirtyWindows) { if (scenario.endsWith('-large')) { - // Keep the large body intact — just dirty it so the close path renders/ - // preview-syncs the full ~90KB document, which is what used to loop. dirtyWindow.webContents.insertText(`\nclose smoke ${scenario} ${dirtyWindow.id}\n`); } else { dirtyWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'A', modifiers: ['meta'] }); @@ -88,48 +126,161 @@ async function worker() { app.exit(0); } +function clickNewWindow(Menu) { + const fileMenu = Menu.getApplicationMenu()?.items.find((item) => item.label === 'File'); + const newItem = fileMenu?.submenu?.items.find((item) => item.label === 'New'); + if (!newItem?.click) throw new Error('File > New menu item is unavailable'); + newItem.click(); +} + +async function shutdownWorker() { + const { app, BrowserWindow, Menu } = electron; + require(resolve(REPO, 'dist/main/main.js')); + await app.whenReady(); + + if (shutdownPhase === 'first') { + app.emit('open-file', { preventDefault() {} }, documentPath); + const pathWindow = await waitFor('path window', () => BrowserWindow.getAllWindows()[0] ?? null); + await replaceEditorText(pathWindow, shutdownPathContent); + + clickNewWindow(Menu); + const untitledWindow = await waitFor('untitled window', () => { + const windows = BrowserWindow.getAllWindows().filter((candidate) => !candidate.isDestroyed()); + return windows.length === 2 ? windows.find((candidate) => candidate.id !== pathWindow.id) ?? null : null; + }); + await replaceEditorText(untitledWindow, shutdownUntitledContent); + + clickNewWindow(Menu); + await waitFor('empty window', () => BrowserWindow.getAllWindows().filter((candidate) => !candidate.isDestroyed()).length === 3); + await waitFor('shutdown trigger API', () => pathWindow.webContents.executeJavaScript( + `typeof window.api.closeSmokeBeginShutdown === 'function'`, + )); + await pathWindow.webContents.executeJavaScript(`window.api.closeSmokeBeginShutdown()`); + await new Promise((resolveClosed) => app.once('window-all-closed', resolveClosed)); + console.log('[close-dialog-smoke] shutdown-first-closed'); + return; + } + + if (shutdownPhase === 'restore') { + const restored = await waitFor('two restored windows', () => { + const windows = BrowserWindow.getAllWindows().filter((candidate) => !candidate.isDestroyed()); + return windows.length === 2 ? windows : null; + }, 30_000); + await Promise.all(restored.map((win) => waitFor('restored editor', () => + win.webContents.executeJavaScript(`Boolean(document.querySelector('.cm-content'))`), + ))); + const states = await Promise.all(restored.map(async (win) => ({ + win, + text: await editorText(win), + session: await win.webContents.executeJavaScript(`window.api.sessionGet()`), + hasBanner: await win.webContents.executeJavaScript(`Boolean(document.querySelector('.restore-yes'))`), + }))); + if (states.some((state) => state.hasBanner)) throw new Error('shutdown restore showed a crash recovery banner'); + const pathState = states.find((state) => state.text === shutdownPathContent); + const untitledState = states.find((state) => state.text === shutdownUntitledContent); + if (!pathState?.session?.snapshot?.path) throw new Error('path document was not immediately restored with its path'); + if (pathState.session.snapshot.dirty !== (process.env.NOTEPAD_AI_CLOSE_SMOKE_EXPECT_PATH_DIRTY === '1')) { + throw new Error('path document dirty state was not immediately restored'); + } + if (untitledState?.session?.snapshot?.path !== null || untitledState.session.snapshot.dirty !== true) { + throw new Error('untitled dirty document was not immediately restored'); + } + console.log('[close-dialog-smoke] shutdown-restore-observable-state'); + app.exit(0); + return; + } + + throw new Error(`unknown shutdown worker phase: ${shutdownPhase}`); +} + +function spawnWorker(env) { + return spawn(electronBinary, [fileURLToPath(import.meta.url)], { + cwd: REPO, + env: { + ...process.env, + ...env, + NOTEPAD_AI_INTEGRATION_TEST: '1', + NOTEPAD_AI_HIDE_WINDOWS: '1', + ELECTRON_ENABLE_LOGGING: '1', + }, + stdio: 'inherit', + }); +} + +async function waitForWorker(name, env) { + const child = spawnWorker(env); + const code = await new Promise((resolveExit) => child.once('exit', resolveExit)); + if (code !== 0) throw new Error(`${name} worker exited ${code}`); +} + +async function runShutdownPair(name, failFileSave) { + const userData = mkdtempSync(join(tmpdir(), `notepad-ai-close-smoke-${name}-`)); + const { doc } = createFixture(userData); + await waitForWorker(`${name} first`, { + NOTEPAD_AI_CLOSE_SMOKE_SHUTDOWN_PHASE: 'first', + NOTEPAD_AI_CLOSE_SMOKE_DOCUMENT: doc, + NOTEPAD_AI_CLOSE_SMOKE_TRIGGER: 'shutdown', + NOTEPAD_AI_CLOSE_DIALOG_CHOICE: 'fail', + NOTEPAD_AI_SMOKE_FAIL_FILE_SAVE: failFileSave ? '1' : undefined, + NOTEPAD_AI_USERDATA: userData, + }); + + if (!failFileSave && !readFileSync(doc).equals(Buffer.from(shutdownPathContent, 'utf8'))) { + throw new Error(`${name} path document did not save byte-exactly`); + } + const aggregate = JSON.parse(readFileSync(join(userData, 'session.json'), 'utf8')); + const contentWindows = aggregate.windows?.filter((entry) => (entry.doc?.length ?? 0) > 0) ?? []; + if (aggregate.cleanExit !== false || aggregate.restoreReason !== 'shutdown') { + throw new Error(`${name} session did not contain the shutdown restore marker`); + } + if (contentWindows.length !== 2 || aggregate.windows.length !== 2) { + throw new Error(`${name} session did not exclude the empty window`); + } + const pathSnapshot = contentWindows.find((entry) => entry.path !== null); + if (pathSnapshot?.doc !== shutdownPathContent) throw new Error(`${name} session lacks the latest path document`); + if (failFileSave && pathSnapshot.dirty !== true) throw new Error(`${name} fault session lacks a dirty path document`); + const untitledSnapshot = contentWindows.find((entry) => entry.path === null); + if (untitledSnapshot?.doc !== shutdownUntitledContent || untitledSnapshot.dirty !== true) { + throw new Error(`${name} session lacks the latest dirty untitled document`); + } + + await waitForWorker(`${name} restore`, { + NOTEPAD_AI_CLOSE_SMOKE_SHUTDOWN_PHASE: 'restore', + NOTEPAD_AI_CLOSE_SMOKE_DOCUMENT: doc, + NOTEPAD_AI_CLOSE_SMOKE_EXPECT_PATH_DIRTY: failFileSave ? '1' : '0', + NOTEPAD_AI_USERDATA: userData, + }); + console.log(`[close-dialog-smoke] ${name}=PASS`); +} + if (scenario) { void worker().catch((error) => { console.error(`[close-dialog-smoke] worker failure: ${error?.stack ?? error}`); process.exitCode = 2; }); +} else if (shutdownPhase) { + void shutdownWorker().catch((error) => { + console.error(`[close-dialog-smoke] shutdown worker failure: ${error?.stack ?? error}`); + process.exitCode = 2; + }); } else { void (async () => { if (!existsSync(resolve(REPO, 'dist/main/main.js'))) throw new Error('dist/main/main.js missing; build the app before smoke execution'); - const userData = mkdtempSync(join(tmpdir(), 'notepad-ai-close-smoke-')); - const doc = join(userData, 'close-smoke.md'); - const secondDoc = join(userData, 'close-smoke-second.md'); - const largeDoc = join(userData, 'close-smoke-large.md'); - writeFileSync(doc, '# Close smoke\n', 'utf8'); - writeFileSync(secondDoc, '# Close smoke second\n', 'utf8'); - // Regression guard for the large-document close loop: seed a ~90KB body so - // the close path exercises the big-doc render/preview-sync route that used - // to blow past the forward deadline and re-prompt the dialog forever. - const largeBody = '# Close smoke large\n\n' + Array.from({ length: 1400 }, (_v, i) => - `- [ ] item ${i} — the quick brown fox jumps over the lazy dog, 다람쥐 헌 쳇바퀴에 타고파.`).join('\n') + '\n'; - writeFileSync(largeDoc, largeBody, 'utf8'); for (const choice of ['discard', 'save', 'cancel', 'quit-cancel', 'quit-discard', 'discard-large', 'save-large']) { + const userData = mkdtempSync(join(tmpdir(), 'notepad-ai-close-smoke-')); + const { doc, secondDoc, largeDoc } = createFixture(userData); const base = choice.replace(/-large$/, ''); - const scenarioDoc = choice.endsWith('-large') ? largeDoc : doc; - const child = spawn(electronBinary, [fileURLToPath(import.meta.url)], { - cwd: REPO, - env: { - ...process.env, - NOTEPAD_AI_CLOSE_SMOKE_SCENARIO: choice, - NOTEPAD_AI_CLOSE_SMOKE_DOCUMENT: scenarioDoc, - NOTEPAD_AI_CLOSE_SMOKE_SECOND_DOCUMENT: secondDoc, - NOTEPAD_AI_CLOSE_DIALOG_CHOICE: base === 'quit-discard' ? 'discard' : base === 'quit-cancel' ? 'cancel' : base, - NOTEPAD_AI_USERDATA: userData, - NOTEPAD_AI_INTEGRATION_TEST: '1', - NOTEPAD_AI_HIDE_WINDOWS: '1', - ELECTRON_ENABLE_LOGGING: '1', - }, - stdio: 'inherit', + await waitForWorker(choice, { + NOTEPAD_AI_CLOSE_SMOKE_SCENARIO: choice, + NOTEPAD_AI_CLOSE_SMOKE_DOCUMENT: choice.endsWith('-large') ? largeDoc : doc, + NOTEPAD_AI_CLOSE_SMOKE_SECOND_DOCUMENT: secondDoc, + NOTEPAD_AI_CLOSE_DIALOG_CHOICE: base === 'quit-discard' ? 'discard' : base === 'quit-cancel' ? 'cancel' : base, + NOTEPAD_AI_USERDATA: userData, }); - const code = await new Promise((resolveExit) => child.once('exit', resolveExit)); - if (code !== 0) throw new Error(`${choice} worker exited ${code}`); console.log(`[close-dialog-smoke] ${choice}=PASS`); } + await runShutdownPair('shutdown-restore', false); + await runShutdownPair('file-failure-restore', true); })().catch((error) => { console.error(`[close-dialog-smoke] failure: ${error?.stack ?? error}`); process.exitCode = 2; diff --git a/src/__tests__/app-windows-discard.test.ts b/src/__tests__/app-windows-discard.test.ts index 252a7aa..436fb9f 100644 --- a/src/__tests__/app-windows-discard.test.ts +++ b/src/__tests__/app-windows-discard.test.ts @@ -139,6 +139,7 @@ async function setup( productionWindows = false, fileGrants: unknown = { release: () => {} }, projectWizardRoots: unknown = { release: () => {} }, + commitShutdownSession: (snapshots: readonly unknown[]) => Promise = async () => {}, ) { electron.reset(); @@ -174,6 +175,7 @@ async function setup( removeSessionWindows, commitQuitSession, showCloseDialog: async () => showCloseDialog(), + commitShutdownSession: commitShutdownSession as never, }); electron.setOnSend((win, channel, payload) => { if (channel === 'close:query-state') { @@ -357,6 +359,25 @@ describe('discard close IPC waiters', () => { expect(removeSessionWindow).toHaveBeenCalledWith(lateWindowKey); expect(coordinatedKeys).not.toContain(removeSessionWindow.mock.calls[0][0]); }); + it('uses removed-key semantics for relaunch without committing quit or shutdown state', async () => { + const commitQuitSession = vi.fn(async () => {}); + const commitShutdownSession = vi.fn(async () => {}); + const removeSessionWindows = vi.fn(async () => {}); + const { appWindows } = await setup((win, channel, payload) => { + if (channel === 'close:discard') { + electron.emitIpc('close:discard-result', win, { requestId: payload.requestId, fenced: true }); + } + if (channel === 'close:consume') { + electron.emitIpc('close:consume-result', win, { requestId: payload.requestId, consumed: true }); + } + }, async () => 'discard', 1, () => true, commitQuitSession, async () => {}, removeSessionWindows, undefined, false, undefined, undefined, commitShutdownSession); + + await expect(appWindows.approveAllForQuit('relaunch')).resolves.toBe(true); + + expect(removeSessionWindows).toHaveBeenCalledWith(['window-1']); + expect(commitQuitSession).not.toHaveBeenCalled(); + expect(commitShutdownSession).not.toHaveBeenCalled(); + }); it('cancels a peer prepare and rolls back both targets when one prepare fails', async () => { const rollbackTargets: number[] = []; @@ -624,4 +645,114 @@ describe('discard close IPC waiters', () => { expect(rollbackResults).toEqual([false]); expect(appWindows.isSessionWriteFenced('window-1')).toBe(false); }); + describe('shutdown persistence', () => { + it('persists snapshots without opening a close dialog and accepts file-save fallback', async () => { + const showCloseDialog = vi.fn(async () => 'cancel' as const); + const commitShutdownSession = vi.fn(async () => {}); + const sent: string[] = []; + const { appWindows } = await setup((win, channel, payload) => { + sent.push(channel); + if (channel === 'close:shutdown-persist:request') { + electron.emitIpc('close:shutdown-persist:result', win, { + id: payload.id, + ok: true, + fileSaved: false, + revision: payload.revision, + snapshot: { doc: 'latest', dirty: true, path: '/untrusted.md' }, + }); + } + if (channel === 'close:consume') { + electron.emitIpc('close:consume-result', win, { requestId: payload.requestId, consumed: true }); + } + }, showCloseDialog, 1, () => true, async () => {}, async () => {}, async () => {}, undefined, false, undefined, undefined, commitShutdownSession); + + await expect(appWindows.approveAllForQuit('shutdown')).resolves.toBe(true); + + expect(showCloseDialog).not.toHaveBeenCalled(); + expect(sent).not.toContain('close:save'); + expect(commitShutdownSession).toHaveBeenCalledWith([expect.objectContaining({ + id: 'window-1', + path: '/tmp/draft.md', + doc: 'latest', + dirty: true, + })]); + }); + it('excludes an unready empty window from shutdown persistence', async () => { + const commitShutdownSession = vi.fn(async () => {}); + const { appWindows, records } = await setup((win, channel, payload) => { + if (channel === 'close:shutdown-persist:request') { + electron.emitIpc('close:shutdown-persist:result', win, { + id: payload.id, + ok: true, + fileSaved: false, + revision: payload.revision, + snapshot: { doc: `latest-${win.id}`, dirty: true }, + }); + } + if (channel === 'close:consume') { + electron.emitIpc('close:consume-result', win, { requestId: payload.requestId, consumed: true }); + } + }, async () => { + throw new Error('shutdown must not open a dialog'); + }, 3, () => true, async () => {}, async () => {}, async () => {}, undefined, false, undefined, undefined, commitShutdownSession); + records[2].ready = false; + records[2].currentPath = null; + + await expect(appWindows.approveAllForQuit('shutdown')).resolves.toBe(true); + + expect(commitShutdownSession).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'window-1', doc: 'latest-1' }), + expect.objectContaining({ id: 'window-2', doc: 'latest-2' }), + ]); + }); + + it('denies stale shutdown snapshots without consuming a lease', async () => { + const commitShutdownSession = vi.fn(async () => {}); + const consumed = vi.fn(); + const { appWindows } = await setup((win, channel, payload) => { + if (channel === 'close:shutdown-persist:request') { + electron.emitIpc('close:shutdown-persist:result', win, { + id: payload.id, + ok: true, + fileSaved: true, + revision: payload.revision + 1, + snapshot: { doc: 'stale' }, + }); + } + if (channel === 'close:consume') consumed(); + }, async () => { + throw new Error('shutdown must not open a dialog'); + }, 1, () => true, async () => {}, async () => {}, async () => {}, undefined, false, undefined, undefined, commitShutdownSession); + + await expect(appWindows.approveAllForQuit('shutdown')).resolves.toBe(false); + + expect(consumed).not.toHaveBeenCalled(); + expect(commitShutdownSession).not.toHaveBeenCalled(); + }); + + it('denies and clears the session fence when shutdown session commit fails', async () => { + const commitShutdownSession = vi.fn(async () => { throw new Error('session write failed'); }); + const { appWindows } = await setup((win, channel, payload) => { + if (channel === 'close:shutdown-persist:request') { + electron.emitIpc('close:shutdown-persist:result', win, { + id: payload.id, + ok: true, + fileSaved: true, + revision: payload.revision, + snapshot: { doc: 'latest' }, + }); + } + if (channel === 'close:consume') { + electron.emitIpc('close:consume-result', win, { requestId: payload.requestId, consumed: true }); + } + }, async () => { + throw new Error('shutdown must not open a dialog'); + }, 1, () => true, async () => {}, async () => {}, async () => {}, undefined, false, undefined, undefined, commitShutdownSession); + + await expect(appWindows.approveAllForQuit('shutdown')).resolves.toBe(false); + + expect(commitShutdownSession).toHaveBeenCalledOnce(); + expect(appWindows.isSessionWriteFenced('window-1')).toBe(false); + }); + }); }); diff --git a/src/__tests__/close-coordinator.test.ts b/src/__tests__/close-coordinator.test.ts index 20eb9fb..1195a55 100644 --- a/src/__tests__/close-coordinator.test.ts +++ b/src/__tests__/close-coordinator.test.ts @@ -152,4 +152,38 @@ describe('CloseCoordinator', () => { expect(Date.now() - started).toBeLessThan(100); }); + it('waits for the current in-flight transaction and resolves immediately while idle', async () => { + const coordinator = new CloseCoordinator(); + let release!: () => void; + const blocked = new Promise((resolve) => { release = resolve; }); + const request = coordinator.request('close', [first], async () => { + await blocked; + return 'allow'; + }, async () => true); + + let idle = false; + const wait = coordinator.waitForIdle().then(() => { idle = true; }); + await Promise.resolve(); + expect(idle).toBe(false); + release(); + await request; + await wait; + expect(idle).toBe(true); + await expect(coordinator.waitForIdle()).resolves.toBeUndefined(); + }); + + it('rejects a conflicting shutdown transaction without changing existing conflict policy', async () => { + const coordinator = new CloseCoordinator(); + let release!: () => void; + const blocked = new Promise((resolve) => { release = resolve; }); + const close = coordinator.request('close', [first], async () => { + await blocked; + return 'allow'; + }, async () => true); + + await expect(coordinator.request('shutdown', [first], async () => 'allow', async () => true)) + .resolves.toEqual({ approved: false, intent: 'shutdown' }); + release(); + await close; + }); }); diff --git a/src/__tests__/main-lifecycle-contract.test.ts b/src/__tests__/main-lifecycle-contract.test.ts index b51ed13..beb9343 100644 --- a/src/__tests__/main-lifecycle-contract.test.ts +++ b/src/__tests__/main-lifecycle-contract.test.ts @@ -1,64 +1,109 @@ import { describe, expect, it, vi } from 'vitest'; +import { createQuitApprovalController } from '../main/lifecycle-flags'; -import { queueOrOpenFile, shouldPublishLaunchWindow, shouldUseMockKeychain } from '../main/lifecycle-flags'; +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((next) => { resolve = next; }); + return { promise, resolve }; +} -describe('main integration keychain gate', () => { - it('requires isolated userData and the exact integration-test marker', () => { - expect(shouldUseMockKeychain({ NOTEPAD_AI_USERDATA: '/tmp/notepad-ai-test' })).toBe(false); - expect(shouldUseMockKeychain({ NOTEPAD_AI_INTEGRATION_TEST: '1' })).toBe(false); - expect(shouldUseMockKeychain({ NOTEPAD_AI_USERDATA: '/tmp/notepad-ai-test', NOTEPAD_AI_INTEGRATION_TEST: 'true' })).toBe(false); - expect(shouldUseMockKeychain({ NOTEPAD_AI_USERDATA: '/tmp/notepad-ai-test', NOTEPAD_AI_INTEGRATION_TEST: '1' })).toBe(true); - }); -}); +describe('quit approval lifecycle controller', () => { + it('waits for an unresolved close transaction then approves shutdown exactly once', async () => { + const close = deferred(); + const approveAllForQuit = vi.fn(async () => true); + const controller = createQuitApprovalController({ + waitForCloseTransaction: () => close.promise, + approveAllForQuit, + clearCloseApprovals: vi.fn(), + }); -describe('launch-window publication', () => { - it('does not promote a Cmd+N window into the open-file reuse target', () => { - expect(shouldPublishLaunchWindow({})).toBe(false); + const pending = controller.requestQuitApproval(); + const shutdown = controller.beginSystemShutdown(); + await Promise.resolve(); + expect(approveAllForQuit).not.toHaveBeenCalled(); + + close.resolve(); + await expect(Promise.all([pending, shutdown])).resolves.toEqual([true, true]); + expect(approveAllForQuit).toHaveBeenCalledTimes(1); + expect(approveAllForQuit).toHaveBeenCalledWith('shutdown'); }); + it('joins duplicate system shutdown requests into one pending approval', async () => { + const approval = deferred(); + const approveAllForQuit = vi.fn(() => approval.promise); + const controller = createQuitApprovalController({ + waitForCloseTransaction: async () => {}, + approveAllForQuit, + clearCloseApprovals: vi.fn(), + }); + + const first = controller.beginSystemShutdown(); + const second = controller.beginSystemShutdown(); + await vi.waitFor(() => expect(approveAllForQuit).toHaveBeenCalledWith('shutdown')); + approval.resolve(true); - it('only publishes a blank lifecycle launch window', () => { - expect(shouldPublishLaunchWindow({ isLaunchWindow: true })).toBe(true); - expect(shouldPublishLaunchWindow({ isLaunchWindow: true, openFilePath: '/tmp/opened.md' })).toBe(false); - expect(shouldPublishLaunchWindow({ isLaunchWindow: true, restore: {} })).toBe(false); + await expect(Promise.all([first, second])).resolves.toEqual([true, true]); + expect(approveAllForQuit).toHaveBeenCalledOnce(); }); -}); -describe('incoming file lifecycle', () => { - it('queues a pre-ready file once for startup and opens ready files immediately', () => { - const pending: string[] = []; - const openFile = vi.fn(); - queueOrOpenFile(false, '/tmp/pre-ready.md', pending, openFile); + it('reruns after a shutdown latch makes a non-shutdown approval stale', async () => { + const approval = deferred(); + const approveAllForQuit = vi.fn(() => approval.promise); + const controller = createQuitApprovalController({ + waitForCloseTransaction: async () => {}, + approveAllForQuit, + clearCloseApprovals: vi.fn(), + }); - expect(pending).toEqual(['/tmp/pre-ready.md']); - expect(openFile).not.toHaveBeenCalled(); + const quit = controller.requestQuitApproval(); + await vi.waitFor(() => expect(approveAllForQuit).toHaveBeenCalledWith('quit')); + const shutdown = controller.beginSystemShutdown(); + approval.resolve(true); - for (const filePath of pending.splice(0)) openFile(filePath); - expect(openFile).toHaveBeenCalledTimes(1); - expect(openFile).toHaveBeenCalledWith('/tmp/pre-ready.md'); + await vi.waitFor(() => expect(approveAllForQuit).toHaveBeenCalledWith('shutdown')); + await expect(Promise.all([quit, shutdown])).resolves.toEqual([true, true]); + expect(approveAllForQuit).toHaveBeenCalledTimes(2); + }); - queueOrOpenFile(true, '/tmp/ready.md', pending, openFile); + it('clears approvals and denies when an approval fails', async () => { + const clearCloseApprovals = vi.fn(); + const controller = createQuitApprovalController({ + waitForCloseTransaction: async () => {}, + approveAllForQuit: async () => { throw new Error('timed out'); }, + clearCloseApprovals, + }); - expect(pending).toEqual([]); - expect(openFile).toHaveBeenCalledTimes(2); - expect(openFile).toHaveBeenLastCalledWith('/tmp/ready.md'); + await expect(controller.requestQuitApproval()).resolves.toBe(false); + expect(clearCloseApprovals).toHaveBeenCalledOnce(); }); + it('clears the shutdown latch after a denied shutdown approval', async () => { + const approveAllForQuit = vi.fn(async (reason: string) => reason !== 'shutdown'); + const controller = createQuitApprovalController({ + waitForCloseTransaction: async () => {}, + approveAllForQuit, + clearCloseApprovals: vi.fn(), + }); - it('deduplicates the same path delivered through both pre-ready sources (open-file + second-instance)', () => { - const pending: string[] = []; - const openFile = vi.fn(); + await expect(controller.beginSystemShutdown()).resolves.toBe(false); + await expect(controller.requestQuitApproval()).resolves.toBe(true); - // Finder open-file and a second-instance argv can race the same document - // before readiness; the shared queue must hold it exactly once so the - // concurrent flush cannot create two windows. - queueOrOpenFile(false, '/tmp/same-doc.md', pending, openFile); - queueOrOpenFile(false, '/tmp/same-doc.md', pending, openFile); - queueOrOpenFile(false, '/tmp/other-doc.md', pending, openFile); + expect(approveAllForQuit.mock.calls.map(([reason]) => reason)).toEqual(['shutdown', 'quit']); + }); + it('clears the shutdown latch when a latched shutdown approval throws', async () => { + let calls = 0; + const approveAllForQuit = vi.fn(async (reason: string) => { + calls += 1; + if (calls === 1) throw new Error('timed out'); + return reason === 'quit'; + }); + const controller = createQuitApprovalController({ + waitForCloseTransaction: async () => {}, + approveAllForQuit, + clearCloseApprovals: vi.fn(), + }); - expect(pending).toEqual(['/tmp/same-doc.md', '/tmp/other-doc.md']); - expect(openFile).not.toHaveBeenCalled(); + await expect(controller.beginSystemShutdown()).resolves.toBe(false); + await expect(controller.requestQuitApproval()).resolves.toBe(true); - for (const filePath of pending.splice(0)) openFile(filePath); - expect(openFile).toHaveBeenCalledTimes(2); - expect(pending).toEqual([]); + expect(approveAllForQuit.mock.calls.map(([reason]) => reason)).toEqual(['shutdown', 'quit']); }); }); diff --git a/src/__tests__/session-ipc-fence.test.ts b/src/__tests__/session-ipc-fence.test.ts index 5c8ae9b..85e80e7 100644 --- a/src/__tests__/session-ipc-fence.test.ts +++ b/src/__tests__/session-ipc-fence.test.ts @@ -29,6 +29,32 @@ describe('session IPC discard fence', () => { electron.reset(); sessionStore.mutateSessionAggregate.mockReset(); }); + it('returns the registry-owned transient shutdown restore reason with the snapshot', async () => { + const record: WindowRecord = { + windowId: 1, + webContentsId: 1001, + windowKey: 'shutdown-restore-window', + lastFocusedAt: 0, + ready: true, + pendingOutbound: [], + restoreSnapshot: { id: 'shutdown-restore-window', path: null, title: null, doc: 'restored draft' }, + restoreReason: 'shutdown', + }; + const { registerSessionIpc } = await import('../main/ipc/session-ipc'); + registerSessionIpc({ + registry: { getByWebContents: (id: number) => id === record.webContentsId ? record : null } as never, + sinkFor: () => (() => {}), + }); + + const get = electron.handler('session:get'); + await expect(get!({ + sender: { id: record.webContentsId }, + senderFrame: { parent: null, url: 'file:///app/index.html' }, + })).resolves.toEqual({ + snapshot: record.restoreSnapshot, + restoreReason: 'shutdown', + }); + }); it('drops a queued write after a preserved target enters its pending commit fence', async () => { const record = { diff --git a/src/__tests__/session-schema.test.ts b/src/__tests__/session-schema.test.ts index 3ea1785..47522f2 100644 --- a/src/__tests__/session-schema.test.ts +++ b/src/__tests__/session-schema.test.ts @@ -18,6 +18,8 @@ import { migrateSessionSnapshot, upsertWindowSnapshot, removeWindowSnapshot, + isRestorableSessionWindow, + normalizeWindowSnapshot, LEGACY_WINDOW_ID, type SessionSnapshotV2, type SessionWindowSnapshot, @@ -245,3 +247,22 @@ describe('input immutability', () => { expect(state.windows.map((w) => w.id)).toEqual(['a', 'b']); // unchanged }); }); +describe('shutdown session schema additions', () => { + it('keeps document and chat-only windows while excluding empty windows', () => { + expect(isRestorableSessionWindow(win('doc', { doc: 'text' }))).toBe(true); + expect(isRestorableSessionWindow(win('chat', { unifiedChatHistory: [{ type: 'separator', label: 'x' }] }))).toBe(true); + expect(isRestorableSessionWindow(win('empty'))).toBe(false); + }); + + it('uses main-owned path and normalizes the optional shutdown marker', () => { + const snapshot = normalizeWindowSnapshot('main-key', '/main-owned.md', { + path: '/renderer-untrusted.md', + doc: 'body', + title: 'title', + }); + expect(snapshot).toMatchObject({ id: 'main-key', path: '/main-owned.md', doc: 'body' }); + expect(migrateSessionSnapshot({ version: 2, windows: [], restoreReason: 'shutdown' }).restoreReason).toBe('shutdown'); + expect(migrateSessionSnapshot({ version: 2, windows: [], restoreReason: 'quit' }).restoreReason).toBeUndefined(); + expect(migrateSessionSnapshot({ version: 2, windows: [] }).version).toBe(2); + }); +}); diff --git a/src/__tests__/session-store-queue.test.ts b/src/__tests__/session-store-queue.test.ts index 4726c77..988491b 100644 --- a/src/__tests__/session-store-queue.test.ts +++ b/src/__tests__/session-store-queue.test.ts @@ -7,7 +7,7 @@ * late renderer write could overwrite the before-quit cleanExit marker. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { SessionQueue, type SessionQueueIO } from '../main/session-queue'; import { upsertWindowSnapshot, @@ -15,9 +15,30 @@ import { type SessionSnapshotV2, type SessionWindowSnapshot, } from '../main/session-schema'; +const sessionStoreHarness = vi.hoisted(() => ({ + disk: { version: 2 as const, windows: [] as unknown[] }, + persists: 0, + failPersist: false, +})); + +vi.mock('electron', () => ({ app: { getPath: () => '/session-test' } })); +vi.mock('node:fs', () => ({ + promises: { + readFile: async () => JSON.stringify(sessionStoreHarness.disk), + rename: async () => {}, + }, +})); +vi.mock('../main/atomic-write', () => ({ + nodeAtomicBackend: () => ({}), + atomicWrite: async (_target: string, contents: string) => { + sessionStoreHarness.persists += 1; + if (sessionStoreHarness.failPersist) throw new Error('disk full'); + sessionStoreHarness.disk = JSON.parse(contents); + }, +})); function win(id: string, doc = `doc-${id}`): SessionWindowSnapshot { - return { id, path: null, doc, pendingTitle: null } as SessionWindowSnapshot; + return { id, path: null, doc, title: null } as SessionWindowSnapshot; } /** In-memory IO that records load/persist activity and can inject a failure. */ @@ -143,6 +164,14 @@ describe('SessionQueue — quit transaction', () => { await q.beginQuit((s) => ({ ...s, cleanExit: true })); expect(q.isQuitting()).toBe(true); }); + it('supersedes a committed quit with a durable shutdown snapshot', async () => { + const h = makeIO(); + const q = new SessionQueue(h.io); + await q.beginQuit((s) => ({ ...s, cleanExit: true })); + await q.beginQuit((s) => ({ ...s, cleanExit: false, restoreReason: 'shutdown' }), { supersede: true }); + expect(h.disk).toMatchObject({ cleanExit: false, restoreReason: 'shutdown' }); + expect(q.isQuitting()).toBe(true); + }); }); describe('SessionQueue — a persist failure does not wedge later writes', () => { @@ -157,3 +186,106 @@ describe('SessionQueue — a persist failure does not wedge later writes', () => expect(h.disk.windows.map((w) => w.id)).not.toContain('w1'); }); }); +describe('session shutdown marker store', () => { + it('writes filtered shutdown snapshots and its marker in one durable transaction', async () => { + sessionStoreHarness.disk = { + version: 2, + windows: [win('stale-empty', '')], + }; + sessionStoreHarness.persists = 0; + vi.resetModules(); + const { markShutdownRestoreQueued, getSessionAggregate } = await import('../main/session-store'); + + await markShutdownRestoreQueued([win('saved', 'body'), win('empty', '')]); + + expect(sessionStoreHarness.persists).toBe(1); + await expect(getSessionAggregate()).resolves.toMatchObject({ + cleanExit: false, + restoreReason: 'shutdown', + windows: [{ id: 'saved', doc: 'body' }], + }); + }); + it('supersedes a committed quit instead of silently accepting shutdown persistence', async () => { + sessionStoreHarness.disk = { version: 2, windows: [win('saved', 'before')] }; + sessionStoreHarness.persists = 0; + sessionStoreHarness.failPersist = false; + vi.resetModules(); + const { markCleanExitQueued, markShutdownRestoreQueued, getSessionAggregate } = await import('../main/session-store'); + + await markCleanExitQueued(); + await markShutdownRestoreQueued([win('saved', 'shutdown snapshot')]); + + expect(sessionStoreHarness.persists).toBe(2); + await expect(getSessionAggregate()).resolves.toMatchObject({ + cleanExit: false, + restoreReason: 'shutdown', + windows: [{ id: 'saved', doc: 'shutdown snapshot' }], + }); + }); + it('rejects a shutdown supersession when durable persistence fails', async () => { + sessionStoreHarness.disk = { version: 2, windows: [win('saved', 'before')] }; + sessionStoreHarness.persists = 0; + sessionStoreHarness.failPersist = false; + vi.resetModules(); + const { markCleanExitQueued, markShutdownRestoreQueued, getSessionAggregate } = await import('../main/session-store'); + + await markCleanExitQueued(); + sessionStoreHarness.failPersist = true; + await expect(markShutdownRestoreQueued([win('saved', 'shutdown snapshot')])).rejects.toThrow('disk full'); + sessionStoreHarness.failPersist = false; + + await expect(getSessionAggregate()).resolves.toMatchObject({ cleanExit: true, windows: [{ id: 'saved', doc: 'before' }] }); + }); + it('removes a stale snapshot when its shutdown replacement is empty', async () => { + sessionStoreHarness.disk = { version: 2, windows: [win('same-id', 'stale body')] }; + sessionStoreHarness.persists = 0; + sessionStoreHarness.failPersist = false; + vi.resetModules(); + const { markShutdownRestoreQueued, getSessionAggregate } = await import('../main/session-store'); + + await markShutdownRestoreQueued([win('same-id', '')]); + + await expect(getSessionAggregate()).resolves.toMatchObject({ windows: [] }); + }); + it('clears the shutdown marker for a clean quit', async () => { + sessionStoreHarness.disk = { + version: 2, + windows: [win('saved', 'body')], + cleanExit: false, + restoreReason: 'shutdown', + }; + sessionStoreHarness.persists = 0; + vi.resetModules(); + const { markCleanExitQueued, getSessionAggregate } = await import('../main/session-store'); + + await markCleanExitQueued(); + + expect(sessionStoreHarness.persists).toBe(1); + await expect(getSessionAggregate()).resolves.toMatchObject({ + cleanExit: true, + windows: [{ id: 'saved', doc: 'body' }], + }); + expect((await getSessionAggregate()).restoreReason).toBeUndefined(); + }); + + it('consumes only the marker and retains snapshots when consumption persistence fails', async () => { + sessionStoreHarness.disk = { + version: 2, + windows: [win('saved', 'body')], + cleanExit: false, + restoreReason: 'shutdown', + }; + sessionStoreHarness.persists = 0; + sessionStoreHarness.failPersist = false; + vi.resetModules(); + const { consumeShutdownRestoreMarker, getSessionAggregate } = await import('../main/session-store'); + + sessionStoreHarness.failPersist = true; + await expect(consumeShutdownRestoreMarker()).rejects.toThrow('disk full'); + sessionStoreHarness.failPersist = false; + await expect(getSessionAggregate()).resolves.toMatchObject({ + restoreReason: 'shutdown', + windows: [{ id: 'saved', doc: 'body' }], + }); + }); +}); diff --git a/src/main/app-windows.ts b/src/main/app-windows.ts index 66d604f..1118cbc 100644 --- a/src/main/app-windows.ts +++ b/src/main/app-windows.ts @@ -9,8 +9,10 @@ import { isTrustedAppUrl, SECURITY_REASON } from './security'; import { isAllowedExternalUrl } from './safe-external'; import { ProjectWizardRootStore } from './project-wizard/access'; import { sendWhenReady, type OutboundSink, type WindowRecord, type WindowRegistry } from './window-registry'; -import type { SessionWindowSnapshot } from './session-schema'; +import { normalizeWindowSnapshot, type SessionWindowSnapshot } from './session-schema'; +import { markShutdownRestoreQueued } from './session-store'; import { queueOrOpenFile, shouldPublishLaunchWindow, type CreateWindowOptions } from './lifecycle-flags'; +import { logWarn } from './app-log'; import type { ConvertDocument } from './convert'; import { closeGuardChoiceFromButton, @@ -41,11 +43,12 @@ type AppWindowsDeps = { removeSessionWindow: (windowKey: string) => Promise; removeSessionWindows: (windowKeys: readonly string[]) => Promise; commitQuitSession: (discardedWindowKeys: readonly string[]) => Promise; + commitShutdownSession?: (snapshots: readonly SessionWindowSnapshot[]) => Promise; showCloseDialog?: (win: BrowserWindow, labels: { title: string; message: string; save: string; discard: string; cancel: string; saveAllowed?: boolean }) => Promise; }; type AppWindows = { - createWindow: (opts?: CreateWindowOptions & { restore?: SessionWindowSnapshot }) => Promise; + createWindow: (opts?: CreateWindowOptions & { restore?: SessionWindowSnapshot; restoreReason?: 'shutdown' }) => Promise; openFilePath: (path: string, win: BrowserWindow, enrollment?: OpenEnrollmentPolicy) => Promise; handleOpen: () => Promise; setReady: () => void; @@ -55,7 +58,8 @@ type AppWindows = { sinkFor: (win: BrowserWindow) => OutboundSink; sendToFocused: (channel: string) => void; approveClose: (win: BrowserWindow) => Promise; - approveAllForQuit: (intent: 'quit' | 'relaunch') => Promise; + approveAllForQuit: (intent: 'quit' | 'relaunch' | 'shutdown') => Promise; + waitForCloseTransaction: () => Promise; clearCloseApprovals: () => void; isSessionWriteFenced: (windowKey: string) => boolean; }; @@ -84,6 +88,7 @@ export function createAppWindows({ removeSessionWindow, removeSessionWindows, commitQuitSession, + commitShutdownSession = markShutdownRestoreQueued, showCloseDialog = async (win, labels) => { const buttons = labels.saveAllowed === false ? [labels.discard, labels.cancel] @@ -115,6 +120,11 @@ export function createAppWindows({ const pendingDiscardRollback = new Map void }>(); const pendingConsume = new Map void }>(); const closeLeases = new Map(); + const pendingShutdownPersist = new Map void; + }>(); const quiesceReady = new Set(); const pendingQuiesce = new Map { + const value = raw as Record; + const id = typeof value?.id === 'string' ? value.id : ''; + const pending = pendingShutdownPersist.get(id); + if (!pending || pending.webContentsId !== event.sender.id) return; + pendingShutdownPersist.delete(id); + const fileSaved = value.fileSaved === true; + const error = typeof value.error === 'string' ? value.error : undefined; + if (error) { + void logWarn('lifecycle', 'shutdown persistence reported a failed save', { fileSaved, error, webContentsId: event.sender.id }); + } + pending.resolve({ + ok: value.ok === true, + revision: Number.isSafeInteger(value.revision) && (value.revision as number) >= 0 + ? value.revision as number + : -1, + snapshot: value.snapshot, + fileSaved, + error, + }); + }); onTrusted('close:authorize-result', (event, raw: unknown) => { const value = raw as Record; const id = typeof value?.requestId === 'string' ? value.requestId : ''; @@ -240,6 +271,11 @@ export function createAppWindows({ for (const pending of pendingDiscardPrepare.values()) { if (pending.webContentsId === event.sender.id && pending.leaseId === id) pending.resolve(false); } + for (const pending of pendingShutdownPersist.values()) { + if (pending.webContentsId === event.sender.id && pending.leaseId === id) { + pending.resolve({ ok: false, revision: -1, snapshot: null, fileSaved: false }); + } + } }); onTrusted('close:locale', (event, locale: unknown) => { const rec = registry.getByWebContents(event.sender.id); @@ -284,6 +320,29 @@ export function createAppWindows({ const lease = closeLeases.get(win.id); return !!leaseId && !!lease && lease.id === leaseId && !lease.invalidated && !win.isDestroyed(); }; + const persistShutdownFromRenderer = ( + win: BrowserWindow, + leaseId: string, + revision: number, + deadline: number, + ) => new Promise<{ ok: boolean; revision: number; snapshot: unknown; fileSaved: boolean; error?: string }>((resolve) => { + if (!activeLease(win, leaseId) || Date.now() >= deadline) { + resolve({ ok: false, revision: -1, snapshot: null, fileSaved: false }); + return; + } + const id = requestId('shutdown-persist', win); + const onDestroyed = () => settle({ ok: false, revision: -1, snapshot: null, fileSaved: false }); + const settle = (result: { ok: boolean; revision: number; snapshot: unknown; fileSaved: boolean; error?: string }) => { + clearTimeout(timer); + win.removeListener('closed', onDestroyed); + pendingShutdownPersist.delete(id); + resolve(result); + }; + const timer = setTimeout(() => settle({ ok: false, revision: -1, snapshot: null, fileSaved: false }), Math.max(0, deadline - Date.now())); + pendingShutdownPersist.set(id, { webContentsId: win.webContents.id, leaseId, resolve: settle }); + win.once('closed', onDestroyed); + win.webContents.send('close:shutdown-persist:request', { id, leaseId, revision }); + }); const authorizeRendererClose = (win: BrowserWindow, leaseId: string | undefined): Promise => { if (!activeLease(win, leaseId)) return Promise.resolve(false); return new Promise((resolve) => { @@ -466,11 +525,43 @@ export function createAppWindows({ authorize: () => authorizeRendererClose(win, leaseIdFor(win.id)), }); }; + const shutdownSnapshots = new Map(); + const decideShutdown = async (target: CloseTarget, context: CloseAttemptContext): Promise => { + const win = BrowserWindow.fromId(target.windowId); + if (!win || win.isDestroyed()) return 'allow'; + + let state: (CloseGuardState & { leaseId: string | null }) | null = null; + return runDecideCloseLoop({ + context, + queryState: async () => { + shutdownSnapshots.delete(target.windowKey); + state = await queryCloseState(win); + }, + resolveGuard: async () => { + if (!state?.leaseId || !state.known || state.syncFailed) return 'cancel'; + const result = await persistShutdownFromRenderer(win, state.leaseId, state.revision, context.forwardDeadline); + if ( + !result.ok + || result.revision !== state.revision + || result.snapshot == null + || typeof result.snapshot !== 'object' + || !activeLease(win, state.leaseId) + ) { + return 'cancel'; + } + const rec = registry.get(target.windowId); + if (!rec) return 'cancel'; + shutdownSnapshots.set(target.windowKey, normalizeWindowSnapshot(target.windowKey, rec.currentPath, result.snapshot)); + return 'allow'; + }, + authorize: () => authorizeRendererClose(win, leaseIdFor(win.id)), + }); + }; const targetsFor = (records: readonly WindowRecord[]): CloseTarget[] => records.map((rec) => ({ windowId: rec.windowId, windowKey: rec.windowKey })); const commitCloseTransaction = async ( - transaction: { intent: 'close' | 'quit' | 'relaunch'; targets: readonly CloseTarget[]; discards: readonly CloseTarget[]; context: CloseAttemptContext }, + transaction: { intent: 'close' | 'quit' | 'relaunch' | 'shutdown'; targets: readonly CloseTarget[]; discards: readonly CloseTarget[]; context: CloseAttemptContext }, ): Promise => { // A discard request fences autosave before its active save drains. Track it // before waiting so every partial prepare is explicitly rolled back. @@ -527,6 +618,14 @@ export function createAppWindows({ return { retry: [...new Map([...invalid, ...requestedDiscards].map((target) => [target.windowId, target])).values()] }; } + const snapshots = transaction.intent === 'shutdown' + ? transaction.targets.map((target) => shutdownSnapshots.get(target.windowKey)) + : []; + if (transaction.intent === 'shutdown' && snapshots.some((snapshot) => !snapshot)) { + await rollback(transaction.targets); + return { retry: transaction.targets }; + } + // Consume every already-authorized lease before any persistent or teardown // side effect. The renderer rejects later document mutations for a consumed // lease, closing the final validation-to-close race. @@ -553,6 +652,8 @@ export function createAppWindows({ try { if (transaction.intent === 'quit') { await commitQuitSession(discardedKeys); + } else if (transaction.intent === 'shutdown') { + await commitShutdownSession(snapshots as SessionWindowSnapshot[]); } else if (removedSessionKeys.length > 0) { await removeSessionWindows(removedSessionKeys); } @@ -585,9 +686,26 @@ export function createAppWindows({ return result.approved; }; - const approveAllForQuit = async (intent: 'quit' | 'relaunch') => { - const result = await coordinator.request(intent, targetsFor(registry.all()), decideClose, commitCloseTransaction, quiesceTransaction); - return result.approved && result.intent === intent; + const approveAllForQuit = async (intent: 'quit' | 'relaunch' | 'shutdown') => { + const records = registry.all(); + const unreadyBlankRecords = intent === 'shutdown' + ? records.filter((rec) => !rec.ready && rec.currentPath == null && !rec.restoreSnapshot && !rec.lastSnapshot) + : []; + try { + const result = await coordinator.request( + intent, + targetsFor(intent === 'shutdown' ? records.filter((rec) => !unreadyBlankRecords.includes(rec)) : records), + intent === 'shutdown' ? decideShutdown : decideClose, + commitCloseTransaction, + quiesceTransaction, + ); + if (result.approved && result.intent === 'shutdown') { + for (const rec of unreadyBlankRecords) approvedCloseWindowIds.add(rec.windowId); + } + return result.approved && result.intent === intent; + } finally { + if (intent === 'shutdown') shutdownSnapshots.clear(); + } }; const windowFromRecord = (rec: WindowRecord | null) => { @@ -717,7 +835,7 @@ export function createAppWindows({ if (rec) registry.claimPath(rec.windowId, canonicalPath); }; - const createWindow = async (opts: CreateWindowOptions & { restore?: SessionWindowSnapshot } = {}) => { + const createWindow = async (opts: CreateWindowOptions & { restore?: SessionWindowSnapshot; restoreReason?: 'shutdown' } = {}) => { // NOTEPAD_AI_HIDE_WINDOWS is a main-process-only seam for integration // runners: real windows still exist and render, but never steal the // user's screen or focus during automated runs. @@ -748,6 +866,7 @@ export function createAppWindows({ ready: false, pendingOutbound: [], restoreSnapshot: opts.restore, + restoreReason: opts.restoreReason, }; registry.register(record); // Electron can deliver an open-file event while the initial window is loading. @@ -862,6 +981,7 @@ export function createAppWindows({ sendToFocused, approveClose, approveAllForQuit, + waitForCloseTransaction: () => coordinator.waitForIdle(), clearCloseApprovals: () => approvedCloseWindowIds.clear(), isSessionWriteFenced: (windowKey) => sessionTargetStates.has(windowKey), }; diff --git a/src/main/close-coordinator.ts b/src/main/close-coordinator.ts index 7ac3259..a0758c3 100644 --- a/src/main/close-coordinator.ts +++ b/src/main/close-coordinator.ts @@ -1,4 +1,4 @@ -export type CloseIntent = 'close' | 'quit' | 'relaunch'; +export type CloseIntent = 'close' | 'quit' | 'relaunch' | 'shutdown'; export type CloseDecision = 'allow' | 'discard' | 'cancel'; export type CloseTarget = { @@ -129,6 +129,9 @@ export async function runDecideCloseLoop({ export class CloseCoordinator { private inFlight: Promise | null = null; private inFlightIntent: CloseIntent | null = null; + waitForIdle(): Promise { + return this.inFlight ? this.inFlight.then(() => {}) : Promise.resolve(); + } request( intent: CloseIntent, diff --git a/src/main/ipc/file-ipc.ts b/src/main/ipc/file-ipc.ts index a3b1a7e..583c062 100644 --- a/src/main/ipc/file-ipc.ts +++ b/src/main/ipc/file-ipc.ts @@ -74,6 +74,12 @@ export function registerFileIpc({ registry, fileGrants, identityFs, saveMutex, b if (!(await fileGrants.validateWriteAuthorization(authorization))) { throw new Error('not-authorized'); } + if ( + process.env.NOTEPAD_AI_INTEGRATION_TEST === '1' + && process.env.NOTEPAD_AI_SMOKE_FAIL_FILE_SAVE === '1' + ) { + throw new Error('smoke-file-save-failed'); + } }, beforeRename: async (temp) => { if (!(await fileGrants.validateWriteAuthorization(authorization))) { diff --git a/src/main/ipc/session-ipc.ts b/src/main/ipc/session-ipc.ts index 8d51726..f2bb1a3 100644 --- a/src/main/ipc/session-ipc.ts +++ b/src/main/ipc/session-ipc.ts @@ -1,22 +1,9 @@ import { BrowserWindow } from 'electron'; import { handleTrusted, onTrusted } from '../ipc-guard'; import { mutateSessionAggregate } from '../session-store'; -import { upsertWindowSnapshot, removeWindowSnapshot, type SessionWindowSnapshot } from '../session-schema'; +import { normalizeWindowSnapshot, upsertWindowSnapshot, removeWindowSnapshot } from '../session-schema'; import { flushPendingOutbound, type WindowRegistry, type OutboundSink } from '../window-registry'; -function toWindowSnapshot(id: string, currentPath: string | null | undefined, raw: unknown): SessionWindowSnapshot { - const r = (raw ?? {}) as Record; - const view = r.view === 'split' || r.view === 'editor-only' || r.view === 'preview-only' ? r.view : undefined; - const win: SessionWindowSnapshot = { id, path: currentPath ?? null, title: typeof r.title === 'string' ? r.title : null, doc: typeof r.doc === 'string' ? r.doc : '' }; - if (typeof r.savedAt === 'number') win.savedAt = r.savedAt; - if (typeof r.splitRatio === 'number') win.splitRatio = r.splitRatio; - if (view) win.view = view; - if (Array.isArray(r.unifiedChatHistory)) win.unifiedChatHistory = r.unifiedChatHistory as SessionWindowSnapshot['unifiedChatHistory']; - if (Array.isArray(r.chatHistory)) win.chatHistory = r.chatHistory as SessionWindowSnapshot['chatHistory']; - if (typeof r.model === 'string') win.model = r.model; - if (typeof r.dirty === 'boolean') win.dirty = r.dirty; - return win; -} export function registerSessionIpc({ registry, sinkFor, isSessionWriteFenced = () => false }: { registry: WindowRegistry; sinkFor: (win: BrowserWindow) => OutboundSink; @@ -25,13 +12,16 @@ export function registerSessionIpc({ registry, sinkFor, isSessionWriteFenced = ( const isCurrentWritableRecord = (webContentsId: number, record: ReturnType) => !!record && registry.getByWebContents(webContentsId) === record && !isSessionWriteFenced(record.windowKey); - handleTrusted('session:get', async (event) => ({ snapshot: registry.getByWebContents(event.sender.id)?.restoreSnapshot ?? null })); + handleTrusted('session:get', async (event) => { + const record = registry.getByWebContents(event.sender.id); + return { snapshot: record?.restoreSnapshot ?? null, restoreReason: record?.restoreReason }; + }); handleTrusted('session:write', async (event, snap: unknown) => { const rec = registry.getByWebContents(event.sender.id); if (!rec || isSessionWriteFenced(rec.windowKey)) return; let written = false; const next = await mutateSessionAggregate((cur) => { if (!isCurrentWritableRecord(event.sender.id, rec)) return cur; - const win = toWindowSnapshot(rec.windowKey, rec.currentPath, snap); + const win = normalizeWindowSnapshot(rec.windowKey, rec.currentPath, snap); rec.lastSnapshot = win; registry.syncSnapshotPath(rec.windowId, win); written = true; diff --git a/src/main/lifecycle-flags.ts b/src/main/lifecycle-flags.ts index 553c019..0fabc35 100644 --- a/src/main/lifecycle-flags.ts +++ b/src/main/lifecycle-flags.ts @@ -7,6 +7,63 @@ export type CreateWindowOptions = { export function shouldUseMockKeychain(env: NodeJS.ProcessEnv): boolean { return Boolean(env.NOTEPAD_AI_USERDATA) && env.NOTEPAD_AI_INTEGRATION_TEST === '1'; } +export type QuitApprovalReason = 'quit' | 'relaunch' | 'shutdown'; + +export type QuitApprovalController = { + beginSystemShutdown(): Promise; + requestQuitApproval(): Promise; +}; + +export function createQuitApprovalController({ + waitForCloseTransaction, + approveAllForQuit, + clearCloseApprovals, +}: { + waitForCloseTransaction(): Promise; + approveAllForQuit(reason: QuitApprovalReason): Promise; + clearCloseApprovals(): void | Promise; +}): QuitApprovalController { + let shutdownLatched = false; + let pending: Promise | null = null; + + const clearAndDeny = async () => { + await Promise.resolve(clearCloseApprovals()).catch(() => {}); + return false; + }; + + const start = (): Promise => { + if (pending) return pending; + + pending = (async () => { + try { + for (;;) { + await waitForCloseTransaction(); + const reason: QuitApprovalReason = shutdownLatched ? 'shutdown' : 'quit'; + const approved = await approveAllForQuit(reason); + if (shutdownLatched && reason !== 'shutdown') continue; + if (reason === 'shutdown' && !approved) shutdownLatched = false; + return approved; + } + } catch { + shutdownLatched = false; + return clearAndDeny(); + } + })().finally(() => { + pending = null; + }); + return pending; + }; + + return { + beginSystemShutdown() { + shutdownLatched = true; + return start(); + }, + requestQuitApproval() { + return start(); + }, + }; +} export function shouldPublishLaunchWindow(opts: CreateWindowOptions): boolean { return opts.isLaunchWindow === true && !opts.restore && !opts.openFilePath; diff --git a/src/main/main.ts b/src/main/main.ts index dc6e494..c38da25 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, shell } from 'electron'; +import { app, powerMonitor, shell } from 'electron'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { prewarmCliSpawnPath } from './ai/cli-runner'; @@ -9,7 +9,7 @@ import { FileGrants } from './file-grants'; import { KeyedMutex } from './keyed-mutex'; import { type IdentityFs } from './path-identity'; import { nodeAtomicBackend } from './atomic-write'; -import { getSessionAggregate, markCleanExitQueued, mutateSessionAggregate, resetSessionAggregate } from './session-store'; +import { consumeShutdownRestoreMarker, getSessionAggregate, markCleanExitQueued, mutateSessionAggregate, resetSessionAggregate } from './session-store'; import { createWindowRegistry } from './window-registry'; import { ProjectWizardRootStore } from './project-wizard/access'; import { isAllowedExternalUrl } from './safe-external'; @@ -39,8 +39,8 @@ import { registerSessionIpc } from './ipc/session-ipc'; import { registerWizardIpc } from './ipc/wizard-ipc'; import { createConverterHost, convertDocument, registerConvertIpc } from './convert'; import { createAppWindows, configureAppIdentity } from './app-windows'; -import { removeWindowSnapshot } from './session-schema'; -import { shouldUseMockKeychain } from './lifecycle-flags'; +import { isRestorableSessionWindow, removeWindowSnapshot } from './session-schema'; +import { createQuitApprovalController, shouldUseMockKeychain } from './lifecycle-flags'; import { shouldPreventBeforeQuit } from './close-guard'; import { buildMenu } from './menu'; import { configureAppLog, logInfo, logWarn, logError } from './app-log'; @@ -171,8 +171,11 @@ const windows = createAppWindows({ }, commitQuitSession: (windowKeys) => markCleanExitQueued(windowKeys), showCloseDialog: process.env.NOTEPAD_AI_INTEGRATION_TEST === '1' - && (testCloseChoice === 'save' || testCloseChoice === 'discard' || testCloseChoice === 'cancel') - ? async () => testCloseChoice + ? testCloseChoice === 'fail' + ? async () => { throw new Error('close-dialog-smoke-failed'); } + : testCloseChoice === 'save' || testCloseChoice === 'discard' || testCloseChoice === 'cancel' + ? async () => testCloseChoice + : undefined : undefined, }); @@ -265,27 +268,60 @@ registerHtmlExportAssetIpc({ attemptRegistry: htmlExportAttemptRegistry, }); -let quitGuardPending = false; let quitApproved = false; let relaunchApproved = false; +let pendingQuitApproval: Promise | null = null; -app.on('before-quit', (event) => { - if (!shouldPreventBeforeQuit({ quitApproved, relaunchApproved })) return; - // This must happen synchronously: native dialogs are async and Electron would - // otherwise start tearing windows down underneath the pending dialog. - event.preventDefault(); - if (quitGuardPending) return; - quitGuardPending = true; - void windows.approveAllForQuit('quit').then((approved) => { +const quitApprovalController = createQuitApprovalController({ + waitForCloseTransaction: windows.waitForCloseTransaction, + approveAllForQuit: async (reason) => { + const startedAt = Date.now(); + void logInfo('lifecycle', 'quit approval started', { reason, startedAt }); + let approved = false; + try { + approved = await windows.approveAllForQuit(reason); + return approved; + } finally { + void logInfo('lifecycle', 'quit approval completed', { + reason, + approved, + elapsedMs: Date.now() - startedAt, + }); + } + }, + clearCloseApprovals: windows.clearCloseApprovals, +}); + +const requestQuitApproval = () => quitApprovalController.requestQuitApproval(); + +const completeQuitApproval = (approval: Promise) => { + if (pendingQuitApproval === approval) return; + pendingQuitApproval = approval; + void approval.then((approved) => { if (!approved) return; quitApproved = true; app.quit(); - }).catch((error) => { - windows.clearCloseApprovals(); - console.error('[close] quit guard failed:', error); }).finally(() => { - quitGuardPending = false; + if (pendingQuitApproval === approval) pendingQuitApproval = null; }); +}; + +const beginSystemShutdown = () => { + const latchedAt = Date.now(); + void logInfo('lifecycle', 'system shutdown latched', { latchedAt }); + const approval = quitApprovalController.beginSystemShutdown(); + completeQuitApproval(approval); + return approval; +}; + +app.on('before-quit', (event) => { + if (!shouldPreventBeforeQuit({ quitApproved, relaunchApproved })) return; + // This must happen synchronously: native dialogs are async and Electron would + // otherwise start tearing windows down underneath the pending dialog. + event.preventDefault(); + const arrivedAt = Date.now(); + void logInfo('lifecycle', 'before quit received', { arrivedAt }); + completeQuitApproval(requestQuitApproval()); }); app.whenReady().then(async () => { void logInfo('boot', 'app ready', { @@ -294,6 +330,22 @@ app.whenReady().then(async () => { log: 'ready', }); void prewarmCliSpawnPath(); + if (process.platform === 'darwin') { + // Electron's powerMonitor shutdown event type omits preventDefault despite supporting it on macOS. + const shutdownPowerMonitor = powerMonitor as unknown as { + on(event: 'shutdown', listener: (event: { preventDefault(): void }) => void): void; + }; + shutdownPowerMonitor.on('shutdown', (event) => { + event.preventDefault(); + void beginSystemShutdown(); + }); + } + if ( + process.env.NOTEPAD_AI_INTEGRATION_TEST === '1' + && process.env.NOTEPAD_AI_CLOSE_SMOKE_TRIGGER === 'shutdown' + ) { + handleTrusted('close-smoke:begin-shutdown', () => beginSystemShutdown()); + } // Construct the additive quarantine pool now that Electron is ready. It stays // unwired from the live wizard (PR-S3b); only the html:quarantine:measure IPC // reaches it. @@ -329,9 +381,20 @@ app.whenReady().then(async () => { async function restorePreviousWindows(): Promise { const prev = await getSessionAggregate(); if (prev.cleanExit === true) { await resetSessionAggregate(); return false; } - const candidates = prev.windows.filter((w) => (w.doc?.length ?? 0) > 0 || (w.unifiedChatHistory?.length ?? 0) > 0); + const candidates = prev.windows.filter(isRestorableSessionWindow); if (candidates.length === 0) return false; - for (const snap of candidates) await windows.createWindow({ restore: snap }); + + let restoreReason: 'shutdown' | undefined; + if (prev.restoreReason === 'shutdown') { + try { + await consumeShutdownRestoreMarker(); + restoreReason = 'shutdown'; + } catch { + void logError('session', 'shutdown restore marker consumption failed', { operation: 'consume-shutdown-marker' }); + } + } + + for (const snap of candidates) await windows.createWindow({ restore: snap, restoreReason }); console.log(`[session] restored windows=${candidates.length}`); return true; } diff --git a/src/main/preload.ts b/src/main/preload.ts index 0800c76..1f56fc9 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -21,6 +21,7 @@ import type { PickHtmlAssetsRequest, PickHtmlAssetsResponse, } from '../shared/html-export-assets'; +import type { ShutdownPersistRequest, ShutdownPersistResult } from '../renderer/api-types'; type OpenedFile = { filePath: string | null; @@ -277,6 +278,28 @@ const api = { sendCloseQuiesceReady: (): void => ipcRenderer.send('close:quiesce-ready'), setCloseLocale: (locale: 'en' | 'ko' | 'zh-Hans' | 'zh-Hant' | 'ja'): void => ipcRenderer.send('close:locale', locale), + onShutdownPersistRequest: (cb: (request: ShutdownPersistRequest) => void): (() => void) => { + const listener = (_e: unknown, request: Partial) => { + if ( + typeof request?.id === 'string' && + typeof request.leaseId === 'string' && + typeof request.revision === 'number' && + Number.isSafeInteger(request.revision) && + request.revision >= -1 + ) { + cb({ id: request.id, leaseId: request.leaseId, revision: request.revision }); + } + }; + ipcRenderer.on('close:shutdown-persist:request', listener); + return () => ipcRenderer.removeListener('close:shutdown-persist:request', listener); + }, + sendShutdownPersistResult: (result: ShutdownPersistResult): void => { + ipcRenderer.send('close:shutdown-persist:result', result); + }, + ...(process.env.NOTEPAD_AI_INTEGRATION_TEST === '1' + && process.env.NOTEPAD_AI_CLOSE_SMOKE_TRIGGER === 'shutdown' + ? { closeSmokeBeginShutdown: (): Promise => ipcRenderer.invoke('close-smoke:begin-shutdown') } + : {}), checkForUpdate: (): Promise<{ updateAvailable: boolean; currentVersion: string; latestVersion: string; url: string } | null> => ipcRenderer.invoke('update:check'), diff --git a/src/main/session-queue.ts b/src/main/session-queue.ts index cbf4aa3..826870e 100644 --- a/src/main/session-queue.ts +++ b/src/main/session-queue.ts @@ -86,10 +86,11 @@ export class SessionQueue { */ beginQuit( mutator: (current: SessionSnapshotV2) => SessionSnapshotV2, + opts: { supersede?: boolean } = {}, ): Promise { const run = this.chain.then(async () => { const current = await this.ensureLoaded(); - if (this.quitting) return current; + if (this.quitting && !opts.supersede) return current; const next = mutator(current); await this.io.persist(next); this.state = next; diff --git a/src/main/session-schema.ts b/src/main/session-schema.ts index 8b412a5..b12780e 100644 --- a/src/main/session-schema.ts +++ b/src/main/session-schema.ts @@ -45,7 +45,36 @@ export type SessionSnapshotV2 = { version: 2; windows: SessionWindowSnapshot[]; cleanExit?: boolean; + restoreReason?: 'shutdown'; }; +export function isRestorableSessionWindow(snapshot: Pick): boolean { + return (snapshot.doc?.length ?? 0) > 0 || (snapshot.unifiedChatHistory?.length ?? 0) > 0; +} + +export function normalizeWindowSnapshot( + id: string, + currentPath: string | null | undefined, + raw: unknown, +): SessionWindowSnapshot { + const r = isRecord(raw) ? raw : {}; + const win: SessionWindowSnapshot = { + id, + path: currentPath ?? null, + title: typeof r.title === 'string' ? r.title : null, + doc: typeof r.doc === 'string' ? r.doc : '', + }; + if (typeof r.savedAt === 'number') win.savedAt = r.savedAt; + if (typeof r.splitRatio === 'number') win.splitRatio = r.splitRatio; + const view = asViewMode(r.view); + if (view) win.view = view; + const chat = cloneArray(r.chatHistory); + if (chat) win.chatHistory = chat; + const unified = cloneArray(r.unifiedChatHistory); + if (unified) win.unifiedChatHistory = unified; + if (typeof r.model === 'string') win.model = r.model; + if (typeof r.dirty === 'boolean') win.dirty = r.dirty; + return win; +} /** Deterministic id assigned to the single window produced by a legacy migration. */ export const LEGACY_WINDOW_ID = 'legacy'; @@ -98,6 +127,7 @@ function normalizeV2(raw: Record): SessionSnapshotV2 { } const out: SessionSnapshotV2 = { version: 2, windows }; if (typeof raw.cleanExit === 'boolean') out.cleanExit = raw.cleanExit; + if (raw.restoreReason === 'shutdown') out.restoreReason = 'shutdown'; return out; } diff --git a/src/main/session-store.ts b/src/main/session-store.ts index 5b549e9..e7b85cb 100644 --- a/src/main/session-store.ts +++ b/src/main/session-store.ts @@ -1,7 +1,14 @@ import { app } from 'electron'; import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { migrateSessionSnapshot, removeWindowSnapshot, type SessionSnapshotV2 } from './session-schema'; +import { + isRestorableSessionWindow, + migrateSessionSnapshot, + removeWindowSnapshot, + upsertWindowSnapshot, + type SessionSnapshotV2, + type SessionWindowSnapshot, +} from './session-schema'; import { SessionQueue } from './session-queue'; import { atomicWrite as atomicWriteFile, nodeAtomicBackend } from './atomic-write'; @@ -70,7 +77,10 @@ export function getSessionAggregate(): Promise { export function mutateSessionAggregate( mutator: (current: SessionSnapshotV2) => SessionSnapshotV2, ): Promise { - return sessionQueue.mutate(mutator); + return sessionQueue.mutate((current) => { + const { restoreReason: _restoreReason, ...withoutRestoreReason } = current; + return mutator(withoutRestoreReason); + }); } /** @@ -78,10 +88,29 @@ export function mutateSessionAggregate( * writes. Discarded windows and the clean-exit marker are one durable change. */ export async function markCleanExitQueued(windowKeys: readonly string[] = []): Promise { - await sessionQueue.beginQuit((state) => ({ - ...windowKeys.reduce(removeWindowSnapshot, state), - cleanExit: true, - })); + await sessionQueue.beginQuit((state) => { + const { restoreReason: _restoreReason, ...withoutRestoreReason } = state; + return { + ...windowKeys.reduce(removeWindowSnapshot, withoutRestoreReason), + cleanExit: true, + }; + }); +} + +/** Persist restorable shutdown snapshots and fence late renderer writes as one transaction. */ +export async function markShutdownRestoreQueued(snapshots: readonly SessionWindowSnapshot[]): Promise { + await sessionQueue.beginQuit((state) => { + const windows = snapshots + .reduce((current, snapshot) => upsertWindowSnapshot(current, snapshot), state) + .windows + .filter(isRestorableSessionWindow); + return { version: 2, windows, cleanExit: false, restoreReason: 'shutdown' }; + }, { supersede: true }); +} + +/** Consume the one-shot shutdown restore marker without deleting its snapshots. */ +export function consumeShutdownRestoreMarker(): Promise { + return mutateSessionAggregate((state) => state); } /** Reset the aggregate to a clean empty state (after a clean-exit restore check). */ diff --git a/src/main/window-registry.ts b/src/main/window-registry.ts index a7530bb..3cc16be 100644 --- a/src/main/window-registry.ts +++ b/src/main/window-registry.ts @@ -41,6 +41,8 @@ export type WindowRecord = { pendingOutbound: OutboundMessage[]; /** Snapshot to restore into this window on launch (session aggregate). */ restoreSnapshot?: SessionWindowSnapshot; + /** One-shot restore mode assigned by main for this window only. */ + restoreReason?: 'shutdown'; /** Most recent snapshot reported by this window's renderer. */ lastSnapshot?: SessionWindowSnapshot; }; diff --git a/src/renderer/api-types.ts b/src/renderer/api-types.ts index 4194c33..bee5b30 100644 --- a/src/renderer/api-types.ts +++ b/src/renderer/api-types.ts @@ -4,6 +4,7 @@ import type { FileTreeEntry } from '../shared/file-types'; import type { HtmlExportPipelineApi, SaveFinalizedRequest, SaveFinalizedResult } from '../shared/html-export-pipeline'; import type { GenerationAttemptResult } from '../main/html-export-generation-orchestrator'; import type { HtmlExportAssetApi } from '../shared/html-export-assets'; +import type { UnifiedChatItem } from './unified-chat-history'; type ProjectWizardSaveApprovedDraftInput = { projectFolder: string; @@ -52,6 +53,32 @@ type ReasoningCapabilitiesSnapshot = { models: Array<{ modelId: string; efforts: ReasoningEffort[] }>; accountModels: string[]; }; +export type ShutdownPersistRequest = { + id: string; + leaseId: string; + revision: number; +}; + +export type ShutdownSessionSnapshot = { + savedAt: number; + path: string | null; + title: string | null; + doc: string; + view: 'split' | 'editor-only' | 'preview-only'; + unifiedChatHistory: UnifiedChatItem[]; + model?: string; + dirty: boolean; +}; + +export type ShutdownPersistResult = { + id: string; + ok: boolean; + fileSaved: boolean; + snapshot: ShutdownSessionSnapshot | null; + revision: number; + error?: string; +}; + export type Api = HtmlExportPipelineApi & HtmlExportAssetApi & { @@ -115,6 +142,10 @@ export type Api = HtmlExportPipelineApi & HtmlExportAssetApi & { sendCloseQuiesceResult: (requestId: string, result: { prepared?: boolean; rolledBack?: boolean }) => void; sendCloseQuiesceReady: () => void; setCloseLocale: (locale: 'en' | 'ko' | 'zh-Hans' | 'zh-Hant' | 'ja') => void; + onShutdownPersistRequest: (cb: (request: ShutdownPersistRequest) => void) => () => void; + sendShutdownPersistResult: (result: ShutdownPersistResult) => void; + /** Integration-only smoke bridge; present only when the shutdown smoke trigger env is active. */ + closeSmokeBeginShutdown?: () => Promise; checkForUpdate: () => Promise<{ updateAvailable: boolean; currentVersion: string; latestVersion: string; url: string } | null>; openExternal: (url: string) => Promise; appVersion: () => Promise; diff --git a/src/renderer/doc-lifecycle.test.ts b/src/renderer/doc-lifecycle.test.ts index cce08d0..bf8a9c5 100644 --- a/src/renderer/doc-lifecycle.test.ts +++ b/src/renderer/doc-lifecycle.test.ts @@ -60,6 +60,19 @@ describe('document close lease and replacement lifecycle', () => { expect(sendCloseLeaseInvalidated).toHaveBeenCalledWith('lease-1', 1); expect(lifecycle.authorizeCloseLease('lease-1')).toBe(false); }); + it('accepts shutdown persistence only for the current unfailed close lease revision', () => { + const { ctx, lifecycle } = setup(); + lifecycle.beginCloseLease('shutdown-lease'); + + expect(lifecycle.canPersistShutdown('shutdown-lease', 0)).toBe(true); + expect(lifecycle.canPersistShutdown('shutdown-lease', 1)).toBe(false); + + lifecycle.markPreviewSyncFailed(); + expect(lifecycle.canPersistShutdown('shutdown-lease', 0)).toBe(false); + + ctx.docRevision = 1; + expect(lifecycle.canPersistShutdown('shutdown-lease', 1)).toBe(false); + }); it('records a preview input through the lifecycle and invalidates its close lease', () => { const { ctx, lifecycle, sendCloseLeaseInvalidated } = setup(); diff --git a/src/renderer/doc-lifecycle.ts b/src/renderer/doc-lifecycle.ts index b184dcf..e6813e8 100644 --- a/src/renderer/doc-lifecycle.ts +++ b/src/renderer/doc-lifecycle.ts @@ -377,6 +377,10 @@ export function initDocLifecycle(ctx: AppContext, deps: DocLifecycleDeps) { function authorizeCloseLease(id: string): boolean { return closeLease?.id === id && !closeLease.invalidated && !closeLease.consumed && closeLease.revision === ctx.docRevision; } + function canPersistShutdown(id: string, revision: number): boolean { + return authorizeCloseLease(id) && revision === ctx.docRevision && !previewSyncFailed; + } + function consumeCloseLease(id: string): boolean { if (!authorizeCloseLease(id)) return false; @@ -530,6 +534,7 @@ export function initDocLifecycle(ctx: AppContext, deps: DocLifecycleDeps) { beginCloseLease, consumeCloseLease, authorizeCloseLease, + canPersistShutdown, fenceDiscard, rollbackDiscardFence, setPreviewFlushGate, diff --git a/src/renderer/main.ts b/src/renderer/main.ts index c2cbcb7..c0be42f 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -240,6 +240,51 @@ window.api.onCloseQueryState((requestId) => { }), }); }); +window.api.onShutdownPersistRequest(({ id, leaseId, revision }) => { + void (async () => { + if (!docLifecycle.canPersistShutdown(leaseId, revision)) { + window.api.sendShutdownPersistResult({ + id, + ok: false, + fileSaved: false, + snapshot: null, + revision: ctx.docRevision, + error: docLifecycle.hasPreviewSyncFailure() ? 'preview-sync-failed' : 'invalid-lease', + }); + return; + } + + let fileSaved = false; + let error: string | undefined; + if (ctx.currentPath !== null && ctx.dirty) { + try { + const committedRevision = await docLifecycle.save(); + fileSaved = committedRevision !== null && committedRevision >= revision; + if (!fileSaved) error = 'save-failed'; + } catch { + error = 'save-failed'; + } + } + + window.api.sendShutdownPersistResult({ + id, + ok: true, + fileSaved, + snapshot: sessionSnapshot.buildSessionSnapshot(), + revision: ctx.docRevision, + error, + }); + })().catch(() => { + window.api.sendShutdownPersistResult({ + id, + ok: false, + fileSaved: false, + snapshot: null, + revision: ctx.docRevision, + error: 'snapshot-failed', + }); + }); +}); window.api.onCloseQuiescePrepare(({ requestId, ttlMs }) => { void docLifecycle.prepareCloseQuiesce(requestId, ttlMs).then((prepared) => { window.api.sendCloseQuiesceResult(requestId, { prepared }); diff --git a/src/renderer/session-snapshot.test.ts b/src/renderer/session-snapshot.test.ts index 2ea8dbd..20b4681 100644 --- a/src/renderer/session-snapshot.test.ts +++ b/src/renderer/session-snapshot.test.ts @@ -27,8 +27,124 @@ afterEach(() => { setLocale('en'); delete (window as any).confirm; vi.restoreAllMocks(); + vi.useRealTimers(); + document.body.replaceChildren(); }); +function createRestoreHarness(response: unknown) { + const replaceDocument = vi.fn(); + const applyPreviewMode = vi.fn(); + const setUnifiedChatHistory = vi.fn(); + const unifiedRestore = vi.fn(); + const setStatus = vi.fn(); + const ctx = { + currentPath: null, + pendingTitle: null, + previewMode: 'split', + dirty: false, + editor: { getDoc: () => 'draft' }, + setStatus, + } as unknown as AppContext; + (window as any).api = { + sessionGet: vi.fn(async () => response), + sessionWrite: vi.fn(async () => {}), + sessionClear: vi.fn(async () => {}), + }; + initSessionSnapshot(ctx, { + prefs: { theme: 'system', fontSize: 'md' }, + unifiedChat: { restore: unifiedRestore } as never, + getUnifiedChatHistory: () => [{ type: 'separator', label: 'restored' }], + setUnifiedChatHistory, + setUnifiedChatOpen: vi.fn(), + applyPreviewMode, + replaceDocument, + }); + return { replaceDocument, applyPreviewMode, setUnifiedChatHistory, unifiedRestore, sessionClear: (window as any).api.sessionClear }; +} + +describe('session restore mode', () => { + it('applies shutdown restores immediately without creating a banner', async () => { + vi.useFakeTimers(); + const restore = createRestoreHarness({ + snapshot: { + doc: 'shutdown draft', + path: '/restored.md', + title: 'Restored', + dirty: true, + view: 'preview-only', + unifiedChatHistory: [{ type: 'separator', label: 'restored' }], + }, + restoreReason: 'shutdown', + }); + + await Promise.resolve(); + + expect(restore.replaceDocument).toHaveBeenCalledWith({ + doc: 'shutdown draft', + currentPath: '/restored.md', + pendingTitle: 'Restored', + dirty: true, + }); + expect(restore.applyPreviewMode).toHaveBeenCalledOnce(); + expect(restore.setUnifiedChatHistory).toHaveBeenCalledOnce(); + expect(restore.unifiedRestore).toHaveBeenCalledOnce(); + expect(document.querySelector('.restore-yes')).toBeNull(); + }); + it('keeps crash restores behind the banner and clears on No', async () => { + vi.useFakeTimers(); + const restore = createRestoreHarness({ snapshot: { doc: 'crash draft' } }); + + await Promise.resolve(); + expect(restore.replaceDocument).not.toHaveBeenCalled(); + vi.advanceTimersByTime(400); + + expect(document.querySelector('.restore-yes')).not.toBeNull(); + (document.querySelector('.restore-no') as HTMLButtonElement).click(); + expect(restore.sessionClear).toHaveBeenCalledOnce(); + expect(restore.replaceDocument).not.toHaveBeenCalled(); + }); + + it('applies crash restores only after Yes, including after a consumed shutdown marker', async () => { + vi.useFakeTimers(); + createRestoreHarness({ + snapshot: { doc: 'shutdown draft' }, + restoreReason: 'shutdown', + }); + await Promise.resolve(); + document.body.replaceChildren(); + + const restore = createRestoreHarness({ snapshot: { doc: 'next crash draft' } }); + await Promise.resolve(); + expect(restore.replaceDocument).not.toHaveBeenCalled(); + vi.advanceTimersByTime(400); + + (document.querySelector('.restore-yes') as HTMLButtonElement).click(); + expect(restore.replaceDocument).toHaveBeenCalledWith({ + doc: 'next crash draft', + currentPath: null, + pendingTitle: null, + dirty: false, + }); + }); +}); + +describe('buildSessionSnapshot', () => { + it('returns the same current payload used by scheduled writes', () => { + (window as any).api = { sessionGet: vi.fn(async () => undefined) }; + + const snapshot = createSessionSnapshot().buildSessionSnapshot(); + + expect(snapshot).toMatchObject({ + path: null, + title: null, + doc: 'draft', + view: 'split', + unifiedChatHistory: [], + dirty: false, + }); + expect(snapshot.savedAt).toEqual(expect.any(Number)); + }); +}); describe('requestLocaleRestart', () => { it('leaves locale and preferences untouched when restart is cancelled', async () => { const sessionWrite = vi.fn(async () => {}); diff --git a/src/renderer/session-snapshot.ts b/src/renderer/session-snapshot.ts index 2ffec93..702df8e 100644 --- a/src/renderer/session-snapshot.ts +++ b/src/renderer/session-snapshot.ts @@ -52,6 +52,21 @@ export function initSessionSnapshot(ctx: AppContext, deps: SessionSnapshotDeps) return true; } + function applySessionSnapshot(snap: any) { + deps.replaceDocument({ + doc: snap.doc ?? '', + currentPath: typeof snap.path === 'string' ? snap.path : null, + pendingTitle: typeof snap.title === 'string' ? snap.title : null, + dirty: snap.dirty === true, + }); + if (snap.view) { ctx.previewMode = snap.view as PreviewMode; deps.applyPreviewMode(); } + deps.setUnifiedChatHistory(restoreUnifiedThread(snap)); + deps.unifiedChat.restore(snap); + if (deps.getUnifiedChatHistory().length > 0) deps.setUnifiedChatOpen(true); + scheduleSessionSnapshot(); + ctx.setStatus(t('status.sessionRestored')); + } + function showRestoreBanner(snap: any) { const root = buildRestoreBanner( { doc: snap.doc, savedAt: snap.savedAt }, @@ -59,20 +74,7 @@ export function initSessionSnapshot(ctx: AppContext, deps: SessionSnapshotDeps) ); document.body.appendChild(root); root.querySelector('.restore-yes')?.addEventListener('click', () => { - deps.replaceDocument({ - doc: snap.doc ?? '', - currentPath: typeof snap.path === 'string' ? snap.path : null, - pendingTitle: typeof snap.title === 'string' ? snap.title : null, - dirty: snap.dirty === true, - }); - if (snap.view) { ctx.previewMode = snap.view as PreviewMode; deps.applyPreviewMode(); } - if (snap) { - deps.setUnifiedChatHistory(restoreUnifiedThread(snap)); - deps.unifiedChat.restore(snap); - if (deps.getUnifiedChatHistory().length > 0) deps.setUnifiedChatOpen(true); - } - scheduleSessionSnapshot(); - ctx.setStatus(t('status.sessionRestored')); + applySessionSnapshot(snap); root.remove(); }); root.querySelector('.restore-no')?.addEventListener('click', () => { @@ -84,10 +86,13 @@ export function initSessionSnapshot(ctx: AppContext, deps: SessionSnapshotDeps) void (async () => { const res = await window.api.sessionGet(); const snap = res?.snapshot; - if (snap && ((snap.doc?.length ?? 0) > 0 || (snap.unifiedChatHistory?.length ?? 0) > 0)) { - setTimeout(() => showRestoreBanner(snap), 400); + if (!snap || ((snap.doc?.length ?? 0) === 0 && (snap.unifiedChatHistory?.length ?? 0) === 0)) return; + if (res.restoreReason === 'shutdown') { + applySessionSnapshot(snap); + return; } + setTimeout(() => showRestoreBanner(snap), 400); })(); - return { scheduleSessionSnapshot, flushSessionSnapshot, requestLocaleRestart }; + return { buildSessionSnapshot, scheduleSessionSnapshot, flushSessionSnapshot, requestLocaleRestart }; } From 7f8993d79b43cdd51913c0c0aa08aaba80ebd3de Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:29:15 +0900 Subject: [PATCH 2/9] fix(close): supersede quit fallback and harden shutdown smoke (#73) When an approved quit is superseded by a denied system-shutdown pass, fall back to the already-approved quit instead of retaining a dead latch/fence. Smoke runner: share one userData across the legacy seven scenarios (cold per-scenario isolation exposed a pre-existing save-close flake), bound shutdown first-phase waits, force-exit failed Electron workers, and focus CodeMirror before synthetic edits so shutdown pairs stay deterministic. --- scripts/close-dialog-smoke-runner.mjs | 36 +++++++++++++++---- src/__tests__/main-lifecycle-contract.test.ts | 26 ++++++++++++++ src/main/lifecycle-flags.ts | 13 +++++-- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/scripts/close-dialog-smoke-runner.mjs b/scripts/close-dialog-smoke-runner.mjs index 0f3f3cf..3aa30f3 100644 --- a/scripts/close-dialog-smoke-runner.mjs +++ b/scripts/close-dialog-smoke-runner.mjs @@ -39,13 +39,17 @@ function editorText(win) { async function replaceEditorText(win, content) { await waitFor('editor', () => win.webContents.executeJavaScript(`Boolean(document.querySelector('.cm-content'))`)); + // Headless/hidden windows deliver selectAll()+insertText() unreliably unless + // the CM content root is focused first (same pattern as roundtrip-smoke). + await win.webContents.executeJavaScript(`document.querySelector('.cm-content')?.focus(); true`); return waitFor('CodeMirror edit', async () => { win.focus(); win.webContents.focus(); + await win.webContents.executeJavaScript(`document.querySelector('.cm-content')?.focus(); true`); win.webContents.selectAll(); - await delay(30); + await delay(50); win.webContents.insertText(content); - await delay(30); + await delay(50); return (await editorText(win)) === content; }, 60_000); } @@ -111,7 +115,7 @@ async function worker() { app.quit(); await Promise.race([ closed, - delay(10_000).then(() => { throw new Error('quit discard did not close both dirty windows'); }), + delay(20_000).then(() => { throw new Error('quit discard did not close both dirty windows'); }), ]); console.log('[close-dialog-smoke] quit-discard-closed-two-dirty-windows'); return; @@ -120,7 +124,7 @@ async function worker() { win.close(); await Promise.race([ closed, - delay(10_000).then(() => { throw new Error(`${scenario} did not close the window`); }), + delay(20_000).then(() => { throw new Error(`${scenario} did not close the window`); }), ]); console.log(`[close-dialog-smoke] ${scenario}-closed-window`); app.exit(0); @@ -141,6 +145,11 @@ async function shutdownWorker() { if (shutdownPhase === 'first') { app.emit('open-file', { preventDefault() {} }, documentPath); const pathWindow = await waitFor('path window', () => BrowserWindow.getAllWindows()[0] ?? null); + await waitFor('opened file reaches the rendered editor', () => + pathWindow.webContents.executeJavaScript( + `Array.from(document.querySelectorAll('.cm-line')).map((line) => line.textContent || '').join('\\n').includes(${JSON.stringify('Close smoke')})`, + ), + ); await replaceEditorText(pathWindow, shutdownPathContent); clickNewWindow(Menu); @@ -156,7 +165,10 @@ async function shutdownWorker() { `typeof window.api.closeSmokeBeginShutdown === 'function'`, )); await pathWindow.webContents.executeJavaScript(`window.api.closeSmokeBeginShutdown()`); - await new Promise((resolveClosed) => app.once('window-all-closed', resolveClosed)); + await Promise.race([ + new Promise((resolveClosed) => app.once('window-all-closed', resolveClosed)), + delay(20_000).then(() => { throw new Error('shutdown approval did not close all windows'); }), + ]); console.log('[close-dialog-smoke] shutdown-first-closed'); return; } @@ -257,18 +269,28 @@ if (scenario) { void worker().catch((error) => { console.error(`[close-dialog-smoke] worker failure: ${error?.stack ?? error}`); process.exitCode = 2; + // A failed worker must terminate the Electron app; open windows would + // otherwise keep the process (and the whole smoke run) alive forever. + electron.app.exit(2); }); } else if (shutdownPhase) { void shutdownWorker().catch((error) => { console.error(`[close-dialog-smoke] shutdown worker failure: ${error?.stack ?? error}`); process.exitCode = 2; + electron.app.exit(2); }); } else { void (async () => { if (!existsSync(resolve(REPO, 'dist/main/main.js'))) throw new Error('dist/main/main.js missing; build the app before smoke execution'); + // The legacy seven scenarios intentionally share ONE userData in their + // historical order, exactly as before the shutdown pairs were added: a + // per-scenario cold userData exposes a pre-existing cold-start save-close + // delay (>20s on some machines) that made `save`/`save-large` flaky. + // Isolation is only required between the legacy block and the shutdown + // pairs below, which each use their own pair-local userData. + const userData = mkdtempSync(join(tmpdir(), 'notepad-ai-close-smoke-')); + const { doc, secondDoc, largeDoc } = createFixture(userData); for (const choice of ['discard', 'save', 'cancel', 'quit-cancel', 'quit-discard', 'discard-large', 'save-large']) { - const userData = mkdtempSync(join(tmpdir(), 'notepad-ai-close-smoke-')); - const { doc, secondDoc, largeDoc } = createFixture(userData); const base = choice.replace(/-large$/, ''); await waitForWorker(choice, { NOTEPAD_AI_CLOSE_SMOKE_SCENARIO: choice, diff --git a/src/__tests__/main-lifecycle-contract.test.ts b/src/__tests__/main-lifecycle-contract.test.ts index beb9343..57bd263 100644 --- a/src/__tests__/main-lifecycle-contract.test.ts +++ b/src/__tests__/main-lifecycle-contract.test.ts @@ -63,6 +63,32 @@ describe('quit approval lifecycle controller', () => { await expect(Promise.all([quit, shutdown])).resolves.toEqual([true, true]); expect(approveAllForQuit).toHaveBeenCalledTimes(2); }); + it('falls back to an approved quit when its shutdown rerun is denied without retaining it', async () => { + const approval = deferred(); + const clearCloseApprovals = vi.fn(); + let calls = 0; + const approveAllForQuit = vi.fn(async () => { + calls += 1; + return calls === 1 ? approval.promise : false; + }); + const controller = createQuitApprovalController({ + waitForCloseTransaction: async () => {}, + approveAllForQuit, + clearCloseApprovals, + }); + + const quit = controller.requestQuitApproval(); + await vi.waitFor(() => expect(approveAllForQuit).toHaveBeenCalledWith('quit')); + const shutdown = controller.beginSystemShutdown(); + approval.resolve(true); + + await vi.waitFor(() => expect(approveAllForQuit).toHaveBeenCalledWith('shutdown')); + await expect(Promise.all([quit, shutdown])).resolves.toEqual([true, true]); + await expect(controller.requestQuitApproval()).resolves.toBe(false); + + expect(approveAllForQuit.mock.calls.map(([reason]) => reason)).toEqual(['quit', 'shutdown', 'quit']); + expect(clearCloseApprovals).not.toHaveBeenCalled(); + }); it('clears approvals and denies when an approval fails', async () => { const clearCloseApprovals = vi.fn(); diff --git a/src/main/lifecycle-flags.ts b/src/main/lifecycle-flags.ts index 0fabc35..b9b42c7 100644 --- a/src/main/lifecycle-flags.ts +++ b/src/main/lifecycle-flags.ts @@ -35,13 +35,22 @@ export function createQuitApprovalController({ if (pending) return pending; pending = (async () => { + let supersededQuitApproved = false; try { for (;;) { await waitForCloseTransaction(); + // Do not insert an await here: a shutdown after the wait must be + // observed before selecting the approval reason for this pass. const reason: QuitApprovalReason = shutdownLatched ? 'shutdown' : 'quit'; const approved = await approveAllForQuit(reason); - if (shutdownLatched && reason !== 'shutdown') continue; - if (reason === 'shutdown' && !approved) shutdownLatched = false; + if (shutdownLatched && reason !== 'shutdown') { + supersededQuitApproved ||= approved; + continue; + } + if (reason === 'shutdown' && !approved) { + shutdownLatched = false; + if (supersededQuitApproved) return true; + } return approved; } } catch { From c8117c19e471522e82785919f4913a8b14bddc70 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:49:31 +0900 Subject: [PATCH 3/9] chore: unexport internal ShutdownSessionSnapshot type (#73) Keep the shutdown persist snapshot shape local to api-types so knip stays clean; only the Api surface needs to re-export consumer-facing types. --- src/renderer/api-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/api-types.ts b/src/renderer/api-types.ts index bee5b30..4948e8f 100644 --- a/src/renderer/api-types.ts +++ b/src/renderer/api-types.ts @@ -59,7 +59,7 @@ export type ShutdownPersistRequest = { revision: number; }; -export type ShutdownSessionSnapshot = { +type ShutdownSessionSnapshot = { savedAt: number; path: string | null; title: string | null; From 5624c3a6df4106b3a1d5ef3f2965170b8c7f6225 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:43:38 +0900 Subject: [PATCH 4/9] fix(close): approve empty shutdown so power-off is not stranded (#73) CloseCoordinator treated empty target lists as denied. On macOS the powerMonitor handler has already preventDefault()'d, so a zero-window or all-unready-blank shutdown left the app alive and blocked the OS. Empty shutdown now commits the empty session (cleanExit:false + marker) and returns approved; empty quit/close still deny. Unit coverage for coordinator + approveAllForQuit zero-window / all-blank paths. --- src/__tests__/app-windows-discard.test.ts | 52 +++++++++++++++++++++++ src/__tests__/close-coordinator.test.ts | 29 +++++++++++++ src/main/close-coordinator.ts | 16 +++++++ 3 files changed, 97 insertions(+) diff --git a/src/__tests__/app-windows-discard.test.ts b/src/__tests__/app-windows-discard.test.ts index 436fb9f..d92ae2a 100644 --- a/src/__tests__/app-windows-discard.test.ts +++ b/src/__tests__/app-windows-discard.test.ts @@ -754,5 +754,57 @@ describe('discard close IPC waiters', () => { expect(commitShutdownSession).toHaveBeenCalledOnce(); expect(appWindows.isSessionWriteFenced('window-1')).toBe(false); }); + it('approves shutdown with zero windows so power-off is not stranded', async () => { + const commitShutdownSession = vi.fn(async () => {}); + const { appWindows } = await setup( + () => {}, + async () => { throw new Error('shutdown must not open a dialog'); }, + 0, + () => true, + async () => {}, + async () => {}, + async () => {}, + undefined, + false, + undefined, + undefined, + commitShutdownSession, + ); + + await expect(appWindows.approveAllForQuit('shutdown')).resolves.toBe(true); + expect(commitShutdownSession).toHaveBeenCalledWith([]); + }); + + it('approves shutdown when every window is an unready blank', async () => { + const commitShutdownSession = vi.fn(async () => {}); + const { appWindows, wins, records } = await setup( + () => {}, + async () => { throw new Error('shutdown must not open a dialog'); }, + 2, + () => true, + async () => {}, + async () => {}, + async () => {}, + undefined, + false, + undefined, + undefined, + commitShutdownSession, + ); + for (const record of records) { + record.ready = false; + record.currentPath = null; + record.restoreSnapshot = undefined; + record.lastSnapshot = undefined; + } + + await expect(appWindows.approveAllForQuit('shutdown')).resolves.toBe(true); + expect(commitShutdownSession).toHaveBeenCalledWith([]); + // Unready blanks are force-approved after the empty shutdown commit so + // window close-guards do not re-enter the denied empty-target path. + for (const win of wins) { + await expect(appWindows.approveClose(win as never)).resolves.toBe(true); + } + }); }); }); diff --git a/src/__tests__/close-coordinator.test.ts b/src/__tests__/close-coordinator.test.ts index 1195a55..b39894a 100644 --- a/src/__tests__/close-coordinator.test.ts +++ b/src/__tests__/close-coordinator.test.ts @@ -186,4 +186,33 @@ describe('CloseCoordinator', () => { release(); await close; }); + it('approves an empty shutdown transaction after committing the empty session', async () => { + const coordinator = new CloseCoordinator(); + const commit = vi.fn(async () => true); + + await expect(coordinator.request('shutdown', [], async () => 'allow', commit)) + .resolves.toEqual({ approved: true, intent: 'shutdown' }); + expect(commit).toHaveBeenCalledOnce(); + expect(commit.mock.calls[0][0]).toMatchObject({ intent: 'shutdown', targets: [], discards: [] }); + }); + + it('still denies empty non-shutdown transactions', async () => { + const coordinator = new CloseCoordinator(); + const commit = vi.fn(async () => true); + + await expect(coordinator.request('quit', [], async () => 'allow', commit)) + .resolves.toEqual({ approved: false, intent: 'quit' }); + await expect(coordinator.request('close', [], async () => 'allow', commit)) + .resolves.toEqual({ approved: false, intent: 'close' }); + expect(commit).not.toHaveBeenCalled(); + }); + + it('denies empty shutdown when the empty commit fails', async () => { + const coordinator = new CloseCoordinator(); + const commit = vi.fn(async () => false); + + await expect(coordinator.request('shutdown', [], async () => 'allow', commit)) + .resolves.toEqual({ approved: false, intent: 'shutdown' }); + expect(commit).toHaveBeenCalledOnce(); + }); }); diff --git a/src/main/close-coordinator.ts b/src/main/close-coordinator.ts index a0758c3..4a1fd04 100644 --- a/src/main/close-coordinator.ts +++ b/src/main/close-coordinator.ts @@ -156,6 +156,22 @@ export class CloseCoordinator { const decisions = new Map(); let pending = [...targets]; let approved = false; + // Empty shutdown targets must still approve: macOS powerMonitor has already + // preventDefault()'d, so a denial would strand the app and block power-off. + // Persist the empty shutdown commit (marker + empty windows) then return. + if (pending.length === 0) { + if (intent !== 'shutdown') return { approved: false, intent }; + try { + const committed = await commit({ intent, targets: [], discards: [], context }); + if (committed !== false && !(typeof committed === 'object' && 'retry' in committed)) { + approved = true; + return { approved: true, intent }; + } + return { approved: false, intent }; + } catch { + return { approved: false, intent }; + } + } try { while (pending.length > 0 && Date.now() < context.forwardDeadline) { const epoch = await Promise.all(pending.map(async (target) => ({ From 8fb0bcb1b28ac567b20d72f0740890ce8b680f30 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:54:37 +0900 Subject: [PATCH 5/9] fix(close): persist durable snapshots for unready shutdown targets (#73) Restored or file-backed windows that are still loading (ready=false) cannot complete the renderer lease handshake, so decideShutdown used to cancel after powerMonitor already preventDefault()'d and strand OS power-off. Unready restorable windows now mint a main-owned lease and commit the durable main-side snapshot (restoreSnapshot / lastSnapshot / path-only disk fallback) without querying the renderer. Empty blanks stay excluded as before. --- src/__tests__/app-windows-discard.test.ts | 77 +++++++++++++++++++++++ src/main/app-windows.ts | 47 ++++++++++++-- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/src/__tests__/app-windows-discard.test.ts b/src/__tests__/app-windows-discard.test.ts index d92ae2a..39e4303 100644 --- a/src/__tests__/app-windows-discard.test.ts +++ b/src/__tests__/app-windows-discard.test.ts @@ -806,5 +806,82 @@ describe('discard close IPC waiters', () => { await expect(appWindows.approveClose(win as never)).resolves.toBe(true); } }); + it('approves shutdown for an unready restored window using its durable snapshot', async () => { + const commitShutdownSession = vi.fn(async () => {}); + const sent: string[] = []; + const { appWindows, records } = await setup( + (win, channel) => { sent.push(channel); }, + async () => { throw new Error('shutdown must not open a dialog'); }, + 1, + () => true, + async () => {}, + async () => {}, + async () => {}, + undefined, + false, + undefined, + undefined, + commitShutdownSession, + ); + records[0].ready = false; + records[0].currentPath = '/tmp/restoring.md'; + records[0].restoreSnapshot = { + id: 'window-1', + path: '/tmp/restoring.md', + title: 'restoring.md', + doc: 'durable restored body', + dirty: true, + unifiedChatHistory: [], + }; + records[0].lastSnapshot = undefined; + + await expect(appWindows.approveAllForQuit('shutdown')).resolves.toBe(true); + + expect(commitShutdownSession).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'window-1', + path: '/tmp/restoring.md', + doc: 'durable restored body', + dirty: true, + }), + ]); + // No renderer handshake channels for the unready path. + expect(sent).not.toContain('close:query-state'); + expect(sent).not.toContain('close:shutdown-persist:request'); + expect(sent).not.toContain('close:authorize'); + expect(sent).not.toContain('close:consume'); + }); + + it('approves shutdown for an unready file-backed window without a snapshot', async () => { + const commitShutdownSession = vi.fn(async () => {}); + const { appWindows, records } = await setup( + () => {}, + async () => { throw new Error('shutdown must not open a dialog'); }, + 1, + () => true, + async () => {}, + async () => {}, + async () => {}, + undefined, + false, + undefined, + undefined, + commitShutdownSession, + ); + records[0].ready = false; + records[0].currentPath = '/tmp/opening.md'; + records[0].restoreSnapshot = undefined; + records[0].lastSnapshot = undefined; + + await expect(appWindows.approveAllForQuit('shutdown')).resolves.toBe(true); + expect(commitShutdownSession).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'window-1', + path: '/tmp/opening.md', + doc: '', + dirty: false, + }), + ]); + }); }); }); diff --git a/src/main/app-windows.ts b/src/main/app-windows.ts index 1118cbc..e0c18e2 100644 --- a/src/main/app-windows.ts +++ b/src/main/app-windows.ts @@ -120,6 +120,9 @@ export function createAppWindows({ const pendingDiscardRollback = new Map void }>(); const pendingConsume = new Map void }>(); const closeLeases = new Map(); + // Leases minted by main for unready restorable windows during shutdown: no + // renderer handshake is possible, so authorize/consume short-circuit true. + const mainOwnedShutdownLeases = new Set(); const pendingShutdownPersist = new Map => { if (!activeLease(win, leaseId)) return Promise.resolve(false); + if (leaseId && mainOwnedShutdownLeases.has(leaseId)) return Promise.resolve(true); return new Promise((resolve) => { const timer = setTimeout(() => { pendingAuthorize.delete(leaseId!); @@ -363,6 +367,7 @@ export function createAppWindows({ }; const consumeRendererClose = (win: BrowserWindow, leaseId: string | undefined): Promise => { if (!activeLease(win, leaseId)) return Promise.resolve(false); + if (leaseId && mainOwnedShutdownLeases.has(leaseId)) return Promise.resolve(true); return new Promise((resolve) => { const timer = setTimeout(() => { pendingConsume.delete(leaseId!); @@ -526,10 +531,41 @@ export function createAppWindows({ }); }; const shutdownSnapshots = new Map(); + const durableSnapshotForShutdown = (rec: WindowRecord): SessionWindowSnapshot | null => { + if (rec.restoreSnapshot) { + return normalizeWindowSnapshot(rec.windowKey, rec.currentPath ?? rec.restoreSnapshot.path, rec.restoreSnapshot); + } + if (rec.lastSnapshot) { + return normalizeWindowSnapshot(rec.windowKey, rec.currentPath ?? rec.lastSnapshot.path, rec.lastSnapshot); + } + if (rec.currentPath != null) { + // File-backed window still loading: disk is source of truth. + return { id: rec.windowKey, path: rec.currentPath, title: null, doc: '', dirty: false }; + } + return null; + }; + const mintMainOwnedShutdownLease = (windowId: number): string => { + const id = `main-owned-shutdown:${windowId}:${Date.now()}:${Math.random().toString(36).slice(2)}`; + closeLeases.set(windowId, { id, invalidated: false }); + mainOwnedShutdownLeases.add(id); + return id; + }; const decideShutdown = async (target: CloseTarget, context: CloseAttemptContext): Promise => { const win = BrowserWindow.fromId(target.windowId); if (!win || win.isDestroyed()) return 'allow'; + const rec = registry.get(target.windowId); + // Restored / file-backed windows that are still loading cannot complete the + // renderer lease handshake. Use the durable main-side snapshot instead so + // powerMonitor's preventDefault does not strand the OS power-off. + if (rec && !rec.ready) { + const durable = durableSnapshotForShutdown(rec); + if (!durable) return 'cancel'; + shutdownSnapshots.set(target.windowKey, durable); + mintMainOwnedShutdownLease(target.windowId); + return 'allow'; + } + let state: (CloseGuardState & { leaseId: string | null }) | null = null; return runDecideCloseLoop({ context, @@ -549,9 +585,9 @@ export function createAppWindows({ ) { return 'cancel'; } - const rec = registry.get(target.windowId); - if (!rec) return 'cancel'; - shutdownSnapshots.set(target.windowKey, normalizeWindowSnapshot(target.windowKey, rec.currentPath, result.snapshot)); + const live = registry.get(target.windowId); + if (!live) return 'cancel'; + shutdownSnapshots.set(target.windowKey, normalizeWindowSnapshot(target.windowKey, live.currentPath, result.snapshot)); return 'allow'; }, authorize: () => authorizeRendererClose(win, leaseIdFor(win.id)), @@ -704,7 +740,10 @@ export function createAppWindows({ } return result.approved && result.intent === intent; } finally { - if (intent === 'shutdown') shutdownSnapshots.clear(); + if (intent === 'shutdown') { + shutdownSnapshots.clear(); + mainOwnedShutdownLeases.clear(); + } } }; From 367c8e83e0a75fe35cd5e9e50f29d0bb6693019a Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:27:18 +0900 Subject: [PATCH 6/9] fix(close): preserve recovery and path-only snapshots on shutdown (#73) Ready crash-recovery windows can still show a blank renderer while restoreSnapshot holds the only recovered draft. Prefer that durable snapshot over an empty live shutdown persist so markShutdownRestoreQueued does not drop it. Path-only file-backed snapshots (doc empty) are now restorable so an unready loading window reopens after power-off instead of being filtered out of the shutdown session. --- src/__tests__/app-windows-discard.test.ts | 48 +++++++++++++++++++++++ src/__tests__/session-schema.test.ts | 3 +- src/__tests__/session-store-queue.test.ts | 14 +++++++ src/main/app-windows.ts | 16 +++++++- src/main/session-schema.ts | 8 +++- 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/__tests__/app-windows-discard.test.ts b/src/__tests__/app-windows-discard.test.ts index 39e4303..9f97324 100644 --- a/src/__tests__/app-windows-discard.test.ts +++ b/src/__tests__/app-windows-discard.test.ts @@ -883,5 +883,53 @@ describe('discard close IPC waiters', () => { }), ]); }); + it('preserves unanswered crash-recovery snapshot over a blank ready renderer', async () => { + const commitShutdownSession = vi.fn(async () => {}); + const { appWindows, records } = await setup((win, channel, payload) => { + if (channel === 'close:shutdown-persist:request') { + electron.emitIpc('close:shutdown-persist:result', win, { + id: payload.id, + ok: true, + fileSaved: false, + revision: payload.revision, + // Ready renderer is still the blank shell behind an unanswered banner. + snapshot: { doc: '', dirty: false, path: null }, + }); + } + if (channel === 'close:consume') { + electron.emitIpc('close:consume-result', win, { requestId: payload.requestId, consumed: true }); + } + }, async () => { + throw new Error('shutdown must not open a dialog'); + }, 1, () => true, async () => {}, async () => {}, async () => {}, (win, payload) => { + electron.emitIpc('close:state', win, { + ...payload, + dirty: false, + hasPath: false, + docEmpty: true, + revision: 0, + locale: 'en', + }); + }, false, undefined, undefined, commitShutdownSession); + records[0].ready = true; + records[0].currentPath = null; + records[0].restoreSnapshot = { + id: 'window-1', + path: null, + title: null, + doc: 'recovered unsaved draft', + dirty: true, + unifiedChatHistory: [], + }; + + await expect(appWindows.approveAllForQuit('shutdown')).resolves.toBe(true); + expect(commitShutdownSession).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'window-1', + doc: 'recovered unsaved draft', + dirty: true, + }), + ]); + }); }); }); diff --git a/src/__tests__/session-schema.test.ts b/src/__tests__/session-schema.test.ts index 47522f2..0cfdd01 100644 --- a/src/__tests__/session-schema.test.ts +++ b/src/__tests__/session-schema.test.ts @@ -248,9 +248,10 @@ describe('input immutability', () => { }); }); describe('shutdown session schema additions', () => { - it('keeps document and chat-only windows while excluding empty windows', () => { + it('keeps document, chat-only, and path-backed windows while excluding empty untitled', () => { expect(isRestorableSessionWindow(win('doc', { doc: 'text' }))).toBe(true); expect(isRestorableSessionWindow(win('chat', { unifiedChatHistory: [{ type: 'separator', label: 'x' }] }))).toBe(true); + expect(isRestorableSessionWindow(win('path-only', { path: '/tmp/opening.md', doc: '' }))).toBe(true); expect(isRestorableSessionWindow(win('empty'))).toBe(false); }); diff --git a/src/__tests__/session-store-queue.test.ts b/src/__tests__/session-store-queue.test.ts index 988491b..787d193 100644 --- a/src/__tests__/session-store-queue.test.ts +++ b/src/__tests__/session-store-queue.test.ts @@ -247,6 +247,20 @@ describe('session shutdown marker store', () => { await expect(getSessionAggregate()).resolves.toMatchObject({ windows: [] }); }); + it('keeps path-only shutdown snapshots so file-backed windows reopen', async () => { + sessionStoreHarness.disk = { version: 2, windows: [] }; + sessionStoreHarness.persists = 0; + sessionStoreHarness.failPersist = false; + vi.resetModules(); + const { markShutdownRestoreQueued, getSessionAggregate } = await import('../main/session-store'); + + await markShutdownRestoreQueued([{ id: 'path-only', path: '/tmp/opening.md', title: null, doc: '', dirty: false }]); + + await expect(getSessionAggregate()).resolves.toMatchObject({ + restoreReason: 'shutdown', + windows: [{ id: 'path-only', path: '/tmp/opening.md', doc: '' }], + }); + }); it('clears the shutdown marker for a clean quit', async () => { sessionStoreHarness.disk = { version: 2, diff --git a/src/main/app-windows.ts b/src/main/app-windows.ts index e0c18e2..4734994 100644 --- a/src/main/app-windows.ts +++ b/src/main/app-windows.ts @@ -9,7 +9,7 @@ import { isTrustedAppUrl, SECURITY_REASON } from './security'; import { isAllowedExternalUrl } from './safe-external'; import { ProjectWizardRootStore } from './project-wizard/access'; import { sendWhenReady, type OutboundSink, type WindowRecord, type WindowRegistry } from './window-registry'; -import { normalizeWindowSnapshot, type SessionWindowSnapshot } from './session-schema'; +import { isRestorableSessionWindow, normalizeWindowSnapshot, type SessionWindowSnapshot } from './session-schema'; import { markShutdownRestoreQueued } from './session-store'; import { queueOrOpenFile, shouldPublishLaunchWindow, type CreateWindowOptions } from './lifecycle-flags'; import { logWarn } from './app-log'; @@ -587,7 +587,19 @@ export function createAppWindows({ } const live = registry.get(target.windowId); if (!live) return 'cancel'; - shutdownSnapshots.set(target.windowKey, normalizeWindowSnapshot(target.windowKey, live.currentPath, result.snapshot)); + const fromRenderer = normalizeWindowSnapshot(target.windowKey, live.currentPath, result.snapshot); + // Crash-recovery windows keep restoreSnapshot until the user accepts or + // declines the banner. A ready renderer can still be blank; do not clobber + // the durable recovered draft with that empty live snapshot. + const pendingRecovery = live.restoreSnapshot + ? normalizeWindowSnapshot(target.windowKey, live.currentPath ?? live.restoreSnapshot.path, live.restoreSnapshot) + : null; + const chosen = pendingRecovery + && isRestorableSessionWindow(pendingRecovery) + && !isRestorableSessionWindow(fromRenderer) + ? pendingRecovery + : fromRenderer; + shutdownSnapshots.set(target.windowKey, chosen); return 'allow'; }, authorize: () => authorizeRendererClose(win, leaseIdFor(win.id)), diff --git a/src/main/session-schema.ts b/src/main/session-schema.ts index b12780e..beac148 100644 --- a/src/main/session-schema.ts +++ b/src/main/session-schema.ts @@ -47,8 +47,12 @@ export type SessionSnapshotV2 = { cleanExit?: boolean; restoreReason?: 'shutdown'; }; -export function isRestorableSessionWindow(snapshot: Pick): boolean { - return (snapshot.doc?.length ?? 0) > 0 || (snapshot.unifiedChatHistory?.length ?? 0) > 0; +export function isRestorableSessionWindow(snapshot: Pick): boolean { + // Path-only snapshots keep file-backed windows restorable when shutdown hits + // before the renderer has content (loading / unanswered recovery banner). + return (snapshot.doc?.length ?? 0) > 0 + || (snapshot.unifiedChatHistory?.length ?? 0) > 0 + || (typeof snapshot.path === 'string' && snapshot.path.length > 0); } export function normalizeWindowSnapshot( From 2a799a6126fa180bb6db3499aa490a8b3247543e Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:35:43 +0900 Subject: [PATCH 7/9] fix(close): recovery prefer-doc, bounded save, path-only reopen (#73) - Prefer unanswered restoreSnapshot whenever the live renderer doc is empty (including path-backed recovery drafts) - Bound renderer shutdown save to 3.5s so slow disk cannot miss main's deadline; always return a post-save snapshot (dirty cleared on success) - Path-only shutdown restores reopen via openFileInCurrent --- src/__tests__/app-windows-discard.test.ts | 48 +++++++++++++++++++++++ src/main/app-windows.ts | 13 +++--- src/renderer/main.ts | 10 ++++- src/renderer/session-snapshot.test.ts | 34 ++++++++++++++++ src/renderer/session-snapshot.ts | 14 ++++++- 5 files changed, 110 insertions(+), 9 deletions(-) diff --git a/src/__tests__/app-windows-discard.test.ts b/src/__tests__/app-windows-discard.test.ts index 9f97324..27c6c16 100644 --- a/src/__tests__/app-windows-discard.test.ts +++ b/src/__tests__/app-windows-discard.test.ts @@ -931,5 +931,53 @@ describe('discard close IPC waiters', () => { }), ]); }); + it('preserves path-backed unanswered recovery over a blank path-only live snapshot', async () => { + const commitShutdownSession = vi.fn(async () => {}); + const { appWindows, records } = await setup((win, channel, payload) => { + if (channel === 'close:shutdown-persist:request') { + electron.emitIpc('close:shutdown-persist:result', win, { + id: payload.id, + ok: true, + fileSaved: false, + revision: payload.revision, + snapshot: { doc: '', dirty: false, path: '/tmp/recovered.md' }, + }); + } + if (channel === 'close:consume') { + electron.emitIpc('close:consume-result', win, { requestId: payload.requestId, consumed: true }); + } + }, async () => { + throw new Error('shutdown must not open a dialog'); + }, 1, () => true, async () => {}, async () => {}, async () => {}, (win, payload) => { + electron.emitIpc('close:state', win, { + ...payload, + dirty: false, + hasPath: true, + docEmpty: true, + revision: 0, + locale: 'en', + }); + }, false, undefined, undefined, commitShutdownSession); + records[0].ready = true; + records[0].currentPath = '/tmp/recovered.md'; + records[0].restoreSnapshot = { + id: 'window-1', + path: '/tmp/recovered.md', + title: 'recovered.md', + doc: 'recovered file-backed draft', + dirty: true, + unifiedChatHistory: [], + }; + + await expect(appWindows.approveAllForQuit('shutdown')).resolves.toBe(true); + expect(commitShutdownSession).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'window-1', + path: '/tmp/recovered.md', + doc: 'recovered file-backed draft', + dirty: true, + }), + ]); + }); }); }); diff --git a/src/main/app-windows.ts b/src/main/app-windows.ts index 4734994..8d72a1a 100644 --- a/src/main/app-windows.ts +++ b/src/main/app-windows.ts @@ -589,14 +589,15 @@ export function createAppWindows({ if (!live) return 'cancel'; const fromRenderer = normalizeWindowSnapshot(target.windowKey, live.currentPath, result.snapshot); // Crash-recovery windows keep restoreSnapshot until the user accepts or - // declines the banner. A ready renderer can still be blank; do not clobber - // the durable recovered draft with that empty live snapshot. + // declines the banner. Prefer it whenever the live renderer still has an + // empty document — even if a file path alone would make the live + // snapshot "restorable". const pendingRecovery = live.restoreSnapshot - ? normalizeWindowSnapshot(target.windowKey, live.currentPath ?? live.restoreSnapshot.path, live.restoreSnapshot) + ? normalizeWindowSnapshot(target.windowKey, live.restoreSnapshot.path ?? live.currentPath, live.restoreSnapshot) : null; - const chosen = pendingRecovery - && isRestorableSessionWindow(pendingRecovery) - && !isRestorableSessionWindow(fromRenderer) + const liveDocEmpty = (fromRenderer.doc?.length ?? 0) === 0; + const recoveryHasDoc = (pendingRecovery?.doc?.length ?? 0) > 0; + const chosen = pendingRecovery && recoveryHasDoc && liveDocEmpty ? pendingRecovery : fromRenderer; shutdownSnapshots.set(target.windowKey, chosen); diff --git a/src/renderer/main.ts b/src/renderer/main.ts index c0be42f..06ec830 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -254,11 +254,17 @@ window.api.onShutdownPersistRequest(({ id, leaseId, revision }) => { return; } + // Bound the file-save wait so a slow disk cannot exceed main's shutdown + // deadline and strand power-off. Always return a snapshot either way. let fileSaved = false; let error: string | undefined; if (ctx.currentPath !== null && ctx.dirty) { try { - const committedRevision = await docLifecycle.save(); + const saveBudgetMs = 3_500; + const committedRevision = await Promise.race([ + docLifecycle.save(), + new Promise((resolve) => setTimeout(() => resolve(null), saveBudgetMs)), + ]); fileSaved = committedRevision !== null && committedRevision >= revision; if (!fileSaved) error = 'save-failed'; } catch { @@ -266,6 +272,8 @@ window.api.onShutdownPersistRequest(({ id, leaseId, revision }) => { } } + // Build after the save attempt so a successful write clears dirty; a timed- + // out/failed save still returns the latest editor content as fallback. window.api.sendShutdownPersistResult({ id, ok: true, diff --git a/src/renderer/session-snapshot.test.ts b/src/renderer/session-snapshot.test.ts index 20b4681..450f002 100644 --- a/src/renderer/session-snapshot.test.ts +++ b/src/renderer/session-snapshot.test.ts @@ -126,6 +126,40 @@ describe('session restore mode', () => { dirty: false, }); }); + it('reopens path-only shutdown restores via openFileInCurrent', async () => { + const openFileInCurrent = vi.fn(async () => ({ opened: true })); + const replaceDocument = vi.fn(); + const ctx = { + currentPath: null, + pendingTitle: null, + previewMode: 'split', + dirty: false, + editor: { getDoc: () => '' }, + setStatus: vi.fn(), + } as unknown as AppContext; + (window as any).api = { + sessionGet: vi.fn(async () => ({ + snapshot: { path: '/tmp/opening.md', doc: '', title: null, dirty: false }, + restoreReason: 'shutdown', + })), + sessionWrite: vi.fn(async () => {}), + sessionClear: vi.fn(async () => {}), + openFileInCurrent, + }; + initSessionSnapshot(ctx, { + prefs: { theme: 'system', fontSize: 'md' }, + unifiedChat: { restore: vi.fn() } as never, + getUnifiedChatHistory: () => [], + setUnifiedChatHistory: vi.fn(), + setUnifiedChatOpen: vi.fn(), + applyPreviewMode: vi.fn(), + replaceDocument, + }); + await Promise.resolve(); + await Promise.resolve(); + expect(openFileInCurrent).toHaveBeenCalledWith('/tmp/opening.md'); + expect(replaceDocument).not.toHaveBeenCalled(); + }); }); describe('buildSessionSnapshot', () => { diff --git a/src/renderer/session-snapshot.ts b/src/renderer/session-snapshot.ts index 702df8e..9a0771d 100644 --- a/src/renderer/session-snapshot.ts +++ b/src/renderer/session-snapshot.ts @@ -86,11 +86,21 @@ export function initSessionSnapshot(ctx: AppContext, deps: SessionSnapshotDeps) void (async () => { const res = await window.api.sessionGet(); const snap = res?.snapshot; - if (!snap || ((snap.doc?.length ?? 0) === 0 && (snap.unifiedChatHistory?.length ?? 0) === 0)) return; + if (!snap) return; + const hasDoc = (snap.doc?.length ?? 0) > 0 || (snap.unifiedChatHistory?.length ?? 0) > 0; + const path = typeof snap.path === 'string' && snap.path.length > 0 ? snap.path : null; + // Path-only shutdown snapshots must still reopen the file; empty untitled + // windows remain excluded. + if (!hasDoc && !path) return; if (res.restoreReason === 'shutdown') { - applySessionSnapshot(snap); + if (hasDoc) { + applySessionSnapshot(snap); + } else if (path) { + void window.api.openFileInCurrent(path); + } return; } + if (!hasDoc) return; setTimeout(() => showRestoreBanner(snap), 400); })(); From ce5de2067ad62538476c81138d47187b12e09b5d Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:48:21 +0900 Subject: [PATCH 8/9] fix(close): chat recovery, clear declined snapshot, clean reopen (#73) - Prefer pending recovery when it has doc or chat and live has neither - session:clear clears in-memory restoreSnapshot/restoreReason after decline - Clean path-backed shutdown restores reopen from disk (dirty still applies snapshot) --- src/__tests__/app-windows-discard.test.ts | 46 +++++++++++++++++++++++ src/__tests__/session-ipc-fence.test.ts | 28 ++++++++++++++ src/main/app-windows.ts | 12 +++--- src/main/ipc/session-ipc.ts | 4 ++ src/renderer/session-snapshot.test.ts | 36 +++++++++++++++++- src/renderer/session-snapshot.ts | 6 ++- 6 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/__tests__/app-windows-discard.test.ts b/src/__tests__/app-windows-discard.test.ts index 27c6c16..903bc21 100644 --- a/src/__tests__/app-windows-discard.test.ts +++ b/src/__tests__/app-windows-discard.test.ts @@ -979,5 +979,51 @@ describe('discard close IPC waiters', () => { }), ]); }); + it('preserves chat-only unanswered recovery over a blank live snapshot', async () => { + const commitShutdownSession = vi.fn(async () => {}); + const { appWindows, records } = await setup((win, channel, payload) => { + if (channel === 'close:shutdown-persist:request') { + electron.emitIpc('close:shutdown-persist:result', win, { + id: payload.id, + ok: true, + fileSaved: false, + revision: payload.revision, + snapshot: { doc: '', dirty: false, path: null, unifiedChatHistory: [] }, + }); + } + if (channel === 'close:consume') { + electron.emitIpc('close:consume-result', win, { requestId: payload.requestId, consumed: true }); + } + }, async () => { + throw new Error('shutdown must not open a dialog'); + }, 1, () => true, async () => {}, async () => {}, async () => {}, (win, payload) => { + electron.emitIpc('close:state', win, { + ...payload, + dirty: false, + hasPath: false, + docEmpty: true, + revision: 0, + locale: 'en', + }); + }, false, undefined, undefined, commitShutdownSession); + records[0].ready = true; + records[0].currentPath = null; + records[0].restoreSnapshot = { + id: 'window-1', + path: null, + title: null, + doc: '', + dirty: false, + unifiedChatHistory: [{ type: 'separator', label: 'recovered chat' }], + }; + + await expect(appWindows.approveAllForQuit('shutdown')).resolves.toBe(true); + expect(commitShutdownSession).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'window-1', + unifiedChatHistory: [{ type: 'separator', label: 'recovered chat' }], + }), + ]); + }); }); }); diff --git a/src/__tests__/session-ipc-fence.test.ts b/src/__tests__/session-ipc-fence.test.ts index 85e80e7..8eabf6b 100644 --- a/src/__tests__/session-ipc-fence.test.ts +++ b/src/__tests__/session-ipc-fence.test.ts @@ -299,4 +299,32 @@ describe('session IPC discard fence', () => { expect(syncSnapshotPath).toHaveBeenCalledWith(record.windowId, expect.objectContaining({ path: null })); expect(claimPath).not.toHaveBeenCalled(); }); + it('clears in-memory restoreSnapshot when the user declines recovery', async () => { + const pending = { id: 'declined-recovery', path: null, title: null, doc: 'discarded draft' }; + const record: WindowRecord = { + windowId: 1, + webContentsId: 1001, + windowKey: 'declined-recovery', + lastFocusedAt: 0, + ready: true, + pendingOutbound: [], + restoreSnapshot: pending, + restoreReason: undefined, + }; + sessionStore.mutateSessionAggregate.mockImplementation(async (mutator: (state: SessionSnapshotV2) => SessionSnapshotV2) => + mutator({ version: 2, windows: [pending] })); + const { registerSessionIpc } = await import('../main/ipc/session-ipc'); + registerSessionIpc({ + registry: { getByWebContents: (id: number) => id === record.webContentsId ? record : null } as never, + sinkFor: () => (() => {}), + }); + const clear = electron.handler('session:clear'); + await clear!({ + sender: { id: record.webContentsId }, + senderFrame: { parent: null, url: 'file:///app/index.html' }, + }); + expect(record.restoreSnapshot).toBeUndefined(); + expect(record.restoreReason).toBeUndefined(); + }); + }); diff --git a/src/main/app-windows.ts b/src/main/app-windows.ts index 8d72a1a..5ebfe6f 100644 --- a/src/main/app-windows.ts +++ b/src/main/app-windows.ts @@ -589,15 +589,15 @@ export function createAppWindows({ if (!live) return 'cancel'; const fromRenderer = normalizeWindowSnapshot(target.windowKey, live.currentPath, result.snapshot); // Crash-recovery windows keep restoreSnapshot until the user accepts or - // declines the banner. Prefer it whenever the live renderer still has an - // empty document — even if a file path alone would make the live - // snapshot "restorable". + // declines the banner. Prefer recovery whenever it still has document or + // chat content and the live renderer has neither (path alone does not + // count as live content here). const pendingRecovery = live.restoreSnapshot ? normalizeWindowSnapshot(target.windowKey, live.restoreSnapshot.path ?? live.currentPath, live.restoreSnapshot) : null; - const liveDocEmpty = (fromRenderer.doc?.length ?? 0) === 0; - const recoveryHasDoc = (pendingRecovery?.doc?.length ?? 0) > 0; - const chosen = pendingRecovery && recoveryHasDoc && liveDocEmpty + const hasContent = (snap: SessionWindowSnapshot | null | undefined) => + (snap?.doc?.length ?? 0) > 0 || (snap?.unifiedChatHistory?.length ?? 0) > 0; + const chosen = pendingRecovery && hasContent(pendingRecovery) && !hasContent(fromRenderer) ? pendingRecovery : fromRenderer; shutdownSnapshots.set(target.windowKey, chosen); diff --git a/src/main/ipc/session-ipc.ts b/src/main/ipc/session-ipc.ts index f2bb1a3..e2759fa 100644 --- a/src/main/ipc/session-ipc.ts +++ b/src/main/ipc/session-ipc.ts @@ -31,6 +31,10 @@ export function registerSessionIpc({ registry, sinkFor, isSessionWriteFenced = ( }); handleTrusted('session:clear', async (event) => { const rec = registry.getByWebContents(event.sender.id); if (!rec || isSessionWriteFenced(rec.windowKey)) return; + // User declined the recovery banner — drop the in-memory pending snapshot so + // a later shutdown cannot resurrect content they explicitly discarded. + rec.restoreSnapshot = undefined; + rec.restoreReason = undefined; await mutateSessionAggregate((cur) => isCurrentWritableRecord(event.sender.id, rec) ? removeWindowSnapshot(cur, rec.windowKey) : cur); diff --git a/src/renderer/session-snapshot.test.ts b/src/renderer/session-snapshot.test.ts index 450f002..6aa1b21 100644 --- a/src/renderer/session-snapshot.test.ts +++ b/src/renderer/session-snapshot.test.ts @@ -62,7 +62,7 @@ function createRestoreHarness(response: unknown) { } describe('session restore mode', () => { - it('applies shutdown restores immediately without creating a banner', async () => { + it('applies dirty shutdown restores immediately without creating a banner', async () => { vi.useFakeTimers(); const restore = createRestoreHarness({ snapshot: { @@ -160,6 +160,40 @@ describe('session restore mode', () => { expect(openFileInCurrent).toHaveBeenCalledWith('/tmp/opening.md'); expect(replaceDocument).not.toHaveBeenCalled(); }); + it('reopens clean path-backed shutdown restores from disk even when doc is nonempty', async () => { + const openFileInCurrent = vi.fn(async () => ({ opened: true })); + const replaceDocument = vi.fn(); + const ctx = { + currentPath: null, + pendingTitle: null, + previewMode: 'split', + dirty: false, + editor: { getDoc: () => '' }, + setStatus: vi.fn(), + } as unknown as AppContext; + (window as any).api = { + sessionGet: vi.fn(async () => ({ + snapshot: { path: '/tmp/clean.md', doc: 'stale editor copy', title: 'clean.md', dirty: false }, + restoreReason: 'shutdown', + })), + sessionWrite: vi.fn(async () => {}), + sessionClear: vi.fn(async () => {}), + openFileInCurrent, + }; + initSessionSnapshot(ctx, { + prefs: { theme: 'system', fontSize: 'md' }, + unifiedChat: { restore: vi.fn() } as never, + getUnifiedChatHistory: () => [], + setUnifiedChatHistory: vi.fn(), + setUnifiedChatOpen: vi.fn(), + applyPreviewMode: vi.fn(), + replaceDocument, + }); + await Promise.resolve(); + await Promise.resolve(); + expect(openFileInCurrent).toHaveBeenCalledWith('/tmp/clean.md'); + expect(replaceDocument).not.toHaveBeenCalled(); + }); }); describe('buildSessionSnapshot', () => { diff --git a/src/renderer/session-snapshot.ts b/src/renderer/session-snapshot.ts index 9a0771d..ffb9be8 100644 --- a/src/renderer/session-snapshot.ts +++ b/src/renderer/session-snapshot.ts @@ -93,7 +93,11 @@ export function initSessionSnapshot(ctx: AppContext, deps: SessionSnapshotDeps) // windows remain excluded. if (!hasDoc && !path) return; if (res.restoreReason === 'shutdown') { - if (hasDoc) { + // Clean path-backed windows reopen from disk so cloud/git updates win. + // Dirty (or failed-save) recovery still applies the in-memory snapshot. + if (path && snap.dirty !== true) { + void window.api.openFileInCurrent(path); + } else if (hasDoc) { applySessionSnapshot(snap); } else if (path) { void window.api.openFileInCurrent(path); From 8185337f131edbd1aec3e8f7195c3e0780bb2d50 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:55:39 +0900 Subject: [PATCH 9/9] fix(session): restore chat on clean reopen and clear applied recovery (#73) - Clean path-backed shutdown restore reopens disk file and still applies chat/view metadata from the snapshot - Path-only ordinary crash restores reopen the file (no blank untitled) - Live session:write clears pending restoreSnapshot after apply --- src/__tests__/session-ipc-fence.test.ts | 31 +++++++++ src/main/ipc/session-ipc.ts | 4 ++ src/renderer/session-snapshot.test.ts | 84 +++++++++++++++++++++++++ src/renderer/session-snapshot.ts | 27 +++++--- 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/src/__tests__/session-ipc-fence.test.ts b/src/__tests__/session-ipc-fence.test.ts index 8eabf6b..02f29a6 100644 --- a/src/__tests__/session-ipc-fence.test.ts +++ b/src/__tests__/session-ipc-fence.test.ts @@ -327,4 +327,35 @@ describe('session IPC discard fence', () => { expect(record.restoreReason).toBeUndefined(); }); + it('clears restoreSnapshot after a live session write', async () => { + const record: WindowRecord = { + windowId: 1, + webContentsId: 1001, + windowKey: 'applied-recovery', + lastFocusedAt: 0, + ready: true, + pendingOutbound: [], + currentPath: null, + restoreSnapshot: { id: 'applied-recovery', path: null, title: null, doc: 'applied draft' }, + restoreReason: 'shutdown', + }; + sessionStore.mutateSessionAggregate.mockImplementation(async (mutator: (state: SessionSnapshotV2) => SessionSnapshotV2) => + mutator({ version: 2, windows: [] })); + const { registerSessionIpc } = await import('../main/ipc/session-ipc'); + registerSessionIpc({ + registry: { + getByWebContents: (id: number) => id === record.webContentsId ? record : null, + syncSnapshotPath: vi.fn(), + } as never, + sinkFor: () => (() => {}), + }); + const write = electron.handler('session:write'); + await write!({ + sender: { id: record.webContentsId }, + senderFrame: { parent: null, url: 'file:///app/index.html' }, + }, { doc: 'live', path: null }); + expect(record.restoreSnapshot).toBeUndefined(); + expect(record.restoreReason).toBeUndefined(); + }); + }); diff --git a/src/main/ipc/session-ipc.ts b/src/main/ipc/session-ipc.ts index e2759fa..a4f9403 100644 --- a/src/main/ipc/session-ipc.ts +++ b/src/main/ipc/session-ipc.ts @@ -23,6 +23,10 @@ export function registerSessionIpc({ registry, sinkFor, isSessionWriteFenced = ( if (!isCurrentWritableRecord(event.sender.id, rec)) return cur; const win = normalizeWindowSnapshot(rec.windowKey, rec.currentPath, snap); rec.lastSnapshot = win; + // Live write means the user owns the document now — drop any pending + // recovery snapshot so later empty shutdown cannot resurrect it. + rec.restoreSnapshot = undefined; + rec.restoreReason = undefined; registry.syncSnapshotPath(rec.windowId, win); written = true; return { ...upsertWindowSnapshot(cur, win), cleanExit: false }; diff --git a/src/renderer/session-snapshot.test.ts b/src/renderer/session-snapshot.test.ts index 6aa1b21..4dd399b 100644 --- a/src/renderer/session-snapshot.test.ts +++ b/src/renderer/session-snapshot.test.ts @@ -194,6 +194,90 @@ describe('session restore mode', () => { expect(openFileInCurrent).toHaveBeenCalledWith('/tmp/clean.md'); expect(replaceDocument).not.toHaveBeenCalled(); }); + it('restores chat metadata when reopening a clean path-backed shutdown snapshot', async () => { + const openFileInCurrent = vi.fn(async () => ({ opened: true })); + const setUnifiedChatHistory = vi.fn(); + const unifiedRestore = vi.fn(); + const setUnifiedChatOpen = vi.fn(); + const applyPreviewMode = vi.fn(); + const replaceDocument = vi.fn(); + const setStatus = vi.fn(); + const ctx = { + currentPath: null, + pendingTitle: null, + previewMode: 'split', + dirty: false, + editor: { getDoc: () => '' }, + setStatus, + } as unknown as AppContext; + (window as any).api = { + sessionGet: vi.fn(async () => ({ + snapshot: { + path: '/tmp/clean-chat.md', + doc: 'stale', + title: null, + dirty: false, + view: 'preview-only', + unifiedChatHistory: [{ type: 'separator', label: 'kept chat' }], + }, + restoreReason: 'shutdown', + })), + sessionWrite: vi.fn(async () => {}), + sessionClear: vi.fn(async () => {}), + openFileInCurrent, + }; + initSessionSnapshot(ctx, { + prefs: { theme: 'system', fontSize: 'md' }, + unifiedChat: { restore: unifiedRestore } as never, + getUnifiedChatHistory: () => [{ type: 'separator', label: 'kept chat' }], + setUnifiedChatHistory, + setUnifiedChatOpen, + applyPreviewMode, + replaceDocument, + }); + await Promise.resolve(); + await Promise.resolve(); + expect(openFileInCurrent).toHaveBeenCalledWith('/tmp/clean-chat.md'); + expect(replaceDocument).not.toHaveBeenCalled(); + expect(setUnifiedChatHistory).toHaveBeenCalled(); + expect(unifiedRestore).toHaveBeenCalled(); + expect(applyPreviewMode).toHaveBeenCalled(); + }); + + it('reopens path-only crash restores without a banner', async () => { + const openFileInCurrent = vi.fn(async () => ({ opened: true })); + const replaceDocument = vi.fn(); + const ctx = { + currentPath: null, + pendingTitle: null, + previewMode: 'split', + dirty: false, + editor: { getDoc: () => '' }, + setStatus: vi.fn(), + } as unknown as AppContext; + (window as any).api = { + sessionGet: vi.fn(async () => ({ + snapshot: { path: '/tmp/crash-path.md', doc: '', title: null, dirty: false }, + })), + sessionWrite: vi.fn(async () => {}), + sessionClear: vi.fn(async () => {}), + openFileInCurrent, + }; + initSessionSnapshot(ctx, { + prefs: { theme: 'system', fontSize: 'md' }, + unifiedChat: { restore: vi.fn() } as never, + getUnifiedChatHistory: () => [], + setUnifiedChatHistory: vi.fn(), + setUnifiedChatOpen: vi.fn(), + applyPreviewMode: vi.fn(), + replaceDocument, + }); + await Promise.resolve(); + await Promise.resolve(); + expect(openFileInCurrent).toHaveBeenCalledWith('/tmp/crash-path.md'); + expect(document.querySelector('.restore-yes')).toBeNull(); + expect(replaceDocument).not.toHaveBeenCalled(); + }); }); describe('buildSessionSnapshot', () => { diff --git a/src/renderer/session-snapshot.ts b/src/renderer/session-snapshot.ts index ffb9be8..6882072 100644 --- a/src/renderer/session-snapshot.ts +++ b/src/renderer/session-snapshot.ts @@ -52,6 +52,13 @@ export function initSessionSnapshot(ctx: AppContext, deps: SessionSnapshotDeps) return true; } + function applySessionMetadata(snap: any) { + if (snap.view) { ctx.previewMode = snap.view as PreviewMode; deps.applyPreviewMode(); } + deps.setUnifiedChatHistory(restoreUnifiedThread(snap)); + deps.unifiedChat.restore(snap); + if (deps.getUnifiedChatHistory().length > 0) deps.setUnifiedChatOpen(true); + } + function applySessionSnapshot(snap: any) { deps.replaceDocument({ doc: snap.doc ?? '', @@ -59,10 +66,7 @@ export function initSessionSnapshot(ctx: AppContext, deps: SessionSnapshotDeps) pendingTitle: typeof snap.title === 'string' ? snap.title : null, dirty: snap.dirty === true, }); - if (snap.view) { ctx.previewMode = snap.view as PreviewMode; deps.applyPreviewMode(); } - deps.setUnifiedChatHistory(restoreUnifiedThread(snap)); - deps.unifiedChat.restore(snap); - if (deps.getUnifiedChatHistory().length > 0) deps.setUnifiedChatOpen(true); + applySessionMetadata(snap); scheduleSessionSnapshot(); ctx.setStatus(t('status.sessionRestored')); } @@ -89,14 +93,17 @@ export function initSessionSnapshot(ctx: AppContext, deps: SessionSnapshotDeps) if (!snap) return; const hasDoc = (snap.doc?.length ?? 0) > 0 || (snap.unifiedChatHistory?.length ?? 0) > 0; const path = typeof snap.path === 'string' && snap.path.length > 0 ? snap.path : null; - // Path-only shutdown snapshots must still reopen the file; empty untitled - // windows remain excluded. + // Path-only snapshots must still reopen the file; empty untitled remain excluded. if (!hasDoc && !path) return; if (res.restoreReason === 'shutdown') { // Clean path-backed windows reopen from disk so cloud/git updates win. - // Dirty (or failed-save) recovery still applies the in-memory snapshot. + // Non-document session state (chat/view) is still restored from the snapshot. + // Dirty (or failed-save) recovery still applies the full in-memory snapshot. if (path && snap.dirty !== true) { void window.api.openFileInCurrent(path); + applySessionMetadata(snap); + scheduleSessionSnapshot(); + ctx.setStatus(t('status.sessionRestored')); } else if (hasDoc) { applySessionSnapshot(snap); } else if (path) { @@ -104,7 +111,11 @@ export function initSessionSnapshot(ctx: AppContext, deps: SessionSnapshotDeps) } return; } - if (!hasDoc) return; + // Ordinary crash recovery: path-only still reopens the file; content uses banner. + if (!hasDoc) { + if (path) void window.api.openFileInCurrent(path); + return; + } setTimeout(() => showRestoreBanner(snap), 400); })();