Skip to content

PublicClientApplication leaks an unclosed BroadcastChannel / MessagePort handle (regression since v4.2.0) #8603

@gh2k

Description

@gh2k

Core Library

MSAL.js (@azure/msal-browser)

Core Library Version

5.10.0

Wrapper Library

Not Applicable

Wrapper Library Version

None

Public or Confidential Client?

  • Public

Description

new PublicClientApplication(...) opens a BroadcastChannel inside EventHandler and never closes it. PublicClientApplication exposes no public method to release it, so the underlying MessagePort keeps Node's event loop alive forever.

In a browser this is harmless — the channel goes away when the tab closes — but in any Node-based context (jest / vitest / Playwright globalSetup / one-off scripts) the process never exits naturally. We hit this on Linux CI as a jest run that hung silently for 60+ minutes; jest's --detectOpenHandles traced it directly to new BroadcastChannel(...) in EventHandler's constructor.

Error Message

No error — the process simply never exits. process._getActiveHandles() reports a leaked MessagePort after construction:

done
active handles: [ 'MessagePort' ]
<hangs indefinitely>

MSAL Logs

Not applicable — the bug reproduces on construction, before any operation that produces MSAL logs.

Network Trace (Preferrably Fiddler)

Not applicable — no network calls involved.

MSAL Configuration

{
  auth: {
    clientId: "00000000-0000-0000-0000-000000000000",
    authority: "https://login.microsoftonline.com/common"
  }
}

Relevant Code Snippets

// repro.cjs
const { PublicClientApplication } = require('@azure/msal-browser')

new PublicClientApplication({
  auth: {
    clientId: '00000000-0000-0000-0000-000000000000',
    authority: 'https://login.microsoftonline.com/common',
  },
})

console.log('done')
setImmediate(() => {
  console.log('active handles:', process._getActiveHandles().map(h => h.constructor.name))
})

Reproduction Steps

package.json:

{ "name": "msal-handle-leak-repro", "type": "commonjs", "private": true }
npm install @azure/msal-browser@5.10.0
node repro.cjs

Bisection:

@azure/msal-browser Behaviour
3.30.0 exits cleanly, active handles: []
4.1.0 exits cleanly, active handles: []
4.2.0 hangs, MessagePort leaked ← regression introduced here
4.10.0 / 4.27.0 hangs, MessagePort leaked
5.10.0 (latest) hangs, MessagePort leaked

Tested on Node 20.20.2 and 22.22.2 — same outcome.

Expected Behavior

Process prints done, reports active handles: [], and exits with code 0.

Root Cause

Between v4.1.0 and v4.2.0, src/event/EventHandler.ts gained an unconditional BroadcastChannel (v5.10.0 made it conditional on typeof BroadcastChannel !== 'undefined', but Node ≥18 has it as a global, so the channel is still always created):

// EventHandler.ts (v5.10.0)
constructor(logger?: Logger) {
    this.eventCallbacks = new Map();
    this.logger = logger || new Logger({});
    if (typeof BroadcastChannel !== "undefined") {
        this.broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
    }
    this.invokeCrossTabCallbacks = this.invokeCrossTabCallbacks.bind(this);
}

Across the entire msal-browser source, broadcastChannel appears at exactly one creation and three usages (postMessage, addEventListener, removeEventListener) — .close() is never called anywhere.

The IPublicClientApplication interface exposes no dispose / destroy / close / teardown method. clearCache() only clears storage. There is no documented or public way for a consumer to release the channel.

Related workaround in the repo

The library's own jest setup at shared-configs/jest-config/setupGlobals.cjs wraps the global BroadcastChannel with a TrackedBroadcastChannel and exposes global.cleanupMsalBroadcastChannels / global.forceCleanupMsalBroadcastChannels, which iterate every tracked channel and call .close().

Suggested Fix

Either:

  1. Add a public close() / dispose() to PublicClientApplication that delegates to EventHandler (and any other component holding native handles), closing the broadcast channel.
  2. Lazily construct the BroadcastChannel when the first cross-tab callback is registered and close it when the last one is removed. Currently it's eagerly created even when no consumer ever uses cross-tab events.

Option 2 has the nice property of making the leak disappear for the common case (no cross-tab listeners), without requiring a behaviour change for any consumer.

Identity Provider

Entra ID (formerly Azure AD) / MSA

Browsers Affected (Select all that apply)

  • None (Server)

Regression

@azure/msal-browser 4.1.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    msal-browserRelated to msal-browser packagepublic-clientIssues regarding PublicClientApplications

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions