diff --git a/src/tui/README.md b/src/tui/README.md index b78b74980..6b353f06f 100644 --- a/src/tui/README.md +++ b/src/tui/README.md @@ -55,9 +55,10 @@ the e2e harness drives a real run, and `tuiProgramFlow(programId)` returns the program's flow steps, for walking a flow in tests. `runTui` and `runTuiTool` resolve an exit code and never exit: the CLI applies -it. A screen ends the run with `store.requestExit(code)`: `runTui` resolves it -as is, and `runTuiTool` resolves it once the tool's analytics events flush. A -decided failure ends it through `abortOnScreens`, or through `wizardAbort` with +it. A screen ends the run with `store.requestExit(code)`: `runTui` unmounts, +reports the run's end to analytics within two seconds and resolves it, and +`runTuiTool` resolves it once the tool's analytics events flush. A decided +failure ends it through `abortOnScreens`, or through `wizardAbort` with `printAbortOutro` before the TUI mounts; Ctrl+C or a signal ends it 130 or 143. A flow id names a program or a tool. The core finds its flow, screens and deck diff --git a/src/tui/__tests__/run.test.ts b/src/tui/__tests__/run.test.ts index f841db9ca..dde4962e0 100644 --- a/src/tui/__tests__/run.test.ts +++ b/src/tui/__tests__/run.test.ts @@ -195,7 +195,7 @@ it.each([ }, ); -it("resolves a screen's exit request with its code and starts no end shutdown", async () => { +it("resolves a screen's exit request with its code and starts no stream shutdown", async () => { const { store, unmount } = mountedStore(); vi.mocked(runProgram).mockResolvedValue({ outcome: RunOutcome.Success, @@ -210,7 +210,49 @@ it("resolves a screen's exit request with its code and starts no end shutdown", await expect(exited).resolves.toBe(0); await flush(); expect(streamShutdown).not.toHaveBeenCalled(); - expect(unmount).not.toHaveBeenCalled(); + expect(unmount).toHaveBeenCalledOnce(); +}); + +it.each([ + [0, 'cancelled'], + [1, 'error'], +] as const)( + 'ends a screen exit request %i before the run with a %s shutdown, delivered before the exit', + async (code, status) => { + const { store, unmount } = mountedStore(); + // The intro never settles: the user leaves from its menu. + vi.spyOn(store, 'getGate').mockReturnValue(new Promise(() => undefined)); + let delivered = false; + vi.mocked(analytics.flush).mockImplementation(async () => { + // The screen is gone before the wait, so it takes no more input. + expect(unmount).toHaveBeenCalledOnce(); + await flush(); + delivered = true; + }); + const exited = runTui(posthogIntegration, launch('/tmp/intro-exit-test')); + await vi.waitFor(() => expect(store.getGate).toHaveBeenCalled()); + store.requestExit(code); + await expect(exited).resolves.toBe(code); + expect(delivered).toBe(true); + expect(analytics.shutdown).toHaveBeenCalledExactlyOnceWith(status); + expect(runProgram).not.toHaveBeenCalled(); + }, +); + +it('exits on a screen exit request within the report budget when analytics hang', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + try { + const { store } = mountedStore(); + vi.spyOn(store, 'getGate').mockReturnValue(new Promise(() => undefined)); + vi.mocked(analytics.shutdown).mockReturnValue(new Promise(() => undefined)); + const exited = runTui(posthogIntegration, launch('/tmp/hung-exit-test')); + await vi.waitFor(() => expect(store.getGate).toHaveBeenCalled()); + store.requestExit(0); + await vi.advanceTimersByTimeAsync(2000); + await expect(exited).resolves.toBe(0); + } finally { + vi.useRealTimers(); + } }); it('resolves an abort with its code once its outro is dismissed', async () => { diff --git a/src/tui/run.ts b/src/tui/run.ts index 79289770f..2221c36c7 100644 --- a/src/tui/run.ts +++ b/src/tui/run.ts @@ -46,6 +46,9 @@ import type { WizardStore } from './store.js'; import type { TuiLaunch } from './launch.js'; import { tuiWorkflow } from './workflow.js'; +/** How long a screen's exit request waits for analytics before it exits anyway. */ +const EXIT_REPORT_BUDGET_MS = 2000; + /** * Run `config` in the TUI. Resolves with the exit code: the run's, a screen's * exit request, a decided failure's through `wizardAbort`, or 130 or 143 on a signal. @@ -118,9 +121,25 @@ export async function runTui( tui = startTUI(VERSION, config.id, () => onSignal('SIGINT')); const activeTui = tui; const { store } = activeTui; - // A screen's exit request ends the run with no shutdown; start-tui's exit listener unmounts. + // A screen's exit request unmounts, reports the run's end within a bounded wait, then exits with no stream shutdown. store.subscribe(() => { - if (store.exitRequest !== null && !handedOff) exit.end(store.exitRequest); + const code = store.exitRequest; + if (code === null || handedOff || exitInProgress || signalled) return; + exitInProgress = true; + launch.signal.removeEventListener('abort', onAbort); + activeTui.unmount(); + const report = async (): Promise => { + try { + await analytics.shutdown(code === 0 ? 'cancelled' : 'error'); + } catch { + logToFile('[run-wizard] exit request shutdown failed'); + } + await flushAnalytics(); + }; + void Promise.race([ + report(), + new Promise((resolve) => setTimeout(resolve, EXIT_REPORT_BUDGET_MS)), + ]).then(() => exit.end(code)); }); const session = buildSession(launch.session); @@ -244,7 +263,7 @@ export async function runTui( await activeStream.finishRun(runFailed ? 'failed' : 'completed'); await store.waitUntil((s) => s.mintHandoff === 'exit' || s.skillsComplete); // A screen already ended the run (KeepSkills after a success): start no flush it would cut off. - if (exit.ended) return; + if (exit.ended || exitInProgress) return; exitInProgress = true; await activeStream.shutdown(2000);