From af5a6270a3097ebecd008167ae2a8da51c7dfe8f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 5 Feb 2026 11:15:03 +0100 Subject: [PATCH 1/2] ref(core): Unref more timers in server code --- packages/core/src/index.ts | 2 + packages/core/src/instrument/fetch.ts | 29 +++++++----- packages/core/src/utils/anr.ts | 25 ++++++----- packages/core/src/utils/debounce.ts | 6 ++- packages/feedback/src/core/sendFeedback.ts | 4 +- .../node-core/src/integrations/anr/index.ts | 29 ++++++------ .../http/httpServerIntegration.ts | 11 +++-- .../integrations/local-variables/common.ts | 37 ++++++++------- .../integrations/local-variables/worker.ts | 5 +-- .../src/integrations/onuncaughtexception.ts | 22 ++++----- packages/node-core/src/sdk/client.ts | 14 +++--- .../src/event-loop-block-integration.ts | 11 ++++- .../src/event-loop-block-watchdog.ts | 35 ++++++++------- packages/profiling-node/src/integration.ts | 45 +++++++++---------- 14 files changed, 153 insertions(+), 122 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 30ace1803b1a..21d0c961183c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -331,6 +331,8 @@ export { vercelWaitUntil } from './utils/vercelWaitUntil'; export { flushIfServerless } from './utils/flushIfServerless'; export { SDK_VERSION } from './utils/version'; export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids'; +export { safeUnref } from './utils/timer'; + export { getFilenameToMetadataMap } from './metadata'; export { escapeStringForRegex } from './vendor/escapeStringForRegex'; diff --git a/packages/core/src/instrument/fetch.ts b/packages/core/src/instrument/fetch.ts index 590830ab4e20..7a89d0b41214 100644 --- a/packages/core/src/instrument/fetch.ts +++ b/packages/core/src/instrument/fetch.ts @@ -6,6 +6,7 @@ import { isError, isRequest } from '../utils/is'; import { addNonEnumerableProperty, fill } from '../utils/object'; import { supportsNativeFetch } from '../utils/supports'; import { timestampInSeconds } from '../utils/time'; +import { safeUnref } from '../utils/timer'; import { GLOBAL_OBJ } from '../utils/worldwide'; import { addHandler, maybeInstrument, triggerHandlers } from './handlers'; @@ -158,13 +159,15 @@ async function resolveResponse(res: Response | undefined, onFinishedResolving: ( const responseReader = body.getReader(); // Define a maximum duration after which we just cancel - const maxFetchDurationTimeout = setTimeout( - () => { - body.cancel().then(null, () => { - // noop - }); - }, - 90 * 1000, // 90s + const maxFetchDurationTimeout = safeUnref( + setTimeout( + () => { + body.cancel().then(null, () => { + // noop + }); + }, + 90 * 1000, // 90s + ), ); let readingActive = true; @@ -172,11 +175,13 @@ async function resolveResponse(res: Response | undefined, onFinishedResolving: ( let chunkTimeout; try { // abort reading if read op takes more than 5s - chunkTimeout = setTimeout(() => { - body.cancel().then(null, () => { - // noop on error - }); - }, 5000); + chunkTimeout = safeUnref( + setTimeout(() => { + body.cancel().then(null, () => { + // noop on error + }); + }, 5000), + ); // This .read() call will reject/throw when we abort due to timeouts through `body.cancel()` const { done } = await responseReader.read(); diff --git a/packages/core/src/utils/anr.ts b/packages/core/src/utils/anr.ts index 0c1e6a08b4bb..586cdc651364 100644 --- a/packages/core/src/utils/anr.ts +++ b/packages/core/src/utils/anr.ts @@ -1,6 +1,7 @@ import type { StackFrame } from '../types-hoist/stackframe'; import { filenameIsInApp } from './node-stack-trace'; import { UNKNOWN_FUNCTION } from './stacktrace'; +import { safeUnref } from './timer'; type WatchdogReturn = { /** Resets the watchdog timer */ @@ -28,20 +29,22 @@ export function watchdogTimer( let triggered = false; let enabled = true; - setInterval(() => { - const diffMs = timer.getTimeMs(); + safeUnref( + setInterval(() => { + const diffMs = timer.getTimeMs(); - if (triggered === false && diffMs > pollInterval + anrThreshold) { - triggered = true; - if (enabled) { - callback(); + if (triggered === false && diffMs > pollInterval + anrThreshold) { + triggered = true; + if (enabled) { + callback(); + } } - } - if (diffMs < pollInterval + anrThreshold) { - triggered = false; - } - }, 20); + if (diffMs < pollInterval + anrThreshold) { + triggered = false; + } + }, 20), + ); return { poll: () => { diff --git a/packages/core/src/utils/debounce.ts b/packages/core/src/utils/debounce.ts index 5f936d1e14e2..4137107afd3b 100644 --- a/packages/core/src/utils/debounce.ts +++ b/packages/core/src/utils/debounce.ts @@ -1,3 +1,5 @@ +import { safeUnref } from './timer'; + type DebouncedCallback = { (): void | unknown; flush: () => void | unknown; @@ -61,10 +63,10 @@ export function debounce(func: CallbackFunction, wait: number, options?: Debounc if (timerId) { clearTimeout(timerId); } - timerId = setTimeoutImpl(invokeFunc, wait); + timerId = safeUnref(setTimeoutImpl(invokeFunc, wait)); if (maxWait && maxTimerId === undefined) { - maxTimerId = setTimeoutImpl(invokeFunc, maxWait); + maxTimerId = safeUnref(setTimeoutImpl(invokeFunc, maxWait)); } return callbackReturnValue; diff --git a/packages/feedback/src/core/sendFeedback.ts b/packages/feedback/src/core/sendFeedback.ts index 712da5c269bf..a0a22c9f1304 100644 --- a/packages/feedback/src/core/sendFeedback.ts +++ b/packages/feedback/src/core/sendFeedback.ts @@ -1,5 +1,5 @@ import type { Event, EventHint, SendFeedback, SendFeedbackParams, TransportMakeRequestResponse } from '@sentry/core'; -import { captureFeedback, getClient, getCurrentScope, getLocationHref } from '@sentry/core'; +import { captureFeedback, getClient, getCurrentScope, getLocationHref, safeUnref } from '@sentry/core'; import { FEEDBACK_API_SOURCE } from '../constants'; /** @@ -35,7 +35,7 @@ export const sendFeedback: SendFeedback = ( // We want to wait for the feedback to be sent (or not) return new Promise((resolve, reject) => { // After 30s, we want to clear anyhow - const timeout = setTimeout(() => reject('Unable to determine if Feedback was correctly sent.'), 30_000); + const timeout = safeUnref(setTimeout(() => reject('Unable to determine if Feedback was correctly sent.'), 30_000)); const cleanup = client.on('afterSendEvent', (event: Event, response: TransportMakeRequestResponse) => { if (event.event_id !== eventId) { diff --git a/packages/node-core/src/integrations/anr/index.ts b/packages/node-core/src/integrations/anr/index.ts index e2207f9379c7..72876645afdb 100644 --- a/packages/node-core/src/integrations/anr/index.ts +++ b/packages/node-core/src/integrations/anr/index.ts @@ -10,6 +10,7 @@ import { getFilenameToDebugIdMap, getIsolationScope, GLOBAL_OBJ, + safeUnref, } from '@sentry/core'; import { NODE_VERSION } from '../../nodeVersion'; import type { NodeClient } from '../../sdk/client'; @@ -220,20 +221,20 @@ async function _startWorker( worker.terminate(); }); - const timer = setInterval(() => { - try { - const currentSession = getIsolationScope().getSession(); - // We need to copy the session object and remove the toJSON method so it can be sent to the worker - // serialized without making it a SerializedSession - const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; - // message the worker to tell it the main event loop is still running - worker.postMessage({ session, debugImages: getFilenameToDebugIdMap(initOptions.stackParser) }); - } catch { - // - } - }, options.pollInterval); - // Timer should not block exit - timer.unref(); + const timer = safeUnref( + setInterval(() => { + try { + const currentSession = getIsolationScope().getSession(); + // We need to copy the session object and remove the toJSON method so it can be sent to the worker + // serialized without making it a SerializedSession + const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; + // message the worker to tell it the main event loop is still running + worker.postMessage({ session, debugImages: getFilenameToDebugIdMap(initOptions.stackParser) }); + } catch { + // + } + }, options.pollInterval), + ); worker.on('message', (msg: string) => { if (msg === 'session-ended') { diff --git a/packages/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts index f37ddc07a125..f5dc6d280809 100644 --- a/packages/node-core/src/integrations/http/httpServerIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -13,6 +13,7 @@ import { getCurrentScope, getIsolationScope, httpRequestToRequestData, + safeUnref, stripUrlQueryAndFragment, withIsolationScope, } from '@sentry/core'; @@ -307,10 +308,12 @@ export function recordRequestSession( DEBUG_BUILD && debug.log('Sending request session aggregate due to client flush'); flushPendingClientAggregates(); }); - const timeout = setTimeout(() => { - DEBUG_BUILD && debug.log('Sending request session aggregate due to flushing schedule'); - flushPendingClientAggregates(); - }, sessionFlushingDelayMS).unref(); + const timeout = safeUnref( + setTimeout(() => { + DEBUG_BUILD && debug.log('Sending request session aggregate due to flushing schedule'); + flushPendingClientAggregates(); + }, sessionFlushingDelayMS), + ); } } }); diff --git a/packages/node-core/src/integrations/local-variables/common.ts b/packages/node-core/src/integrations/local-variables/common.ts index f86988b4cbfc..49a78fda850e 100644 --- a/packages/node-core/src/integrations/local-variables/common.ts +++ b/packages/node-core/src/integrations/local-variables/common.ts @@ -1,4 +1,5 @@ import type { Debugger } from 'node:inspector'; +import { safeUnref } from '@sentry/core'; export type Variables = Record; @@ -26,28 +27,30 @@ export function createRateLimiter( let retrySeconds = 5; let disabledTimeout = 0; - setInterval(() => { - if (disabledTimeout === 0) { - if (count > maxPerSecond) { - retrySeconds *= 2; - disable(retrySeconds); + safeUnref( + setInterval(() => { + if (disabledTimeout === 0) { + if (count > maxPerSecond) { + retrySeconds *= 2; + disable(retrySeconds); - // Cap at one day - if (retrySeconds > 86400) { - retrySeconds = 86400; + // Cap at one day + if (retrySeconds > 86400) { + retrySeconds = 86400; + } + disabledTimeout = retrySeconds; } - disabledTimeout = retrySeconds; - } - } else { - disabledTimeout -= 1; + } else { + disabledTimeout -= 1; - if (disabledTimeout === 0) { - enable(); + if (disabledTimeout === 0) { + enable(); + } } - } - count = 0; - }, 1_000).unref(); + count = 0; + }, 1_000), + ); return () => { count += 1; diff --git a/packages/node-core/src/integrations/local-variables/worker.ts b/packages/node-core/src/integrations/local-variables/worker.ts index 304c7d527ef7..04633dd60857 100644 --- a/packages/node-core/src/integrations/local-variables/worker.ts +++ b/packages/node-core/src/integrations/local-variables/worker.ts @@ -184,6 +184,5 @@ startDebugger().catch(e => { log('Failed to start debugger', e); }); -setInterval(() => { - // Stop the worker from exiting -}, 10_000); +// We purposefully not unref this interval to prevent the worker from exiting +setInterval(() => {}, 10_000); diff --git a/packages/node-core/src/integrations/onuncaughtexception.ts b/packages/node-core/src/integrations/onuncaughtexception.ts index 8afa70787a5c..41f2b32aa94d 100644 --- a/packages/node-core/src/integrations/onuncaughtexception.ts +++ b/packages/node-core/src/integrations/onuncaughtexception.ts @@ -1,4 +1,4 @@ -import { captureException, debug, defineIntegration, getClient } from '@sentry/core'; +import { captureException, debug, defineIntegration, getClient, safeUnref } from '@sentry/core'; import { isMainThread } from 'worker_threads'; import { DEBUG_BUILD } from '../debug-build'; import type { NodeClient } from '../sdk/client'; @@ -149,15 +149,17 @@ export function makeErrorHandler(client: NodeClient, options: OnUncaughtExceptio // note that after hitting this branch, we might catch more errors where (caughtSecondError && !calledFatalError) // we ignore them - they don't matter to us, we're just waiting for the second error timeout to finish caughtSecondError = true; - setTimeout(() => { - if (!calledFatalError) { - // it was probably case 1, let's treat err as the sendErr and call onFatalError - calledFatalError = true; - onFatalError(firstError, error); - } else { - // it was probably case 2, our first error finished capturing while we waited, cool, do nothing - } - }, timeout); // capturing could take at least sendTimeout to fail, plus an arbitrary second for how long it takes to collect surrounding source etc + safeUnref( + setTimeout(() => { + if (!calledFatalError) { + // it was probably case 1, let's treat err as the sendErr and call onFatalError + calledFatalError = true; + onFatalError(firstError, error); + } else { + // it was probably case 2, our first error finished capturing while we waited, cool, do nothing + } + }, timeout), + ); // capturing could take at least sendTimeout to fail, plus an arbitrary second for how long it takes to collect surrounding source etc } } } diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts index 80a233aa3954..0509ee2c6142 100644 --- a/packages/node-core/src/sdk/client.ts +++ b/packages/node-core/src/sdk/client.ts @@ -9,6 +9,7 @@ import { _INTERNAL_flushLogsBuffer, applySdkMetadata, debug, + safeUnref, SDK_VERSION, ServerRuntimeClient, } from '@sentry/core'; @@ -144,12 +145,13 @@ export class NodeClient extends ServerRuntimeClient { this._flushOutcomes(); }; - this._clientReportInterval = setInterval(() => { - DEBUG_BUILD && debug.log('Flushing client reports based on interval.'); - this._flushOutcomes(); - }, clientOptions.clientReportFlushInterval ?? DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS) - // Unref is critical for not preventing the process from exiting because the interval is active. - .unref(); + // We purposefully not unref this interval but we clear it in `this._clientReportOnExitFlushListener`. + this._clientReportInterval = safeUnref( + setTimeout(() => { + DEBUG_BUILD && debug.log('Flushing client reports based on interval.'); + this._flushOutcomes(); + }, clientOptions.clientReportFlushInterval ?? DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS), + ); process.on('beforeExit', this._clientReportOnExitFlushListener); } diff --git a/packages/node-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts index 7b5c4bc43430..287c7dbe26ef 100644 --- a/packages/node-native/src/event-loop-block-integration.ts +++ b/packages/node-native/src/event-loop-block-integration.ts @@ -9,7 +9,14 @@ import type { Integration, IntegrationFn, } from '@sentry/core'; -import { debug, defineIntegration, getClient, getFilenameToDebugIdMap, getIsolationScope } from '@sentry/core'; +import { + debug, + defineIntegration, + getClient, + getFilenameToDebugIdMap, + getIsolationScope, + safeUnref, +} from '@sentry/core'; import type { NodeClient } from '@sentry/node'; import { registerThread, threadPoll } from '@sentry-internal/node-native-stacktrace'; import type { ThreadBlockedIntegrationOptions, WorkerStartData } from './common'; @@ -72,7 +79,7 @@ function startPolling( const pollInterval = (integrationOptions.threshold || DEFAULT_THRESHOLD_MS) / POLL_RATIO; // unref so timer does not block exit - setInterval(() => poll(enabled, initOptions), pollInterval).unref(); + safeUnref(setInterval(() => poll(enabled, initOptions), pollInterval)); return { start: () => { diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts index a4eb696c7a95..531dc54b4105 100644 --- a/packages/node-native/src/event-loop-block-watchdog.ts +++ b/packages/node-native/src/event-loop-block-watchdog.ts @@ -10,6 +10,7 @@ import { makeSession, mergeScopeData, normalizeUrlToBase, + safeUnref, Scope, stripSentryFramesAndReverse, updateSession, @@ -298,23 +299,25 @@ async function sendBlockEvent(crashedThreadId: string): Promise { await transport.flush(2000); } -setInterval(async () => { - for (const [threadId, time] of Object.entries(getThreadsLastSeen())) { - if (time > threshold) { - if (triggeredThreads.has(threadId)) { - continue; - } +safeUnref( + setInterval(async () => { + for (const [threadId, time] of Object.entries(getThreadsLastSeen())) { + if (time > threshold) { + if (triggeredThreads.has(threadId)) { + continue; + } - log(`Blocked thread detected '${threadId}' last polled ${time} ms ago.`); - triggeredThreads.add(threadId); + log(`Blocked thread detected '${threadId}' last polled ${time} ms ago.`); + triggeredThreads.add(threadId); - try { - await sendBlockEvent(threadId); - } catch (error) { - log(`Failed to send event for thread '${threadId}':`, error); + try { + await sendBlockEvent(threadId); + } catch (error) { + log(`Failed to send event for thread '${threadId}':`, error); + } + } else { + triggeredThreads.delete(threadId); } - } else { - triggeredThreads.delete(threadId); } - } -}, pollInterval); + }, pollInterval), +); diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 17ce6f702639..04fe7c9a12e0 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -9,6 +9,7 @@ import { getIsolationScope, getRootSpan, LRUMap, + safeUnref, spanToJSON, uuid4, } from '@sentry/core'; @@ -305,21 +306,20 @@ class ContinuousProfiler { } // Enqueue a timeout to prevent profiles from running over max duration. - const timeout = global.setTimeout(() => { - DEBUG_BUILD && - debug.log( - '[Profiling] max profile duration elapsed, stopping profiling for:', - spanToJSON(span).description, - ); - - const profile = stopSpanProfile(span, profile_id); - if (profile) { - addToProfileQueue(profile_id, profile); - } - }, maxProfileDurationMs); + safeUnref( + global.setTimeout(() => { + DEBUG_BUILD && + debug.log( + '[Profiling] max profile duration elapsed, stopping profiling for:', + spanToJSON(span).description, + ); - // Unref timeout so it doesn't keep the process alive. - timeout.unref(); + const profile = stopSpanProfile(span, profile_id); + if (profile) { + addToProfileQueue(profile_id, profile); + } + }, maxProfileDurationMs), + ); getIsolationScope().setContext('profile', { profile_id }); spanToProfileIdMap.set(span, profile_id); @@ -539,15 +539,14 @@ class ContinuousProfiler { CpuProfilerBindings.startProfiling(chunk.id); DEBUG_BUILD && debug.log(`[Profiling] starting profiling chunk: ${chunk.id}`); - chunk.timer = global.setTimeout(() => { - DEBUG_BUILD && debug.log(`[Profiling] Stopping profiling chunk: ${chunk.id}`); - this._stopChunkProfiling(); - DEBUG_BUILD && debug.log('[Profiling] Starting new profiling chunk.'); - setImmediate(this._restartChunkProfiling.bind(this)); - }, CHUNK_INTERVAL_MS); - - // Unref timeout so it doesn't keep the process alive. - chunk.timer.unref(); + chunk.timer = safeUnref( + global.setTimeout(() => { + DEBUG_BUILD && debug.log(`[Profiling] Stopping profiling chunk: ${chunk.id}`); + this._stopChunkProfiling(); + DEBUG_BUILD && debug.log('[Profiling] Starting new profiling chunk.'); + setImmediate(this._restartChunkProfiling.bind(this)); + }, CHUNK_INTERVAL_MS), + ); } /** From f06c601367b4105a0551fce09fe986e869413940 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 9 Feb 2026 11:26:13 +0100 Subject: [PATCH 2/2] remove incorrect comment --- packages/node-core/src/sdk/client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts index 0509ee2c6142..0742444a93b4 100644 --- a/packages/node-core/src/sdk/client.ts +++ b/packages/node-core/src/sdk/client.ts @@ -145,7 +145,6 @@ export class NodeClient extends ServerRuntimeClient { this._flushOutcomes(); }; - // We purposefully not unref this interval but we clear it in `this._clientReportOnExitFlushListener`. this._clientReportInterval = safeUnref( setTimeout(() => { DEBUG_BUILD && debug.log('Flushing client reports based on interval.');