Skip to content

Commit c61baee

Browse files
CopilotLms24chargome
authored
fix(core): Avoid blocking the process when calling flush on empty buffer (#19062)
This PR fixes a bug when calling `Sentry.flush()` (or `client.flush()`) and there's no telemetry being processed by the SDK. While previously we'd wait for the passed-in timeout, we now exit immediately. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> Co-authored-by: Lukas Stracke <lukas.stracke@sentry.io> Co-authored-by: Charly Gomez <charly.gomez@sentry.io>
1 parent 3cab272 commit c61baee

File tree

9 files changed

+150
-22
lines changed

9 files changed

+150
-22
lines changed

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ module.exports = [
255255
path: createCDNPath('bundle.tracing.logs.metrics.min.js'),
256256
gzip: false,
257257
brotli: false,
258-
limit: '130 KB',
258+
limit: '131 KB',
259259
},
260260
{
261261
name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed',

packages/core/src/client.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1150,7 +1150,6 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
11501150
protected async _isClientDoneProcessing(timeout?: number): Promise<boolean> {
11511151
let ticked = 0;
11521152

1153-
// if no timeout is provided, we wait "forever" until everything is processed
11541153
while (!timeout || ticked < timeout) {
11551154
await new Promise(resolve => setTimeout(resolve, 1));
11561155

packages/core/src/transports/offline.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { InternalBaseTransportOptions, Transport, TransportMakeRequestRespo
44
import { debug } from '../utils/debug-logger';
55
import { envelopeContainsItemType } from '../utils/envelope';
66
import { parseRetryAfterHeader } from '../utils/ratelimit';
7+
import { safeUnref } from '../utils/timer';
78

89
export const MIN_DELAY = 100; // 100 ms
910
export const START_DELAY = 5_000; // 5 seconds
@@ -97,26 +98,24 @@ export function makeOfflineTransport<TO>(
9798
clearTimeout(flushTimer as ReturnType<typeof setTimeout>);
9899
}
99100

100-
flushTimer = setTimeout(async () => {
101-
flushTimer = undefined;
102-
103-
const found = await store.shift();
104-
if (found) {
105-
log('Attempting to send previously queued event');
101+
// We need to unref the timer in node.js, otherwise the node process never exit.
102+
flushTimer = safeUnref(
103+
setTimeout(async () => {
104+
flushTimer = undefined;
106105

107-
// We should to update the sent_at timestamp to the current time.
108-
found[0].sent_at = new Date().toISOString();
106+
const found = await store.shift();
107+
if (found) {
108+
log('Attempting to send previously queued event');
109109

110-
void send(found, true).catch(e => {
111-
log('Failed to retry sending', e);
112-
});
113-
}
114-
}, delay) as Timer;
110+
// We should to update the sent_at timestamp to the current time.
111+
found[0].sent_at = new Date().toISOString();
115112

116-
// We need to unref the timer in node.js, otherwise the node process never exit.
117-
if (typeof flushTimer !== 'number' && flushTimer.unref) {
118-
flushTimer.unref();
119-
}
113+
void send(found, true).catch(e => {
114+
log('Failed to retry sending', e);
115+
});
116+
}
117+
}, delay),
118+
) as Timer;
120119
}
121120

122121
function flushWithBackOff(): void {

packages/core/src/utils/promisebuffer.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { rejectedSyncPromise, resolvedSyncPromise } from './syncpromise';
2+
import { safeUnref } from './timer';
23

34
export interface PromiseBuffer<T> {
45
// exposes the internal array so tests can assert on the state of it.
@@ -77,10 +78,11 @@ export function makePromiseBuffer<T>(limit: number = 100): PromiseBuffer<T> {
7778
return drainPromise;
7879
}
7980

80-
const promises = [drainPromise, new Promise<boolean>(resolve => setTimeout(() => resolve(false), timeout))];
81+
const promises = [
82+
drainPromise,
83+
new Promise<boolean>(resolve => safeUnref(setTimeout(() => resolve(false), timeout))),
84+
];
8185

82-
// Promise.race will resolve to the first promise that resolves or rejects
83-
// So if the drainPromise resolves, the timeout promise will be ignored
8486
return Promise.race(promises);
8587
}
8688

packages/core/src/utils/timer.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Calls `unref` on a timer, if the method is available on @param timer.
3+
*
4+
* `unref()` is used to allow processes to exit immediately, even if the timer
5+
* is still running and hasn't resolved yet.
6+
*
7+
* Use this in places where code can run on browser or server, since browsers
8+
* do not support `unref`.
9+
*/
10+
export function safeUnref(timer: ReturnType<typeof setTimeout>): ReturnType<typeof setTimeout> {
11+
if (typeof timer === 'object' && typeof timer.unref === 'function') {
12+
timer.unref();
13+
}
14+
return timer;
15+
}

packages/core/test/lib/client.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2205,6 +2205,53 @@ describe('Client', () => {
22052205
}),
22062206
]);
22072207
});
2208+
2209+
test('flush returns immediately when nothing is processing', async () => {
2210+
vi.useFakeTimers();
2211+
expect.assertions(2);
2212+
2213+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
2214+
const client = new TestClient(options);
2215+
2216+
// just to ensure the client init'd
2217+
vi.advanceTimersByTime(100);
2218+
2219+
const elapsed = Date.now();
2220+
const done = client.flush(1000).then(result => {
2221+
expect(result).toBe(true);
2222+
expect(Date.now() - elapsed).toBeLessThan(2);
2223+
});
2224+
2225+
// ensures that only after 1 ms, we're already done flushing
2226+
vi.advanceTimersByTime(1);
2227+
await done;
2228+
});
2229+
2230+
test('flush with early exit when processing completes', async () => {
2231+
vi.useRealTimers();
2232+
expect.assertions(3);
2233+
2234+
const { makeTransport, getSendCalled, getSentCount } = makeFakeTransport(50);
2235+
2236+
const client = new TestClient(
2237+
getDefaultTestClientOptions({
2238+
dsn: PUBLIC_DSN,
2239+
enableSend: true,
2240+
transport: makeTransport,
2241+
}),
2242+
);
2243+
2244+
client.captureMessage('test');
2245+
expect(getSendCalled()).toEqual(1);
2246+
2247+
const startTime = Date.now();
2248+
await client.flush(5000);
2249+
const elapsed = Date.now() - startTime;
2250+
2251+
expect(getSentCount()).toEqual(1);
2252+
// if this flakes, remove the test
2253+
expect(elapsed).toBeLessThan(1000);
2254+
});
22082255
});
22092256

22102257
describe('sendEvent', () => {

packages/core/test/lib/utils/promisebuffer.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,4 +252,16 @@ describe('PromiseBuffer', () => {
252252
expect(e).toEqual(new Error('whoops'));
253253
}
254254
});
255+
256+
test('drain returns immediately when buffer is empty', async () => {
257+
const buffer = makePromiseBuffer();
258+
expect(buffer.$.length).toEqual(0);
259+
260+
const startTime = Date.now();
261+
const result = await buffer.drain(5000);
262+
const elapsed = Date.now() - startTime;
263+
264+
expect(result).toBe(true);
265+
expect(elapsed).toBeLessThan(100);
266+
});
255267
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { safeUnref } from '../../../src/utils/timer';
3+
4+
describe('safeUnref', () => {
5+
it('calls unref on a NodeJS timer', () => {
6+
const timeout = setTimeout(() => {}, 1000);
7+
const unrefSpy = vi.spyOn(timeout, 'unref');
8+
safeUnref(timeout);
9+
expect(unrefSpy).toHaveBeenCalledOnce();
10+
});
11+
12+
it('returns the timer', () => {
13+
const timeout = setTimeout(() => {}, 1000);
14+
const result = safeUnref(timeout);
15+
expect(result).toBe(timeout);
16+
});
17+
18+
it('handles multiple unref calls', () => {
19+
const timeout = setTimeout(() => {}, 1000);
20+
const unrefSpy = vi.spyOn(timeout, 'unref');
21+
22+
const result = safeUnref(timeout);
23+
result.unref();
24+
25+
expect(unrefSpy).toHaveBeenCalledTimes(2);
26+
});
27+
28+
it("doesn't throw for a browser timer", () => {
29+
const timer = safeUnref(385 as unknown as ReturnType<typeof setTimeout>);
30+
expect(timer).toBe(385);
31+
});
32+
});

packages/node-core/test/sdk/client.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,4 +386,26 @@ describe('NodeClient', () => {
386386
expect(processOffSpy).toHaveBeenNthCalledWith(1, 'beforeExit', expect.any(Function));
387387
});
388388
});
389+
390+
describe('flush', () => {
391+
it('flush returns immediately when nothing is processing', async () => {
392+
const options = getDefaultNodeClientOptions();
393+
const client = new NodeClient(options);
394+
395+
const startTime = Date.now();
396+
const result = await client.flush(1000);
397+
const elapsed = Date.now() - startTime;
398+
399+
expect(result).toBe(true);
400+
expect(elapsed).toBeLessThan(100);
401+
});
402+
403+
it('flush does not block process exit with unref timers', async () => {
404+
const options = getDefaultNodeClientOptions();
405+
const client = new NodeClient(options);
406+
407+
const result = await client.flush(5000);
408+
expect(result).toBe(true);
409+
});
410+
});
389411
});

0 commit comments

Comments
 (0)