Skip to content

AsyncExpiringMap starts an interval at import time (keeps Jest alive) and never restarts cleanup once stopped #6805

Description

@MoogyG

What React Native libraries do you use?

Expo (mobile only), Expo Router, Hermes, RN New Architecture

Are you using sentry.io or on-premise?

sentry.io (SaS)

Are you using any other error monitoring solution alongside Sentry?

No

@sentry/react-native SDK Version

7.11.0, also reproduced on 8.28.0 (the code below is unchanged on main at 0d58e97)

How does your development environment look like?

Expo SDK 57.0.26, React Native 0.86.3, Jest 29.7.0 (jest-expo 57.0.5), Node 24.21.0, macOS

Sentry.init()

Not needed: importing the SDK is enough to reproduce.

Steps to Reproduce

In a Jest project (jest-expo preset), create a test file containing only:

import '@sentry/react-native';

test('noop', () => {});

Run it with at least two test files or projects, so that Jest uses workers.

Expected Result

Importing the SDK starts no timer, and Jest exits cleanly.

Actual Result

A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.

With a single test file (no workers), Jest prints Jest did not exit one second after the test run has completed. instead.

On 8.28.0 without Jest, importing dist/js/tracing/timeToDisplayFallback.js in plain Node keeps the process alive for ~5,000 ms.

Cause. timeToDisplayFallback.ts creates an AsyncExpiringMap at module scope. Its constructor calls startCleanup(), which starts a 5-second setInterval without .unref(). The interval stops at its first run, but the test process has already finished by then.

Related bug: cleanup never restarts. stopCleanup() clears the interval but does not reset _cleanupInterval to undefined. set() restarts cleanup only when _cleanupInterval is falsy, so after the first cleanup of an empty map, entries added later are never removed by the interval. Only an explicit get(), has() or pop() made after expiry removes them.

Verified against the 7.11.0 and 8.28.0 builds:

const map = new AsyncExpiringMap({ cleanupInterval: 50, ttl: 10 });
await sleep(120);  // first cleanup ran on the empty map and stopped the interval
map.set('span-1', 123);
await sleep(300);
map._map.size;     // 1: the entry expired ~290 ms ago and is still there

Suggested fix.

  • Don't start the interval in the constructor. set() already starts it on demand.
  • Reset _cleanupInterval = undefined in stopCleanup() and clear().
  • Optionally, call .unref?.() on the interval, so it never keeps a Node process alive.

Activity

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

Metadata

Metadata

Assignees

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions