Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/utils/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
80 changes: 80 additions & 0 deletions src/utils/__tests__/uncaught-exception.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
4 changes: 2 additions & 2 deletions src/utils/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await this.client.shutdown();
async flush(timeoutMs?: number): Promise<void> {
await this.client.shutdown(timeoutMs);
}

/**
Expand Down
46 changes: 46 additions & 0 deletions src/utils/uncaught-exception.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
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),
);
});
}