Skip to content

_onclose() incomplete cleanup: _timeoutInfo not cleared and stale .finally() can delete new connection's abort controller #1459

@pcarleton

Description

@pcarleton

Summary

_onclose() in src/shared/protocol.ts has two related cleanup gaps that can cause issues when a Protocol instance is reused across connections via close() + connect().

Bug 1: _timeoutInfo not cleared in _onclose()

_onclose() methodically clears _responseHandlers, _progressHandlers, _taskProgressTokens, _pendingDebouncedNotifications, and _requestHandlerAbortControllers, but does not clear _timeoutInfo. Pending setTimeout callbacks from old requests survive close() and can fire cancel() against a new transport after reconnect.

Reproduction steps:

  1. Protocol connects to transportA. Client sends a request with messageId=5 and timeout=60000ms.
  2. _setupTimeout(5, 60000, ...) stores a setTimeout callback in _timeoutInfo.
  3. At t=30s, close() is called. _onclose() clears response handlers and aborts controllers, but _timeoutInfo is untouched — the 60s timer keeps ticking.
  4. connect(transportB) is called.
  5. At t=60s, the old timeout fires. cancel() calls this._transport?.send(notifications/cancelled) for requestId=5. Since this._transport now points to transportB, a spurious cancellation notification is sent to the wrong transport.

Fix:

Iterate _timeoutInfo entries calling clearTimeout() for each, then clear the map in _onclose().

Bug 2: Stale .finally() can delete new connection's abort controller

In _onrequest, the fire-and-forget promise chain's .finally() does this._requestHandlerAbortControllers.delete(request.id). The request.id is captured from the closure, but there is no check that the stored controller matches.

Reproduction steps:

  1. Client A sends request id=1 on transportA. An AbortController (controllerA) is stored at _requestHandlerAbortControllers.set(1, controllerA). The handler starts async work.
  2. close() is called. _onclose() aborts controllerA and clears the map. The handler is aborted, but the promise chain continues (the error path runs).
  3. connect(transportB) is called.
  4. Client B sends request id=1 (common since client IDs typically start from 0). A new AbortController (controllerB) is stored at _requestHandlerAbortControllers.set(1, controllerB).
  5. The old handler's promise chain from step 1 finally settles. .finally() runs: this._requestHandlerAbortControllers.delete(1) — this deletes controllerB, not controllerA.
  6. Request id=1 on connection B now has no abort controller in the map. If cancellation is attempted, the handler cannot be aborted.

Fix:

Capture a reference to the specific AbortController in the .finally() closure and only delete from the map if the stored value matches:

const controller = abortController;
promise.finally(() => {
  if (this._requestHandlerAbortControllers.get(request.id) === controller) {
    this._requestHandlerAbortControllers.delete(request.id);
  }
});

Impact

Both issues are pre-existing on v1.x. Practical impact is low because (1) spurious cancellations use stale request IDs that would be ignored, (2) Protocol reuse across different clients is uncommon. However, the issues become more relevant as close() + connect() becomes a supported reconnect pattern.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions