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:
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:
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?
- Start a long-running tool batch so subsequent requests queue.
- While item B sits in
requestQueue, call scheduler.dispose().
- 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).
What happened?
CoreToolScheduler.dispose()removes its MCP-progress subscription and aborts an internal controller, but never drainsrequestQueuenor rejects promises parked in_enqueueRequest(). Any caller whoseschedule()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: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:The sole drain point is
_startBatch's loop overrequestQueue; once disposed it never runs again.How can this be reproduced?
requestQueue, callscheduler.dispose().schedule()for B awaits forever; inspectsignal.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 storedreject(the entries already carry settle callbacks that remove listeners).Found by source audit on current
main(commit5411f113c); platform-independent. No open issue/PR covering this was found (searched: scheduler dispose).