Skip to content

limits: rejecting async commandRunner leaks unhandled rejections from Antigravity discovery #145

Description

@pitimon

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:

  1. Strict synchronous runner: detect thenables immediately and convert them into a handled, explicit configuration/error result; or
  2. 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

  • The reproduction emits zero unhandledRejection events.
  • A runner rejection becomes a normal, visible provider error or an explicitly rejected/handled operation.
  • Default spawnSync behavior remains unchanged.
  • A regression test uses a rejecting async runner and proves it fails before the fix.
  • npm run ci:local passes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions