Skip to content

fix(cli)(#21): throw ExitSignal from process.exit wrapper so handlers stop#22

Merged
vrogojin merged 2 commits into
integration/all-fixesfrom
fix/invoice-status-crash-21
May 23, 2026
Merged

fix(cli)(#21): throw ExitSignal from process.exit wrapper so handlers stop#22
vrogojin merged 2 commits into
integration/all-fixesfrom
fix/invoice-status-crash-21

Conversation

@vrogojin
Copy link
Copy Markdown
Contributor

Closes #21.

Summary

sphere invoice status <unknown-prefix> (and every other invoice-* subcommand) used to crash with Error: Cannot read properties of undefined (reading 'invoiceId') whenever the local accounting store had no matching invoice.

Root cause was the process.exit interceptor in src/legacy/legacy-cli.ts's main(): it scheduled an async Sphere destroy and return undefined as never, so the caller's synchronous control flow continued past process.exit(1) and dereferenced matched[0] on an empty array. There are ~180 process.exit(N) call sites across this file that all share the same footgun.

Fix shape

Convert the wrapper to throw a module-scope ExitSignal sentinel synchronously when a Sphere instance is loaded. The outer try/catch in main() detects ExitSignal, awaits closeSphere(), and forwards the code through the original (non-wrapped) process.exit so the catch isn't re-entered. When no Sphere instance is loaded (early arg-validation / help paths), the wrapper falls straight through to originalExit, preserving prior synchronous behaviour.

ExitSignal deliberately doesn't extend Error so inner catch (err) blocks filtering on err instanceof Error don't classify it as a normal error worth logging. Every inner catch in this file was audited — each either re-calls process.exit(N) (re-throws ExitSignal, propagates correctly) or sits over a try body that does no process.exit (so ExitSignal can't reach it).

The prior #226 fix (explicit return after process.exit(1) in invoice-deliver) is unreachable now but left in place as a defensive marker — touching it would widen this PR's scope.

Verification

  • Build (tsup) ✓, typecheck (tsc --noEmit) ✓, lint (eslint) clean (0 new errors).
  • Offline integration suite: 24 passed, 9 skipped (4 of those are the new tests gated by integrationSkip).
  • Manual testnet repro from the issue body — clean exit 1, no stack trace.
  • Same shape verified for invoice close / cancel / pay.

Test plan

  • npx tsup builds clean
  • npm run typecheck clean
  • npx eslint src/legacy/legacy-cli.ts no new errors
  • SKIP_INTEGRATION=1 npx vitest run --config vitest.integration.config.ts test/integration/cli-invoice.integration.test.ts — 24 passed
  • Manual testnet repro per issue body: sphere invoice status 00005eb450a21d54... exits 1 with No invoice found matching prefix: … and no Cannot read properties of undefined
  • Same for invoice close / cancel / pay with bogus prefix
  • Happy paths preserved: invoice list on empty wallet exits 0; arg-validation paths (missing positional) still exit 1 with Usage: …
  • Lifecycle tests on real testnet (run by CI / npm run test:integration)

Related

Vladimir Rogojin added 2 commits May 23, 2026 13:38
… stop

`legacy-cli.ts`'s `main()` wraps `process.exit` to destroy the Sphere
instance (Nostr relays, IPFS handles, SQLite) before the real exit.
Cleanup is async, so the wrapper used to schedule `inst.destroy()
.finally(originalExit)` and `return undefined as never`. That left the
calling line of code to continue executing past `process.exit(N)`.

The shape that surfaced this was `invoice-status`:

  if (matched.length === 0) {
    console.error('No invoice found matching prefix: ...');
    process.exit(1);              // wrapper schedules destroy, returns
  }
  const invoiceId = matched[0].invoiceId;   // ← matched[0] undefined

…which crashed with `Cannot read properties of undefined (reading
'invoiceId')`. Every other `invoice-*` handler (close, cancel, pay,
return, receipts, notices, transfers, export) shares the same shape,
and there are ~180 `process.exit(N)` call sites across this file that
all depend on synchronous termination.

The wrapper now throws an `ExitSignal` sentinel synchronously when a
Sphere instance is loaded. The outer try/catch in `main()` detects
`ExitSignal`, awaits `closeSphere()`, and forwards the code through
the original (non-wrapped) `process.exit` so the catch is not re-
entered. When no instance is loaded (early arg-validation paths), the
wrapper falls straight through to `originalExit`, matching the
previous synchronous behaviour for help / usage exits.

ExitSignal deliberately does not extend `Error` so inner
`catch (err)` blocks that filter on `err instanceof Error` do not
classify it as a normal error worth logging. Every inner catch in
this file either re-calls `process.exit(N)` (which re-throws an
ExitSignal that propagates correctly) or sits over a try body with
no `process.exit` (so ExitSignal can never reach it) — audited via
the catch-block sweep in `src/legacy/legacy-cli.ts`.

Regression pins in `test/integration/cli-invoice.integration.test.ts`
(lifecycle block, gated by `integrationSkip`):

- `invoice status <unknown-prefix>` → exit 1 + clean error message,
  no `Cannot read properties of undefined` / `TypeError` anywhere.
- Companion `it.each` for `close` / `cancel` / `pay` — same shape,
  catches a wrapper regression surfacing on any of these handlers.

Manual repro (matches issue body) on testnet:

  sphere wallet create alice
  sphere wallet use alice
  sphere init --network testnet --nametag inv-crash-xxx
  sphere invoice status 00005eb450a21d54f6d77b3c352a26a7539cc453ccdb1d1928dcdb6a0a266ca31e82
  → No invoice found matching prefix: …
  → exit 1, no stack trace ✓

The prior bf40221 fix (`fix(invoice)(#226)`) added an explicit
`return` after the catch-block `process.exit(1)` in invoice-deliver.
That `return` is now unreachable (the throw propagates first) but
left in place as a defensive marker — removing it widens this PR's
scope unnecessarily.

Related: #19 / #20 (daemon detach) surfaced this bug indirectly by
unblocking manual-test-full-recovery.sh §B → §C.4, where peer2-alice
hit `invoice status` for an invoice it had never received. The
cross-device invoice sync gap (peer2 not seeing peer1's invoice) is
the SDK-side follow-up tracked separately; this commit only fixes
the CLI crash that was masking it.
… guard

The defensive `return` in invoice-deliver's catch dates from #226 when
the `process.exit` wrapper returned `undefined as never` and required
explicit returns at every call site. With #21's wrapper rewrite the
ExitSignal throw propagates first, so the comment's "wrapper returns
undefined" wording is now wrong and would confuse a future reviewer
auditing why the `return` is there.

Keep the `return` itself — defensive marker against a wrapper
regression that reintroduces the fall-through. Rewrite the comment to
describe the current ExitSignal-based mechanism and the regression
class it guards against.

No behaviour change. Pure documentation drift cleanup.
@vrogojin vrogojin merged commit c626879 into integration/all-fixes May 23, 2026
@vrogojin vrogojin deleted the fix/invoice-status-crash-21 branch May 23, 2026 11:47
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