-
Notifications
You must be signed in to change notification settings - Fork 48
fix(tui): pin the ink/react pair and guard the render() crash #1164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }); | ||
| // 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); | ||
|
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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Register fatal handlers before Ink loadsWhy we think it's a valid issue
Issue descriptionES 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 fixMove 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)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Two things bound the impact, though: the crash occurs before I'm escalating rather than fixing unattended. The only robust fix is the one you suggested — make |
||
| } | ||
|
|
||
| // Heal any .claude/settings backup a previous interrupted run left orphaned, | ||
| // before anything else reads Claude settings — conflict detection, OAuth, and | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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), | ||
| ); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.