Skip to content

fix(sdk): sleep() leaks an abort listener when the timer wins #476

Description

@EricAndrechek

Area: sdk — REST retry path. Pre-existing; surfaced reviewing #470 and deliberately not fixed there.

sleep() in clients/ts/src/http.ts attaches an abort listener to the caller's signal and only removes it when the listener fires:

const timer = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {  }, { once: true });

{ once: true } is removal-after-firing, not cleanup. On the ordinary path the timer wins, the promise resolves, and the listener stays attached to the signal for as long as that signal lives.

Impact

Only bites a caller who reuses one long-lived AbortSignal across many requests — a per-page or per-session controller, say, rather than one per call. Each retrying request leaves one listener behind (up to maxRetries per call, plus one per 503/Retry-After wait), so roughly ten retried requests on a shared signal trips Node's default MaxListenersExceededWarning and the count grows unbounded from there. Nothing breaks, but the warning is alarming and points at the SDK.

Not reachable through the streaming transport: SSETransport._sleep uses a stored _wake resolver rather than a signal listener, so it has no equivalent.

Fix

Either remove it explicitly when the timer wins:

const onAbort = () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); };
const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms);
signal?.addEventListener("abort", onAbort, { once: true });

…or pass an AbortSignal to addEventListener's own options so the runtime drops it, which needs a second controller per call and is probably not worth it here.

Acceptance

  • A resolved sleep() leaves no listener on the caller's signal
  • Pinned by a test asserting the listener count on a shared signal stays flat across several retried requests
  • No behavior change to abort handling itself (ABORTED is classified from signal.aborted, not from this listener)

Related: #470 (wrapped this call site so an abort during the backoff returns ABORTED instead of escaping, but left the listener lifecycle alone).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/sdkTypeScript SDK (clients/ts/)bugSomething isn't working

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions