diff --git a/bin.ts b/bin.ts index f4bb1bd5..60ab2056 100644 --- a/bin.ts +++ b/bin.ts @@ -69,6 +69,19 @@ import { uploadSourcemapsCommand } from './src/commands/upload-sourcemaps'; import { skillCommand } from './src/commands/skill'; import { cliCommand } from './src/commands/cli'; import { recoverOrphanedSettingsBackups } from './src/lib/agent/claude-settings'; +import { analytics } from './src/utils/analytics'; +import { logToFile } from './src/utils/debug'; +import { installWizardUncaughtExceptionHandler } from './src/utils/uncaught-exception'; + +// posthog-node installs its autocapture listener while analytics is imported. +// Register after it so the SDK records unexpected errors first, while the +// wizard decides whether this one known Node transport error is actually fatal. +installWizardUncaughtExceptionHandler({ + runtime: process, + flush: (timeoutMs) => analytics.flush(timeoutMs), + log: logToFile, + print: (error) => process.stderr.write(`${error.stack ?? String(error)}\n`), +}); // Heal any .claude/settings backup a previous interrupted run left orphaned, // before anything else reads Claude settings — conflict detection, OAuth, and diff --git a/src/utils/__tests__/analytics.test.ts b/src/utils/__tests__/analytics.test.ts index fe4ed004..382d42db 100644 --- a/src/utils/__tests__/analytics.test.ts +++ b/src/utils/__tests__/analytics.test.ts @@ -557,6 +557,12 @@ describe('Analytics', () => { }); describe('shutdown', () => { + it('passes a flush deadline through to the PostHog client', async () => { + await analytics.flush(2_000); + + expect(mockPostHogInstance.shutdown).toHaveBeenCalledWith(2_000); + }); + it('emits the terminal event once — the first status wins over the interrupt fallback', async () => { analytics.setTag('program_id', 'warehouse-source'); diff --git a/src/utils/__tests__/uncaught-exception.test.ts b/src/utils/__tests__/uncaught-exception.test.ts new file mode 100644 index 00000000..dcec9fb7 --- /dev/null +++ b/src/utils/__tests__/uncaught-exception.test.ts @@ -0,0 +1,80 @@ +import { installWizardUncaughtExceptionHandler } from '@utils/uncaught-exception'; + +describe('installWizardUncaughtExceptionHandler', () => { + const buildHarness = () => { + let listener: ((error: Error) => void) | undefined; + const runtime = { + on: vi.fn( + (event: 'uncaughtException', nextListener: (error: Error) => void) => { + expect(event).toBe('uncaughtException'); + listener = nextListener; + return runtime; + }, + ), + exit: vi.fn(), + }; + const flush = vi.fn().mockResolvedValue(undefined); + const log = vi.fn(); + const print = vi.fn(); + + const options = { + runtime, + flush, + log, + print, + }; + installWizardUncaughtExceptionHandler(options); + + if (!listener) + throw new Error('uncaughtException listener was not installed'); + return { listener, runtime, flush, log, print, options }; + }; + + it('installs only once on the same runtime', () => { + const { runtime, options } = buildHarness(); + + installWizardUncaughtExceptionHandler(options); + + expect(runtime.on).toHaveBeenCalledTimes(1); + }); + + it('keeps the wizard alive for the Node HTTP/2 idle timeout', async () => { + const { listener, runtime, flush, log, print } = buildHarness(); + const error = new Error('socket idle timeout'); + error.name = 'InformationalError'; + + listener(error); + await Promise.resolve(); + + expect(log).toHaveBeenCalledWith( + '[uncaught-exception] ignored Node HTTP/2 idle timeout', + error, + ); + expect(flush).not.toHaveBeenCalled(); + expect(print).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it.each([ + Object.assign(new Error('socket idle timeout'), { name: 'Error' }), + Object.assign(new Error('different failure'), { + name: 'InformationalError', + }), + ])('preserves fatal handling for %s', async (error) => { + const { listener, runtime, flush, print } = buildHarness(); + + listener(error); + await vi.waitFor(() => expect(runtime.exit).toHaveBeenCalledWith(1)); + + expect(print).toHaveBeenCalledWith(error); + expect(flush).toHaveBeenCalledWith(2_000); + }); + + it('still exits when analytics flushing fails', async () => { + const { listener, runtime, flush } = buildHarness(); + flush.mockRejectedValueOnce(new Error('flush failed')); + + listener(new Error('boom')); + await vi.waitFor(() => expect(runtime.exit).toHaveBeenCalledWith(1)); + }); +}); diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 5ceafd8a..9ede46fa 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -308,8 +308,8 @@ export class Analytics { * starts — `shutdown()` would inflate the run count with a "finished" event * for a parse error that never actually ran the wizard. */ - async flush(): Promise { - await this.client.shutdown(); + async flush(timeoutMs?: number): Promise { + await this.client.shutdown(timeoutMs); } /** diff --git a/src/utils/uncaught-exception.ts b/src/utils/uncaught-exception.ts new file mode 100644 index 00000000..1e0369bb --- /dev/null +++ b/src/utils/uncaught-exception.ts @@ -0,0 +1,46 @@ +const handlerInstalled = Symbol.for( + 'posthog-wizard.uncaught-exception-handler-installed', +); + +type ExceptionHandlerRuntime = { + on(event: 'uncaughtException', listener: (error: Error) => void): unknown; + exit(code: number): unknown; + [handlerInstalled]?: true; +}; + +type ExceptionHandlerOptions = { + runtime: ExceptionHandlerRuntime; + flush: (timeoutMs: number) => Promise; + log: (...args: unknown[]) => void; + print: (error: Error) => void; +}; + +export function installWizardUncaughtExceptionHandler( + options: ExceptionHandlerOptions, +): void { + if (options.runtime[handlerInstalled]) return; + options.runtime[handlerInstalled] = true; + + let exiting = false; + + options.runtime.on('uncaughtException', (error) => { + if ( + error.name === 'InformationalError' && + error.message === 'socket idle timeout' + ) { + options.log( + '[uncaught-exception] ignored Node HTTP/2 idle timeout', + error, + ); + return; + } + + if (exiting) return; + exiting = true; + options.print(error); + void options.flush(2_000).then( + () => options.runtime.exit(1), + () => options.runtime.exit(1), + ); + }); +}