Skip to content

[Service Bus] receiveMessages leaks an abort listener on every drain, causing unbounded memory growth #40407

Open

Agent suggestions

Public preview

Description

@ApocDev
  • Package Name: @azure/service-bus
  • Package Version: 7.9.5 (also present in 7.10.0 and on main)
  • Operating system: Linux (Kubernetes, node:22)
  • nodejs — version: 22

Describe the bug
BatchingReceiverLite.tryDrainReceiver adds an "abort" listener to the caller's abortSignal, but only removes it on the abort and timeout paths. When the receiver drains normally, drainListener clears the timer and resolves without calling removeListeners(), so the abort listener stays attached.

https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/servicebus/service-bus/src/core/batchingReceiver.ts#L342-L346

A drain happens after most receiveMessages calls that end with leftover credit (for example every empty poll). A long-running consumer that passes the same shutdown AbortSignal to every receiveMessages call gains one listener per poll. In our service (17 receivers polling with maxWaitTimeInMs: 1000) that is ~60k listeners/hour. Node logs MaxListenersExceededWarning: ... abort listeners added to [AbortSignal], and the process eventually dies with JavaScript heap out of memory.

To Reproduce
Against the Service Bus emulator with an empty queue:

import { getEventListeners } from "node:events";
import { ServiceBusClient } from "@azure/service-bus";

const client = new ServiceBusClient("Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;");
const receiver = client.createReceiver("probe");
const shutdown = new AbortController();

for (let i = 0; i < 200; i++) {
  await receiver.receiveMessages(1, { maxWaitTimeInMs: 200, abortSignal: shutdown.signal });
}
console.log(getEventListeners(shutdown.signal, "abort").length); // ~200, expected 0

Activity

  1. github-actions commented on Oct 20, 2026

    @github-actions
    Contributor

    Thank you for your feedback. Tagging and routing to the team member(s) best able to assist.

    //cc: Eldert Grootenboer (@EldertGrootenboer) Johnathan W (@j7nw4r) Josue Gomez (@SwayGom) Sagar Patel (@sagar0207) Srini Karri (@skarri-microsoft)

  2. added
    customer-reportedIssues that are reported by GitHub users external to the Azure organization.
    ClientThis issue points to a problem in the data-plane of the library.
    Service AttentionWorkflow: This issue is responsible by Azure service team.
    needs-team-attentionWorkflow: This issue needs attention from Azure service team or SDK team
    on Oct 20, 2026
  3. github-actions commented on Oct 20, 2026

    @github-actions
    Contributor

    🎯 Agentic Issue Triage

    Summary: BatchingReceiverLite.tryDrainReceiver fails to remove its abort listener on the normal (non-abort, non-timeout) drain completion path, causing abort listeners to accumulate on long-lived AbortSignals and eventually leading to memory growth / MaxListenersExceededWarning.

    📋 Issue Details

    Package: @azure/service-bus 7.9.5 (also reproduced on 7.10.0 and main)
    Affected API: BatchingReceiverLite.tryDrainReceiver in sdk/servicebus/service-bus/src/core/batchingReceiver.ts
    Scenarios:

    • A long-running consumer passes the same shutdown AbortSignal to every receiveMessages call.
    • Every call that ends in a drain with leftover credit (e.g., an empty poll with maxWaitTimeInMs set) adds a new abort listener that is never removed.
    • At scale (e.g., 17 receivers polling every second), ~60k listeners/hour accumulate, producing MaxListenersExceededWarning and eventual JavaScript heap out of memory.

    Root ask: Fix the drain-completion path so removeListeners() (which detaches both the abort listener and the receiverDrained listener) is invoked whenever drainListener fires, not only on abort or timeout.

    🔎 Debugging / Reproduction Notes

    Reviewed sdk/servicebus/service-bus/src/core/batchingReceiver.ts directly. Confirmed the code structure matches the report:

    • drainListener() clears the drain timer and resolves the promise, but does not call removeListeners().
    • onAbort() and the setTimeout timeout callback both correctly call removeListeners().
    • receiver.once(...) means the receiverDrained listener itself is auto-removed after firing, but the abortSignal.addEventListener("abort", onAbort) registration is not bound with { once: true }, so it persists indefinitely unless explicitly removed.
    • This means every normal drain (the common/majority case for empty-queue polling) leaks one abort listener per call.

    Suggested investigation steps:

    1. Add removeListeners() (or equivalently remove just the abort listener) inside drainListener before/after resolve().
    2. Consider registering onAbort with { once: true } as defense-in-depth, though explicit cleanup is still needed since Node only auto-removes the listener it fired, not ones added via separate signals.
    3. Add a regression test using getEventListeners(signal, "abort") (as in the repro) asserting the count returns to 0 after repeated receiveMessages calls that drain normally.
    4. Check other call sites using checkAndRegisterWithAbortSignal in the same file (around the abortSignalCleanupFunction usage) for similar listener-leak patterns, since this is a recurring hygiene concern around AbortSignal usage in this file.

    No similar open issues were found via search for "abort listener" or "MaxListenersExceededWarning" in this repository.

    🏷️ Label Confidence
    • Category: Client — The affected code is client library source under sdk/servicebus/service-bus/src, not a management-plane (@azure/arm-*) package.
    • Service: Service Bus — Issue explicitly names @azure/service-bus and a sdk/servicebus source path.
    • Confidence: High — Package and affected API are explicitly identified, there is no ambiguity between services, and the category is clearly a client-library defect.
    👥 Owner Routing
    • Matched CODEOWNERS entry: # ServiceLabel: %Service Bus — only entry in CODEOWNERS associating labels with Service Bus; no multi-label override exists for it.
    • AzureSdkOwners: none listed
    • ServiceOwners: @EldertGrootenboer, @j7nw4r, @SwayGom, @sagar0207, @skarri-microsoft
    • Routing action: Added Service Attention and needs-team-attention labels; issue left unassigned; ServiceOwners mentioned in routing comment.
    • Scan notes: No other # ServiceLabel: entries reference "Service Bus" in combination with other labels, so the single-label entry was used directly without needing to scan further for a more specific override.

    Generated by Agentic Triage for #40407 · copilot · auto · 57.5 AIC · ⌖ 9.71 AIC · ⊞ 15.9K · ◷

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ClientThis issue points to a problem in the data-plane of the library.Service AttentionWorkflow: This issue is responsible by Azure service team.Service Buscustomer-reportedIssues that are reported by GitHub users external to the Azure organization.needs-team-attentionWorkflow: This issue needs attention from Azure service team or SDK team

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions