Skip to content

fix: EventEmitter memory leak - #39

Open
jaredperreault-okta wants to merge 11 commits into
masterfrom
jp-emitter-leak-fix
Open

fix: EventEmitter memory leak#39
jaredperreault-okta wants to merge 11 commits into
masterfrom
jp-emitter-leak-fix

Conversation

@jaredperreault-okta

Copy link
Copy Markdown
Contributor

No description provided.

@jaredperreault-okta jaredperreault-okta changed the title first pass fix: EventEmitter memory leak Aug 31, 2026
});

// bind listener to Derived class instance
this.coordinator.emitter.on('metadata_updated', async ({ id, metadata }) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This listener bound on every Credential instance, but filter to only fire on the specific Credential. It's incorrectly space in method space. Moved this logic to static section where other listeners are bound

this.emitter.emit('credential_removed', { id });
});

this.coordinator.emitter.on('metadata_updated', async ({ id, metadata }) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tags_updated trigger moved from observeToken

/**
* Cleans up resourece associated with the Credential instance to prevent leaks.
*/
public dispose () {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a method to clean resources used by Credential instances. Since the tags_updated listener was moved to static space, the only instance-level listener is token_did_refresh of the OAuth2 instance with Credential. oauth2.dispose clears that listener.

Also added an AbortController instance of each Credential for future proofing of clearing resources

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern of clearing this.oauth2.emitter listeners also solves this nested listener which contributed to the memory leak

https://github.com/okta/okta-client-javascript/blob/master/packages/auth-foundation/src/Credential/CredentialCoordinator.ts#L128

const id = typeof cred === 'string' ? cred : cred.id;
if (this.credentials.has(id)) {
const cred = this.credentials.get(id)!;
cred.dispose();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calls new .dipose method on Credentials during .remove()

/**
* Cleans up resourece associated with the client instance to prevent leaks.
*/
public dispose () {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used within Credential via OAuth2.dispose. Clears active listeners on the EventEmitter to prevent similar leaks

/**
* Cleans up resourece associated with the client instance to prevent leaks.
*/
public dispose () {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used within Credential.dispose. Clears active listeners on this.emitter via super.dispose to prevent similar leaks. Also clears the #httpCache

}
this.listeners[eventName]!.push(handler);

if (signal && !signal?.aborted) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern isn't really utilized yet, but for future-proofing. Listens on a provided AbortSignal (presumably via an AbortController) and clears the event listener bound at the same time as the AbortSignal. Could useful to assist resource clean up of other entities in the future

log('removal');
this.credentialDataSource.remove(id);

// // removal messages never carried a token body; `id` is all `hasCredential` actually reads

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to confirm with testing this section is no longer needed

// @ts-expect-error - Credential `set token()` is a private setter to avoid exposing this to the public API
credential.token = token;
this.emitter.emit('credential_refreshed', { credential });
const credential = this.credentialDataSource.credentialFor(token);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per AI suggestion, there is probably no need to broadcast the token value when synchronizing tabs, each tab can access the token value from storage (the source of truth) to save on memory usage

Copy link
Copy Markdown

Re-reviewed at b7c2850 against the earlier pass at bb7a297. I couldn't run the suites locally (yarn install fails fetching the larger tarballs in my environment), so this is from reading the branch — CI is the source of truth.

The leak fix looks right. Three independent changes, any one of which would have helped: hoisting the metadata_updated binding out of observeToken() into the static coordinator setter, dispose() wired into remove()/clear(), and dropping the token body from broadcasts. Worth stating explicitly because it's the durable property: after the hoist, the only subscription a Credential holds is to its own oauth2.emitter, so even an un-disposed Credential is self-contained and collectable — the coordinator-emitter leak can't recur through that path.

Resolved since the last pass, each with a test I'd call load-bearing: the already-aborted-signal case in EventEmitter.on (EventEmitter.ts:37-40, test at EventEmitter.spec.ts:122); the signals WeakMap keying, now per-event, with the cross-event contamination test at :176; and cross-tab credential_removed for unmaterialized credentials (spa-platform/.../CredentialCoordinator.ts:142-152, both branches covered at :160-176). The new coordinator spec and the metadata_updated listener-count test close the coverage gaps I flagged.

One thing regressed. TokenStorage.ts:340 and :351 now emit token_removed from handleReadError/handleDecryptionError, and the only subscriber is the broadcast at spa-platform/.../CredentialCoordinator.ts:77-79. So a transient read failure in one tab now fans credential_removed out to every other tab, each disposing its Credential and surfacing the event to the app — while the tab that actually failed keeps its own credential, since nothing subscribes locally. A silent per-tab null became a synchronized cross-tab removal that skips the faulting tab. If the emit is deliberate, the fan-out should be too; otherwise use a non-destructive read there, or suppress the emit when the read originated from a broadcast. Untested either way — the new spec sets encryptAtRest = false (:22).

Refresh echo now costs a decrypt per message. :51-53 rebroadcasts on any local credential_refreshed and the receive branch re-emits at :179, re-triggering it. The echo terminates on Token.isEqual, but the receive path now does tokenStorage.get(id) — read, decrypt, new Token — where it previously took the token off the payload. With T tabs holding the credential that's O(T²) handlings each doing a decrypt per rotation, which partly reintroduces the per-tab allocation that dropping the payload was meant to remove. Simplest fix is to skip the re-emit for broadcast-originated updates.

Smaller items. dispose() is still public and doesn't evict from the dataSource — worth @internal, especially now that APIClient.dispose() splices interceptors (:62-65), so calling it on a live credential also strips its request interceptors. #pendingRefresh/PromiseQueue still aren't cleared alongside #httpCache. credential_added changed to { credential?: Credential, id: string }credential is optional now and undefined for cross-tab events; that's the breaking change here and it isn't in the CHANGELOG, nor is the tags_updated widening. And the fixture change at BrowserTokenStorage.spec.ts:213-216 masks a real shortID() encoding issue rather than fixing it — I've written that up separately.

Nits: "resourece" in both dispose docs (APIClient.ts:60, client.ts:149), "gargabe"/"sigificant" in Credential.dispose(). Also: the new spec pairs jest.useFakeTimers() with a handler that awaits pause(50) when isFirefox() — passes under jsdom's UA, but hangs rather than fails if that ever flips.


Analysis produced with Claude Code and not executed locally — see the caveat above.

@jaredperreault-okta
jaredperreault-okta marked this pull request as ready for review September 3, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants