From 648af0a3f1d2e552306b98a0d381dbf31a01176e Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 30 Sep 2026 10:18:49 +0200 Subject: [PATCH 1/2] fix(tracing): Don't start AsyncExpiringMap cleanup interval at import time `timeToDisplayFallback.ts` creates an `AsyncExpiringMap` at module scope, whose constructor started a `setInterval` without `.unref()`. Importing the SDK was therefore enough to keep a Node/Jest process from exiting cleanly. The interval is now started lazily on the first `set()` (as the existing `set()` guard already intended), `stopCleanup()` resets the handle so cleanup can actually restart after the map empties, and the interval is `unref`'d where supported so it never keeps a Node process alive. Fixes #6805 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + .../core/src/js/utils/AsyncExpiringMap.ts | 17 +++++++--- .../core/test/utils/AsyncExpiringMap.test.ts | 32 +++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b1d5d77b..4a7ea77462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixes +- Don't start the `AsyncExpiringMap` cleanup interval at import time, and restart it after the map empties ([#6805](https://github.com/getsentry/sentry-react-native/issues/6805)) - Preserve already-quoted React Native bundle script paths in the Expo iOS plugin ([#6796](https://github.com/getsentry/sentry-react-native/pull/6796)) ### Dependencies diff --git a/packages/core/src/js/utils/AsyncExpiringMap.ts b/packages/core/src/js/utils/AsyncExpiringMap.ts index bba14e01f1..2b7efe53df 100644 --- a/packages/core/src/js/utils/AsyncExpiringMap.ts +++ b/packages/core/src/js/utils/AsyncExpiringMap.ts @@ -19,7 +19,8 @@ export class AsyncExpiringMap { this._ttl = ttl; this._map = new Map(); this._cleanupIntervalMs = cleanupInterval; - this.startCleanup(); + // The cleanup interval is started lazily on the first `set()`. Starting it here would keep the + // interval (and therefore a Node/Jest process) alive just from importing the module, with nothing to clean. } /** @@ -143,9 +144,7 @@ export class AsyncExpiringMap { * Clear all entries. */ public clear(): void { - if (this._cleanupInterval) { - clearInterval(this._cleanupInterval); - } + this.stopCleanup(); this._map.clear(); } @@ -155,6 +154,9 @@ export class AsyncExpiringMap { public stopCleanup(): void { if (this._cleanupInterval) { clearInterval(this._cleanupInterval); + // Reset so `set()` can restart cleanup on demand. Without this the handle stays truthy after being + // cleared, so `set()` never re-arms the interval and later entries are only evicted lazily on access. + this._cleanupInterval = undefined; } } @@ -162,6 +164,11 @@ export class AsyncExpiringMap { * Start the cleanup interval. */ public startCleanup(): void { - this._cleanupInterval = setInterval(() => this.cleanup(), this._cleanupIntervalMs); + const interval = setInterval(() => this.cleanup(), this._cleanupIntervalMs); + // `unref` exists on Node timers (Jest, tests, tooling) but not on the React Native `setInterval` number + // (typed as `number` here), so access it defensively. It ensures the interval never keeps a Node process + // alive on its own. + (interval as unknown as { unref?: () => void }).unref?.(); + this._cleanupInterval = interval; } } diff --git a/packages/core/test/utils/AsyncExpiringMap.test.ts b/packages/core/test/utils/AsyncExpiringMap.test.ts index f8953d7e34..96850887ac 100644 --- a/packages/core/test/utils/AsyncExpiringMap.test.ts +++ b/packages/core/test/utils/AsyncExpiringMap.test.ts @@ -142,6 +142,38 @@ describe('AsyncExpiringMap', () => { expect(retrievedValue).toBeUndefined(); }); + it('does not start a cleanup interval on construction', () => { + // Importing a module that constructs a map at module scope must not start a timer, otherwise it keeps a + // Node/Jest process alive just from the import. See https://github.com/getsentry/sentry-react-native/issues/6805 + const timersBefore = jest.getTimerCount(); + + // eslint-disable-next-line no-new + new AsyncExpiringMap(); + + expect(jest.getTimerCount()).toBe(timersBefore); + }); + + it('restarts the cleanup interval after the map empties and a new entry is added', () => { + const ttl = 2000; + const cleanupInterval = ttl / 2; + const map = new AsyncExpiringMap({ ttl, cleanupInterval }); + const internalMap = (map as unknown as { _map: Map })._map; + + // First entry: the interval sweeps it and stops itself once the map is empty. + map.set('first', 'value'); + now += ttl; + jest.advanceTimersByTime(ttl); + expect(internalMap.size).toBe(0); + + // Second entry added after the interval stopped must re-arm cleanup so the interval evicts it too. + map.set('second', 'value'); + now += ttl; + jest.advanceTimersByTime(ttl); + + // Asserted via the internal map, not get()/has(), so it proves the interval evicted it rather than a lazy read. + expect(internalMap.size).toBe(0); + }); + it('stops cleanup when stopCleanup is called', () => { const map = new AsyncExpiringMap(); From c0b1da970d3d2bf14a82c3be8998f39d5d4869ee Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 30 Sep 2026 10:32:44 +0200 Subject: [PATCH 2/2] Update changelog ref --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a7ea77462..62a3f9fd67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ ### Fixes -- Don't start the `AsyncExpiringMap` cleanup interval at import time, and restart it after the map empties ([#6805](https://github.com/getsentry/sentry-react-native/issues/6805)) +- Don't start the `AsyncExpiringMap` cleanup interval at import time, and restart it after the map empties ([#6811](https://github.com/getsentry/sentry-react-native/pull/6811)) - Preserve already-quoted React Native bundle script paths in the Expo iOS plugin ([#6796](https://github.com/getsentry/sentry-react-native/pull/6796)) ### Dependencies