Summary
sphere invoice status <ID> for an invoice that does not exist in the local accounting store prints the expected No invoice found matching prefix: … message to stderr, then immediately crashes with Error: Cannot read properties of undefined (reading 'invoiceId') and exits non-zero with an ugly stack trace. The crash also affects every other invoice-* subcommand that follows the same lookup pattern (invoice-close, invoice-cancel, invoice-pay, …).
Root cause is the process.exit interceptor in src/legacy/legacy-cli.ts:1556-1567, so this is wider than just invoice status.
Reproduction
mkdir -p /tmp/inv-crash && cd /tmp/inv-crash
sphere wallet create alice
sphere wallet use alice
SPHERE_ALLOW_MNEMONIC_NON_TTY=1 sphere init --network testnet --nametag "alice-$(date +%s | tail -c 5)"
# Any 64-hex string that isn't actually a local invoice ID:
sphere invoice status 00005eb450a21d54f6d77b3c352a26a7539cc453ccdb1d1928dcdb6a0a266ca31e82
Observed:
No invoice found matching prefix: 00005eb450a21d54f6d77b3c352a26a7539cc453ccdb1d1928dcdb6a0a266ca31e82
Error: Cannot read properties of undefined (reading 'invoiceId')
Expected:
No invoice found matching prefix: 00005eb450a21d54f6d77b3c352a26a7539cc453ccdb1d1928dcdb6a0a266ca31e82
(clean exit, code 1, no stack trace)
This was discovered while running manual-test-full-recovery.sh §C.4 against the integration branch — the script attempted sphere invoice status \"$INV\" on peer2-alice (where the invoice was never synced) and the crash set rc=1, masking the actual cross-device sync gap (a separate, SDK-side issue).
Root cause
src/legacy/legacy-cli.ts:1556-1567 intercepts process.exit to ensure the Sphere instance is destroyed cleanly before the real exit:
const originalExit = process.exit.bind(process);
process.exit = ((code?: number) => {
if (sphereInstance) {
const inst = sphereInstance;
sphereInstance = null;
inst.destroy()
.catch(() => { /* best-effort cleanup */ })
.finally(() => originalExit(code));
return undefined as never; // ← async! returns without terminating
}
return originalExit(code);
}) as typeof process.exit;
When sphereInstance is set (which it is after every await getSphere() call), the interceptor kicks off inst.destroy() and returns undefined immediately. The synchronous code that called process.exit(1) then continues to execute, because Node never tore the call stack down.
The invoice-status handler relies on process.exit(1) being terminating:
// src/legacy/legacy-cli.ts:4030-4043
const allInvoices = await sphere.accounting.getInvoices();
const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix));
if (matched.length === 0) {
console.error(\`No invoice found matching prefix: \${idOrPrefix}\`);
process.exit(1); // ← returns undefined when sphereInstance is set
}
…
const invoiceId = matched[0].invoiceId; // ← matched[0] is undefined → crash
Scope
Every invoice-* case, plus probably many other sphere-loaded commands, has the same shape:
await getSphere() → validation fails → process.exit(1) → fall-through dereferences undefined
A targeted grep against legacy-cli.ts for process.exit(1) between await getSphere() and a following access shows ~25+ call sites that share this footgun.
Suggested fix
Pick one of:
-
Don't return from the interceptor when sphereInstance is set — make the synchronous call still throw / not return so the caller does not continue. e.g. throw originalExit(code) after firing-and-forgetting destroy, or block synchronously using a re-entry guard plus a deasync trick. Awkward.
-
Replace every process.exit(1) after getSphere() with await closeSphere(); originalExit(1); — explicit cleanup at call sites. Verbose but correct. A small helper (async function fail(msg: string): Promise<never>) keeps the call sites tidy.
-
Make every guarded process.exit(N) immediately followed by return — local fix that limits the blast radius without touching the interceptor. Doesn't help future callers who forget the return.
Option 2 (or a helper that wraps it) is the cleanest because it removes the implicit "exit might be async" footgun entirely. Option 3 is the smallest change that unblocks the immediate symptom across the existing call sites.
Related
Summary
sphere invoice status <ID>for an invoice that does not exist in the local accounting store prints the expectedNo invoice found matching prefix: …message to stderr, then immediately crashes withError: Cannot read properties of undefined (reading 'invoiceId')and exits non-zero with an ugly stack trace. The crash also affects every otherinvoice-*subcommand that follows the same lookup pattern (invoice-close,invoice-cancel,invoice-pay, …).Root cause is the
process.exitinterceptor insrc/legacy/legacy-cli.ts:1556-1567, so this is wider than justinvoice status.Reproduction
Observed:
Expected:
(clean exit, code 1, no stack trace)
This was discovered while running
manual-test-full-recovery.sh§C.4 against the integration branch — the script attemptedsphere invoice status \"$INV\"on peer2-alice (where the invoice was never synced) and the crash setrc=1, masking the actual cross-device sync gap (a separate, SDK-side issue).Root cause
src/legacy/legacy-cli.ts:1556-1567interceptsprocess.exitto ensure the Sphere instance is destroyed cleanly before the real exit:When
sphereInstanceis set (which it is after everyawait getSphere()call), the interceptor kicks offinst.destroy()and returnsundefinedimmediately. The synchronous code that calledprocess.exit(1)then continues to execute, because Node never tore the call stack down.The
invoice-statushandler relies onprocess.exit(1)being terminating:Scope
Every
invoice-*case, plus probably many othersphere-loaded commands, has the same shape:A targeted grep against
legacy-cli.tsforprocess.exit(1)betweenawait getSphere()and a following access shows ~25+ call sites that share this footgun.Suggested fix
Pick one of:
Don't return from the interceptor when sphereInstance is set — make the synchronous call still throw / not return so the caller does not continue. e.g.
throw originalExit(code)after firing-and-forgetting destroy, or block synchronously using a re-entry guard plus a deasync trick. Awkward.Replace every
process.exit(1)aftergetSphere()withawait closeSphere(); originalExit(1);— explicit cleanup at call sites. Verbose but correct. A small helper (async function fail(msg: string): Promise<never>) keeps the call sites tidy.Make every guarded
process.exit(N)immediately followed byreturn— local fix that limits the blast radius without touching the interceptor. Doesn't help future callers who forget thereturn.Option 2 (or a helper that wraps it) is the cleanest because it removes the implicit "exit might be async" footgun entirely. Option 3 is the smallest change that unblocks the immediate symptom across the existing call sites.
Related
sphere daemon start --detachexits immediately, leaving a stale PID file #19 / fix(daemon)(#19): keep --detach child alive past process.disconnect() #20 — surfaced this bug indirectly: the daemon detach fix unblocked manual-test-full-recovery.sh §B, which then surfaced this crash at §C.4.