Skip to content

refactor(cli): throw CliError instead of calling process.exit#473

Draft
Kamirus wants to merge 8 commits intomainfrom
refactor/ban-process-exit
Draft

refactor(cli): throw CliError instead of calling process.exit#473
Kamirus wants to merge 8 commits intomainfrom
refactor/ban-process-exit

Conversation

@Kamirus
Copy link
Copy Markdown
Collaborator

@Kamirus Kamirus commented Apr 1, 2026

Problem

Direct process.exit() calls scattered across ~90 sites in the CLI silently terminate the process, bypassing any finally clauses in the call stack. This makes cleanup (lock files, temp dirs, subprocesses) unreliable and makes the code untestable in isolation.

Root cause

The CLI had no error-signalling contract — errors were communicated by calling process.exit() directly wherever an error was detected, rather than by propagating a value up the call stack.

Fix

Introduce a CliError class in cli/error.ts. The cliError() and cliAbort() helpers now throw a CliError instead of calling process.exit(). The only place process.exit() is now called is in handleCliError() — a single catch handler registered at the top-level entry point:

program.parseAsync().catch(handleCliError);

This means finally blocks throughout the call stack execute correctly when a CLI error is raised.

The three categories of remaining process.exit() calls (all with eslint-disable-next-line no-restricted-properties comments):

  1. cli/error.tshandleCliError, the single authorised exit point
  2. SIGINT handlers in test.ts and install-mops-dep.ts — event callbacks where thrown errors cannot propagate
  3. replica.ts — a detached stderr.on("data") handler for a long-running process, also cannot propagate

An ESLint no-restricted-properties rule on process.exit enforces this going forward.

Caveats

Catch blocks written to handle subprocess/runtime errors (e.g. execa failures) also needed updating — they previously were unreachable from cliError() since process.exit() terminated synchronously. Now that cliError() throws, these catch blocks must re-throw CliError instances so they are not wrapped in a new "Error while running X" message. All affected catch blocks in build.ts, check.ts, check-candid.ts, and lint.ts have been fixed.

Test plan

  • All 63 existing tests pass, all 46 snapshots pass
  • TypeScript type check passes (tsc --noEmit)
  • ESLint clean on all changed files (pre-commit hooks pass)
  • process.exit grep confirms no unguarded calls outside error.ts and legitimate event handlers

Kamirus added 4 commits April 1, 2026 10:40
Replace all direct process.exit() calls across 33 CLI files with
centralized cliError() and cliAbort() functions from error.ts.

- cliError(message?, exitCode?) for error exits (default code 1)
- cliAbort(message?) for clean exits (code 0, SIGINT/cancellation)
- checkConfigFile() now always exits on failure (removed exit param)
- Added ESLint no-restricted-properties rule to ban future process.exit

Made-with: Cursor
Replace the process.exit() calls in cliError/cliAbort with throwing a
CliError exception. process.exit() is now only called in handleCliError
(the top-level catch in cli.ts) and in SIGINT/detached event handlers
where exceptions cannot propagate.

This ensures finally clauses in called functions run when a CLI error
is raised.

- cliError/cliAbort throw CliError
- program.parseAsync().catch(handleCliError) is the single exit point
- docs.ts: Promise now uses reject(new CliError(...)) from event handlers
- self.ts: proc.on("exit") wrapped in awaitable Promise with reject
- replica.ts: detached stderr handler uses process.exit(11) + eslint-disable
- Fix package-lock.json name field (worktree artifact)

Made-with: Cursor
The empty catch{} was there to handle 'which mocv' failing (not installed).
Now that cliError() throws instead of calling process.exit(), the CliError
was being silently swallowed by that same catch block.

Fix by detecting mocv in the try/catch using a flag, then calling cliError()
outside it.

Made-with: Cursor
Catch blocks designed to handle subprocess/runtime errors (execa, spawn)
were also swallowing CliError instances thrown by cliError() calls within
the same try block. Now that cliError() throws instead of process.exit(),
the CliError propagated into these handlers and got wrapped in a new
'Error while running X' message.

Fix: add 'if (err instanceof CliError) throw err' at the top of each
affected catch block so CliError passes through unchanged.

Files affected: build.ts, check.ts, check-candid.ts, lint.ts

All 63 tests pass after this fix.

Made-with: Cursor
@Kamirus Kamirus changed the title refactor(cli): centralize process.exit through cliError/cliAbort refactor(cli): throw CliError instead of calling process.exit Apr 1, 2026
Use the simpler fix: re-throw CliError in the catch block, keeping the
original try/catch structure unchanged.

Made-with: Cursor
Kamirus added 3 commits April 1, 2026 12:03
…locks

- Add `import process` to error.ts for consistency with rest of codebase
- Fix unhandled rejection in test.ts wasi/replica chains: add reject param
  to outer Promise and use .then(resolve, reject) instead of .then(resolve)
- Add CliError re-throw guards to install-mops-dep.ts catch blocks to
  prevent silent swallowing, consistent with build.ts/check.ts pattern

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant