diff --git a/CHANGELOG.md b/CHANGELOG.md index d116d36ed648..8fa8c7179c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- fix(cloudflare): Route Durable Object teardown through the original `waitUntil` to avoid a flush-lock deadlock that prevented WebSocket hibernation ([#22328](https://github.com/getsentry/sentry-javascript/issues/22328)) ## 10.66.0 diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 1479278446fa..67649d26446a 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -15,7 +15,8 @@ import { withScope, } from '@sentry/core'; import type { CloudflareOptions } from './client'; -import { flushAndDispose } from './flush'; +import type { ExecutionContextCompat } from './executionContext'; +import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { ensureInstrumented } from './instrument'; import { init } from './sdk'; import { extractRpcMeta } from './utils/rpcMeta'; @@ -117,7 +118,10 @@ export function wrapMethodWithSentry( // see: https://github.com/getsentry/sentry-javascript/issues/13217 const context: typeof wrapperOptions.context | undefined = wrapperOptions.context; - const waitUntil = context?.waitUntil?.bind?.(context); + // see: https://github.com/getsentry/sentry-javascript/issues/22328 + const waitUntil = context + ? getOriginalWaitUntil(context as ExecutionContextCompat)?.bind(context) + : undefined; const storage = resolveOriginalStorage(context, thisArg); let scopeClient = scope.getClient(); diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index ae2e376cc555..c7be771f143e 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -1,5 +1,6 @@ import * as sentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeFlushLock } from '../src/flush'; import { getInstrumented } from '../src/instrument'; import * as sdk from '../src/sdk'; import { wrapMethodWithSentry } from '../src/wrapMethodWithSentry'; @@ -694,3 +695,53 @@ describe('wrapMethodWithSentry', () => { }); }); }); + +describe('wrapMethodWithSentry waitUntil teardown (hibernation regression)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Regression for #22328 + it('does not deadlock teardown against a concurrent waitUntil task', async () => { + const waitUntilPromises: Array> = []; + const context = { + waitUntil: vi.fn((promise: Promise) => { + waitUntilPromises.push(promise); + }), + } as unknown as ExecutionContext; + + // A prior invocation instrumented context.waitUntil + // (installs the flush lock) + const lock = makeFlushLock(context); + + // A concurrent, in-flight waitUntil task holds the flush lock + let resolveUserTask!: () => void; + const userTask = new Promise(resolve => { + resolveUserTask = resolve; + }); + context.waitUntil(userTask); + + // flush waits for the flush lock to drain. + mocks.flush.mockImplementationOnce(async () => { + await lock.finalize(); + return true; + }); + + const wrapped = wrapMethodWithSentry( + { options: { dsn: 'https://test@sentry.io/123' }, context, spanName: 'webSocketMessage' }, + vi.fn().mockResolvedValue('ok'), + ); + + await wrapped(); + + // Releasing the concurrent task drains the lock + resolveUserTask(); + + await expect(Promise.all(waitUntilPromises)).resolves.toBeDefined(); + expect(mocks.flush).toHaveBeenCalled(); + }); +});