What
An injected asynchronous commandRunner rejection can escape getUsageLimits() as unhandledRejection events even though the usage-limits call itself resolves with provider results.
The command helpers are synchronous by contract (cp.spawnSync by default), but they accept an arbitrary commandRunner and immediately inspect its return value. If that runner returns a rejected Promise, the Promise is neither awaited nor caught. Antigravity detection/listing reaches this path more than once in one sweep.
Reproduction
Run against the current PR #143 branch:
node -e 'const u=require("./src/lib/usage-limits"); const seen=[]; process.on("unhandledRejection",e=>seen.push(e&&e.message)); u.resetUsageLimitsCache(); u.getUsageLimits({home:"/tmp/tt-no-creds",env:{HOME:"/tmp/tt-no-creds"},platform:"linux",commandRunner:async()=>{throw new Error("commandRunner boom")},fetchImpl:async()=>{throw new Error("network boom")},requestFn:async()=>{throw new Error("request boom")},providerTimeoutMs:20}).then(()=>setTimeout(()=>{console.log(JSON.stringify(seen));process.exit(seen.length?0:2)},40))'
Observed:
["commandRunner boom","commandRunner boom"]
getUsageLimits() completes, but two unhandled rejections are emitted afterward.
Evidence
src/lib/usage-limits.js:992-998: runCommand() calls the injected runner synchronously and returns its value without Promise handling.
- Antigravity process/port discovery calls the command path multiple times.
fetchAntigravityLimits() catches errors in its awaited HTTP path, but it cannot catch a Promise that was returned from a helper treated as a synchronous result.
Impact
Under Node's default unhandled-rejection behavior, this can terminate the process. It also makes test/integration runners unsafe and means a caller-provided runner can fail outside the endpoint's provider-error contract.
Decision needed in implementation
Choose and enforce one contract:
- Strict synchronous runner: detect thenables immediately and convert them into a handled, explicit configuration/error result; or
- Async-capable runner: make the complete affected command-call chain await the runner.
Do not merely add a process-level unhandledRejection listener.
Definition of done
What
An injected asynchronous
commandRunnerrejection can escapegetUsageLimits()asunhandledRejectionevents even though the usage-limits call itself resolves with provider results.The command helpers are synchronous by contract (
cp.spawnSyncby default), but they accept an arbitrarycommandRunnerand immediately inspect its return value. If that runner returns a rejected Promise, the Promise is neither awaited nor caught. Antigravity detection/listing reaches this path more than once in one sweep.Reproduction
Run against the current PR #143 branch:
node -e 'const u=require("./src/lib/usage-limits"); const seen=[]; process.on("unhandledRejection",e=>seen.push(e&&e.message)); u.resetUsageLimitsCache(); u.getUsageLimits({home:"/tmp/tt-no-creds",env:{HOME:"/tmp/tt-no-creds"},platform:"linux",commandRunner:async()=>{throw new Error("commandRunner boom")},fetchImpl:async()=>{throw new Error("network boom")},requestFn:async()=>{throw new Error("request boom")},providerTimeoutMs:20}).then(()=>setTimeout(()=>{console.log(JSON.stringify(seen));process.exit(seen.length?0:2)},40))'Observed:
getUsageLimits()completes, but two unhandled rejections are emitted afterward.Evidence
src/lib/usage-limits.js:992-998:runCommand()calls the injected runner synchronously and returns its value without Promise handling.fetchAntigravityLimits()catches errors in its awaited HTTP path, but it cannot catch a Promise that was returned from a helper treated as a synchronous result.Impact
Under Node's default unhandled-rejection behavior, this can terminate the process. It also makes test/integration runners unsafe and means a caller-provided runner can fail outside the endpoint's provider-error contract.
Decision needed in implementation
Choose and enforce one contract:
Do not merely add a process-level
unhandledRejectionlistener.Definition of done
unhandledRejectionevents.spawnSyncbehavior remains unchanged.npm run ci:localpasses.