Skip to content
Draft
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
38 changes: 38 additions & 0 deletions bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,44 @@ 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 { releaseTerminal } from './src/ui/tui/terminal';
import { runCleanups } from './src/utils/wizard-abort';
import { logToFile, getLogFilePath } from './src/utils/debug';

// Last-resort net for any error no local handler caught. A TUI command may
// already own the alt screen, so leave it, print a readable line, and exit —
// otherwise the user is stranded on a blank screen with the real error lost in
// the discarded alt buffer. Node already terminates on these events; this only
// makes the exit clean.
function handleFatal(err: unknown): void {
// Preserve the full stack in the wizard log first, as the runner's fatal
// path does — the readable line below carries only err.message, and the
// crash this net exists to catch ("Cannot read properties of undefined
// (reading 'S')") names no module without its stack. logToFile needs no
// init and never throws, so it is safe this early and inside the handler.
logToFile('[bin] FATAL:', err);
releaseTerminal();
const message = err instanceof Error ? err.message : String(err);
// eslint-disable-next-line no-console
console.error(
`\n\x1b[1;91m✖ The PostHog wizard crashed: ${message}\x1b[0m\n` +
`Full logs: ${getLogFilePath()}\n`,
);
emitWizardError({ code: ErrorCodes.InternalUnhandled, message });
Comment thread
posthog[bot] marked this conversation as resolved.
// Run registered cleanups before exiting, same as wizardAbort() and the
// runner catch/signal paths — a fatal event otherwise skips them, leaving
// state a cleanup owns unrestored (e.g. the backed-up .claude/settings.json
// that backupAndFixClaudeSettings deletes and registers a restore for).
runCleanups();
process.exit(1);
Comment thread
posthog[bot] marked this conversation as resolved.
}
// Skip under test: the CLI suites import bin.ts and mock process.exit to throw,
// so a global handler would turn every mocked exit into a worker crash. Prod
// builds inline NODE_ENV as 'production', so the net is always live for users.
if (process.env.NODE_ENV !== 'test') {
process.on('unhandledRejection', handleFatal);
process.on('uncaughtException', handleFatal);
Comment on lines +106 to +108

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Register fatal handlers before Ink loads

should_fix bug

Why we think it's a valid issue
  • Checked: the static import graph from bin.ts, the published bundle dist/bin.js from @posthog/wizard@2.68.0, ink@6.8.0's build output, and react-reconciler@0.33.0's production build.
  • Found: bin.ts reaches ink through a static chain: bin.ts:61src/commands/audit.ts:4src/commands/factories/family-command-factory.ts:13src/commands/factories/family-picker.tsx:20 (import { Box, Text, render } from 'ink'). No dynamic import breaks the chain.
  • Found: the shipped bundle confirms the order. dist/bin.js line 33 is import { Box, Text, render, useInput, useStdout } from "ink";, and the entry body starts far below at line 6509 (const NODE_VERSION_RANGE). ESM evaluates every static dependency before the first body statement, so process.on('uncaughtException', handleFatal) at bin.ts:90-92 registers after Ink and React load.
  • Found: the reported crash is a module-load crash, not a render() crash. ink/build/ink.js:14 imports ./reconciler.js, which calls createReconciler({...}) at module scope (reconciler.js:73). react-reconciler@0.33.0 puts its whole body in module.exports = function ($$$config), and that factory reads the internals at top level: ReactSharedInternals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE and then prevOnStartTransitionFinish = ReactSharedInternals.S; (production build line 10636). A mismatched React makes the first read undefined, so the second read throws TypeError: Cannot read properties of undefined (reading 'S') during module evaluation.
  • Impact: both new guards miss the exact failure this PR targets. The try/catch around render() in src/ui/tui/start-tui.ts never runs, because the process dies before startTUI is called. The fatal handlers never exist yet, so Node prints the raw stack and exits, and emitWizardError({ code: ErrorCodes.InternalUnhandled }) never fires. The team therefore keeps no telemetry for this class of start failure. Only the exact version pins in package.json prevent the crash.
  • Impact (severity bound): the crash happens before enterDarkTerminal(), so the terminal is still on the main screen and the user does see the error text. The loss is the readable message and the error event, not a blank screen. The handlers still help for failures after startup, so the change is not useless — it is incomplete for the motivating case.
Issue description

ES module dependencies execute before the body of bin.ts. Static command imports load program content that imports ink. react-reconciler reads React's S internal while that module loads. The reported mismatch can therefore throw before these listeners register. The render() catch also cannot catch a module-load failure.

Suggested fix

Move command registration and Wizard.init() to a separate module. Keep bin.ts as a small bootstrap that registers the listeners before dynamically importing that module with .catch(handleFatal). Add a subprocess test that makes the Ink import fail.

Prompt to fix with AI (copy-paste)
## Context
@bin.ts#L90-92

<issue_description>
ES module dependencies execute before the body of bin.ts. Static command imports load program content that imports ink. react-reconciler reads React's S internal while that module loads. The reported mismatch can therefore throw before these listeners register. The render() catch also cannot catch a module-load failure.
</issue_description>

<issue_validation>
- **Checked:** the static import graph from `bin.ts`, the published bundle `dist/bin.js` from `@posthog/wizard@2.68.0`, `ink@6.8.0`'s build output, and `react-reconciler@0.33.0`'s production build.
- **Found:** `bin.ts` reaches `ink` through a static chain: `bin.ts:61` → `src/commands/audit.ts:4` → `src/commands/factories/family-command-factory.ts:13` → `src/commands/factories/family-picker.tsx:20` (`import { Box, Text, render } from 'ink'`). No dynamic import breaks the chain.
- **Found:** the shipped bundle confirms the order. `dist/bin.js` line 33 is `import { Box, Text, render, useInput, useStdout } from "ink";`, and the entry body starts far below at line 6509 (`const NODE_VERSION_RANGE`). ESM evaluates every static dependency before the first body statement, so `process.on('uncaughtException', handleFatal)` at bin.ts:90-92 registers after Ink and React load.
- **Found:** the reported crash is a module-load crash, not a `render()` crash. `ink/build/ink.js:14` imports `./reconciler.js`, which calls `createReconciler({...})` at module scope (`reconciler.js:73`). `react-reconciler@0.33.0` puts its whole body in `module.exports = function ($$$config)`, and that factory reads the internals at top level: `ReactSharedInternals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE` and then `prevOnStartTransitionFinish = ReactSharedInternals.S;` (production build line 10636). A mismatched React makes the first read `undefined`, so the second read throws `TypeError: Cannot read properties of undefined (reading 'S')` during module evaluation.
- **Impact:** both new guards miss the exact failure this PR targets. The `try/catch` around `render()` in `src/ui/tui/start-tui.ts` never runs, because the process dies before `startTUI` is called. The fatal handlers never exist yet, so Node prints the raw stack and exits, and `emitWizardError({ code: ErrorCodes.InternalUnhandled })` never fires. The team therefore keeps no telemetry for this class of start failure. Only the exact version pins in `package.json` prevent the crash.
- **Impact (severity bound):** the crash happens before `enterDarkTerminal()`, so the terminal is still on the main screen and the user does see the error text. The loss is the readable message and the error event, not a blank screen. The handlers still help for failures after startup, so the change is not useless — it is incomplete for the motivating case.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Move command registration and Wizard.init() to a separate module. Keep bin.ts as a small bootstrap that registers the listeners before dynamically importing that module with .catch(handleFatal). Add a subprocess test that makes the Ink import fail.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and correct: the fatal handlers register too late to catch the exact crash this PR targets. bin.ts statically imports the command modules (line 61's auditCommand reaches ink via audit.tsfamily-command-factory.tsfamily-picker.tsx), and since tsdown keeps deps external, ink/react-reconciler evaluate at module load — before process.on(...) at lines 90-92 runs. A version-mismatch crash happens during that module evaluation, so neither the handlers nor the render() try/catch fire, and no InternalUnhandled event is emitted.

Two things bound the impact, though: the crash occurs before enterDarkTerminal(), so the terminal is still on the main screen and the user does see the raw stack — the loss is the friendly message and the telemetry event, not a blank screen. And the version pins added in this same PR already prevent the motivating mismatch, so this is defense-in-depth for a class that's now prevented at the source.

I'm escalating rather than fixing unattended. The only robust fix is the one you suggested — make bin.ts a thin bootstrap that registers the handlers, then loads command registration + Wizard.init() via a dynamic import() (a 'side-effect import first' shortcut won't work, because external imports hoist to the top of the bundle regardless of where first-party code inlines). That restructures the entry point and has to be reconciled with the node-version preflight's load-order assumption, the synchronous orphaned-settings recovery, and the test-only mock-server block, plus a new subprocess test that poisons the ink import to prove it. That's a design decision with a real cost/benefit tradeoff (entry-point restructure + new test harness, with some risk of subtle startup-ordering regressions, versus telemetry coverage for an already-pinned-away crash) — it needs a human to weigh and own, so I've left it for a maintainer rather than reshaping the bootstrap on my own.

}

// Heal any .claude/settings backup a previous interrupted run left orphaned,
// before anything else reads Claude settings — conflict detection, OAuth, and
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"fflate": "^0.8.3",
"fuse.js": "^7.5.0",
"glob": "9.3.5",
"ink": "^6.8.0",
"ink": "6.8.0",
"inquirer": "^6.2.0",
"jiti": "^2.7.0",
"jsonc-parser": "^3.3.1",
Expand All @@ -55,7 +55,8 @@
"opn": "^5.4.0",
"pi-mcp-adapter": "~2.15.0",
"posthog-node": "^5.45.2",
"react": "^19.2.4",
"react": "19.2.4",
"react-reconciler": "0.33.0",
"read-env": "^1.3.0",
"recast": "^0.23.3",
"semver": "^7.5.3",
Expand Down
7 changes: 5 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions src/ui/tui/__tests__/start-tui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { startTUI } from '@ui/tui/start-tui';
import { Program } from '@ui/tui/store';

// render() is the throw site: Ink's reconciler blows up on a react /
// react-reconciler version mismatch. Force that here.
const renderThrows = new Error(
"Cannot read properties of undefined (reading 'S')",
);
vi.mock('ink', async (importOriginal) => ({
...(await importOriginal<typeof import('ink')>()),
render: () => {
throw renderThrows;
},
}));
vi.mock('../../../utils/analytics.js', () => ({
analytics: {
wizardCapture: vi.fn(),
setTag: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('../../../utils/debug.js', () => ({
logToFile: vi.fn(),
}));

const LEAVE_ALT_SCREEN = '\x1b[?1049l';
const ENTER_ALT_SCREEN = '\x1b[?1049h';

describe('startTUI', () => {
it('leaves the alt screen before a render() crash propagates', () => {
const chunks: string[] = [];
const originalWrite = process.stdout.write;
process.stdout.write = ((chunk: unknown) => {
chunks.push(String(chunk));
return true;
}) as typeof process.stdout.write;

try {
expect(() => startTUI('1.0.0', Program.PostHogIntegration)).toThrow(
renderThrows,
);
} finally {
process.stdout.write = originalWrite;
}

const output = chunks.join('');
// The alt screen was entered, then left — so the caller's error message
// lands in the restored main buffer instead of a discarded one.
expect(output).toContain(ENTER_ALT_SCREEN);
expect(output).toContain(LEAVE_ALT_SCREEN);
expect(output.lastIndexOf(LEAVE_ALT_SCREEN)).toBeGreaterThan(
output.indexOf(ENTER_ALT_SCREEN),
);
});
});
17 changes: 14 additions & 3 deletions src/ui/tui/start-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,20 @@ export function startTUI(
const inkUI = new InkUI(store);
setUI(inkUI);

const { unmount: inkUnmount, waitUntilExit } = render(
createElement(App, { store }),
);
// render() throws synchronously when Ink's reconciler cannot drive the
// resolved React — the signature of a react / react-reconciler version
// mismatch. enterDarkTerminal() already switched to the alt screen, so leave
// it before the error propagates. Without this the caller prints its message
// into the alt buffer, which the terminal discards on exit, and the user is
// left on a blank screen with no message.
let rendered: ReturnType<typeof render>;
try {
rendered = render(createElement(App, { store }));
} catch (err) {
releaseTerminal();
throw err;
}
const { unmount: inkUnmount, waitUntilExit } = rendered;

analytics.setTag('program_id', program);
// The launch marker — the first event of every TUI run, captured under
Expand Down
Loading