google-gemini / google-gemini/gemini-cli
bug: CoreToolScheduler.dispose() leaves queued schedule() callers pending forever with leaked abort listeners
- Dominant language
- TypeScript
- Stars
- 107k
- Forks
- 14.6k
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 45
Description
## What happened?
`CoreToolScheduler.dispose()` removes its MCP-progress subscription and aborts an internal controller, but never drains `requestQueue` nor rejects promises parked in `_enqueueRequest()`. Any caller whose `schedule()` is queued behind an active batch at teardown time has its promise settle **never**, leaking the `'abort'` listener attached to the caller's signal and the queue entry itself.
## Affected code
`packages/core/src/scheduler/scheduler.ts:140-143`:
```ts
dispose(): void {
coreEvents.off(CoreEvent.McpProgress, this.handleMcpProgress);
this.disposeController.abort();
}
```
`packages/core/src/core/../scheduler/scheduler.ts:229-243` — the only paths that settle a queued promise are the caller's own abort signal or batch processing, neither of which can happen after dispose:
```ts
const abortHandler = () => {
const index = this.requestQueue.findIndex((item) => item.requests === requests);
if (index > -1) { this.requestQueue.splice(index, 1); reject(new Error('Tool call cancelled while in queue.')); }
};
// ... resolve/reject wrappers remove the listener only when settled
```
The sole drain point is `_startBatch`'s loop over `requestQueue`; once disposed it never runs again.
## How can this be reproduced?
1. Start a long-running tool batch so subsequent requests queue.
2. While item B sits in `requestQueue`, call `scheduler.dispose()`.
3. The caller of `schedule()` for B awaits forever; inspect `signal.listenerCount('abort')` to see the leaked listener.
## What did you expect to happen?
Dispose should reject every queued promise (e.g., `new Error('Scheduler disposed')`) so callers fail fast and their listeners are removed.
## Impact
Subagent/session teardown leaves callers hung indefinitely and retains closures per queued call.
## Suggested direction
In `dispose()`, splice all queue entries and invoke their stored `reject` (the entries already carry settle callbacks that remove listeners).
---
*Found by source audit on current `main` (commit `5411f113c`); platform-independent. No open issue/PR covering this was found (searched: scheduler dispose).*
Contributor guide
Research direction
Start in packages/core/src/scheduler/scheduler.ts, especially dispose() and the requestQueue handling in _enqueueRequest(). Reproduce a queued schedule() call behind a long-running batch, then dispose the scheduler. Done means every queued promise rejects promptly and the queued callers' abort listeners and queue entries are released.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 86/100