Fix Windows libuv/TTY crashes in create, branch create, config export/apply

ST
2d ago
+112-14

Summary

Investigated whether #306, #300, #286, #270 (all Windows-only libuv/TTY crash reports) share one root cause. They trace to two distinct mechanisms, not one:

1. TTY-capability bypass → fixes #270, #286

src/commands/config/export.ts and src/commands/config/apply.ts call @clack/prompts' confirm() directly instead of the isInteractive-guarded wrapper already defined in src/lib/prompts.ts. Clack's raw confirm() always tries to init a TTY on stdin regardless of whether stdout/stdin is actually a console handle, which is exactly what produces #270's Error: TTY initialization failed: uv_tty_init returned EINVAL.

src/commands/branch/create.ts's spinner has the same category of gap — it's guarded by !json only, not !json && isInteractive, unlike the identical pattern already established in ai/setup.ts, posthog/setup.ts, and webscraper/apify/connect.ts. That mismatch matches #286 (spinner writes into a non-interactive Windows terminal and eventually EPIPEs).

Fix: route export.ts/apply.ts's confirmation prompt through lib/prompts.js, and add the same isInteractive guard to branch/create.ts's spinner — both changes adopt a pattern that already exists and is tested elsewhere in this codebase.

2. Forced process.exit() racing a still-open handle → fixes #306, #300

  • create.ts calls process.exit(0) immediately after a cancelled prompt. In the non-interactive fallback (lib/prompts.ts's nonTtyText/LineReader), a cancel means stdin just hit EOF and the underlying readline.Interface's own 'close' event only just fired. Calling process.exit() synchronously the instant after that is the documented class of Windows libuv race in src/win/async.c — forcing an immediate handle teardown while one is already mid-close.
  • Every command's handleError() (the single shared error path) runs through PostHog analytics first. src/lib/analytics.ts constructs the PostHog client without enableLocalEvaluation: false. I pulled posthog-node@5.28.9's source to confirm: with local evaluation enabled (the default), the FeatureFlagsPoller constructor calls loadFeatureFlags() unconditionally and unawaited, immediately firing a flag-definitions fetch and scheduling a recurring setTimeout poll — entirely pointless here, since nothing in src/ ever calls getFeatureFlag/isFeatureEnabled. shutdownAnalytics() → client.shutdown() → stopPoller() only does clearTimeout(this.poller), which cancels the next scheduled poll — it does not cancel an already in-flight fetch. handleError()'s process.exit() fires right after that shutdown() resolves, so an in-flight PostHog socket can still be open when the process is forced to exit. This matches #300's Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 94 and the issue's own triage note about "the catch-path PostHog flush."

Fix: disable PostHog's local evaluation (one line, in the single shared client constructor — removes the dead poller/fetch entirely), and replace create.ts's cancel-time process.exit(0) with process.exitCode = 0; return; so Node drains naturally instead of forcing a hard stop (Node's own docs recommend exitCode over exit() for exactly this class of issue).

Confidence

  • High on fix (1): source-verified, exact error-message match (uv_tty_init returned EINVAL), minimal diff adopting an existing in-repo pattern.
  • Medium-high on fix (2): I don't have a Windows box to reproduce the libuv assertion directly, but both changes are independently correct on their own merits (the PostHog poller is genuinely dead weight; process.exitCode over process.exit() is the documented-safe pattern for this exact race), and they directly target the mechanisms each issue's own repro describes.

Test plan

  • npx tsc --noEmit — no new errors vs. the pre-fix baseline (diffed explicitly)
  • npx eslint src/ — clean
  • npx vitest run — 835 passed, 5 pre-existing failures (Windows path-separator issues in flyctl.test.ts, migrations.test.ts, local/checkout.test.ts — none of these files were touched by this PR)
  • Added regression tests for config export/config apply's overwrite/apply confirmation prompt — previously uncovered, since every existing test passes --force/--yes/--json and skips the prompt entirely. New tests assert the command calls lib/prompts.js's confirm() (not raw @clack/prompts) and that declining leaves state untouched.
  • Updated branch/create.test.ts to mock isInteractive: true, since those tests specifically assert on the spinner-driven interactive path.

🤖 Generated with Claude Code


Summary by cubic

Fixes four Windows-only crashes in create, branch create, config export, and config apply (#306, #300, #286, #270). They came from two sources: prompts and spinners that bypassed the CLI's isInteractive TTY guard, and process.exit() racing a still-open libuv handle.

Bug Fixes

  • Route config export and config apply's confirmation prompts through lib/prompts.js's guarded confirm() instead of raw @clack/prompts, which tried to init a TTY on stdin even without a console handle and crashed with uv_tty_init returned EINVAL.
  • Guard branch create's spinner with isInteractive (previously only !json), matching the pattern used elsewhere in the CLI.
  • Disable PostHog local evaluation (enableLocalEvaluation: false) so the unused feature-flag poller never starts, leaving no in-flight socket for handleError()'s process.exit() to race.
  • Replace cancel-time process.exit(0) in create.ts with process.exitCode = 0; return; so Node drains naturally instead of forcing a hard stop.
  • Add regression tests for the export/apply confirmation routing (previously uncovered, since existing tests always passed --force/--yes/--json) and mock isInteractive: true in branch/create.test.ts for its spinner path.