From 6da5af9848b6a1e1dbd9fbbb67ee35c2a86a6694 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 27 May 2026 17:00:22 +0200 Subject: [PATCH 1/2] fix(cloudflare): Use original waitUntil to not create a deadlock --- packages/cloudflare/src/flush.ts | 28 +++++++++- packages/cloudflare/src/request.ts | 9 ++- packages/cloudflare/test/flush.test.ts | 77 +++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/src/flush.ts b/packages/cloudflare/src/flush.ts index e68399d468e7..4a44dfbf1b51 100644 --- a/packages/cloudflare/src/flush.ts +++ b/packages/cloudflare/src/flush.ts @@ -9,6 +9,7 @@ type FlushLock = { type FlushLockRegistry = { readonly locks: Set; + readonly originalWaitUntil: ExecutionContext['waitUntil']; }; type FlushLockInternal = FlushLock & { @@ -18,6 +19,30 @@ type FlushLockInternal = FlushLock & { const flushLockRegistries = new WeakMap(); +// Map from instrumented waitUntil to its original function +const originalWaitUntilMap = new WeakMap(); + +/** + * Returns the original (un-instrumented) waitUntil function for a context. + * This should be used when calling waitUntil with flushAndDispose to avoid deadlock. + * + * The flush lock mechanism wraps context.waitUntil to track pending tasks. + * If we call waitUntil(flushAndDispose(client)) through the instrumented version, + * it creates a deadlock because: + * 1. The instrumented waitUntil acquires the flush lock + * 2. flushAndDispose calls client.flush() which waits for the lock to be released + * 3. The lock won't be released until the waitUntil promise completes + * 4. The waitUntil promise won't complete until flush() returns + * + * By using the original waitUntil for flush operations, we bypass this issue. + */ +export function getOriginalWaitUntil(context: ExecutionContext): ExecutionContext['waitUntil'] | undefined { + // eslint-disable-next-line @typescript-eslint/unbound-method + const currentWaitUntil = context.waitUntil; + const original = originalWaitUntilMap.get(currentWaitUntil); + return original ?? currentWaitUntil; +} + /** * Enhances the given execution context by wrapping its `waitUntil` method with a proxy * to monitor pending tasks, and provides a flusher function to ensure all tasks @@ -67,8 +92,8 @@ function getOrCreateFlushLockRegistry(context: ExecutionContext): FlushLockRegis return existingRegistry; } - const registry: FlushLockRegistry = { locks: new Set() }; const originalWaitUntil = context.waitUntil.bind(context) as typeof context.waitUntil; + const registry: FlushLockRegistry = { locks: new Set(), originalWaitUntil }; const instrumentedWaitUntil: typeof context.waitUntil = promise => { // Snapshot active locks so locks created after this call do not wait for earlier waitUntil work. const locks = [...registry.locks]; @@ -87,6 +112,7 @@ function getOrCreateFlushLockRegistry(context: ExecutionContext): FlushLockRegis }; flushLockRegistries.set(instrumentedWaitUntil, registry); + originalWaitUntilMap.set(instrumentedWaitUntil, originalWaitUntil); context.waitUntil = instrumentedWaitUntil; return registry; diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index f89e93924e1b..a4c42ccbd7e8 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -14,7 +14,7 @@ import { } from '@sentry/core'; import { captureIncomingRequestBody } from './integrations/httpServer'; import type { CloudflareOptions } from './client'; -import { flushAndDispose } from './flush'; +import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils'; import { init } from './sdk'; import { classifyResponseStreaming } from './utils/streaming'; @@ -47,7 +47,12 @@ export function wrapRequestHandler( const { options, request, captureErrors = true } = wrapperOptions; const context = wrapperOptions.context; - const waitUntil = context?.waitUntil?.bind?.(context); + // Use getOriginalWaitUntil to get the un-instrumented waitUntil function. + // This is crucial to avoid deadlock: the flush lock mechanism wraps waitUntil + // to track pending tasks. If we use the instrumented version for flushAndDispose, + // it acquires the lock, then flushAndDispose tries to wait for the same lock, + // creating a deadlock. + const waitUntil = context ? getOriginalWaitUntil(context)?.bind(context) : undefined; const client = init({ ...options, ctx: context }); isolationScope.setClient(client); diff --git a/packages/cloudflare/test/flush.test.ts b/packages/cloudflare/test/flush.test.ts index 0226378aac1d..a0b4de60bb77 100644 --- a/packages/cloudflare/test/flush.test.ts +++ b/packages/cloudflare/test/flush.test.ts @@ -2,7 +2,7 @@ import { type ExecutionContext } from '@cloudflare/workers-types'; import * as sentryCore from '@sentry/core'; import { type Client } from '@sentry/core'; import { describe, expect, it, onTestFinished, vi } from 'vitest'; -import { flushAndDispose, makeFlushLock } from '../src/flush'; +import { flushAndDispose, getOriginalWaitUntil, makeFlushLock } from '../src/flush'; describe('Flush buffer test', () => { const waitUntilPromises: Promise[] = []; @@ -109,3 +109,78 @@ describe('flushAndDispose', () => { flushSpy.mockRestore(); }); }); + +describe('getOriginalWaitUntil', () => { + it('returns the original waitUntil before instrumentation', () => { + const originalWaitUntil = vi.fn(); + const context: ExecutionContext = { + waitUntil: originalWaitUntil, + passThroughOnException: vi.fn(), + }; + + const result = getOriginalWaitUntil(context); + expect(result).toBe(originalWaitUntil); + }); + + it('returns the original waitUntil after instrumentation', () => { + const originalWaitUntil = vi.fn(); + const context: ExecutionContext = { + waitUntil: originalWaitUntil, + passThroughOnException: vi.fn(), + }; + + makeFlushLock(context); + + const result = getOriginalWaitUntil(context); + + expect(result).not.toBe(context.waitUntil); + expect(result).toBeDefined(); + result!(Promise.resolve()); + expect(originalWaitUntil).toHaveBeenCalled(); + }); + + it('returns the original waitUntil after multiple instrumentations', () => { + const originalWaitUntil = vi.fn(); + const context: ExecutionContext = { + waitUntil: originalWaitUntil, + passThroughOnException: vi.fn(), + }; + + makeFlushLock(context); + makeFlushLock(context); + makeFlushLock(context); + + const result = getOriginalWaitUntil(context); + + expect(result).not.toBe(context.waitUntil); + result!(Promise.resolve()); + expect(originalWaitUntil).toHaveBeenCalled(); + }); + + it('allows flushAndDispose to complete when called via original waitUntil', async () => { + const waitUntilPromises: Promise[] = []; + const context: ExecutionContext = { + waitUntil: vi.fn(promise => { + waitUntilPromises.push(promise); + }), + passThroughOnException: vi.fn(), + }; + + const lock = makeFlushLock(context); + + const mockClient = { + flush: vi.fn(async () => { + await lock.finalize(); + return true; + }), + dispose: vi.fn(), + } as unknown as Client; + + const originalWaitUntil = getOriginalWaitUntil(context); + originalWaitUntil!.call(context, flushAndDispose(mockClient)); + + await vi.waitFor(() => Promise.all(waitUntilPromises)); + expect(mockClient.flush).toHaveBeenCalled(); + expect(mockClient.dispose).toHaveBeenCalled(); + }); +}); From be553e566b1133c29f80fa78219a92a86baf15d0 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 28 May 2026 08:55:38 +0200 Subject: [PATCH 2/2] fixup! fix(cloudflare): Use original waitUntil to not create a deadlock --- packages/cloudflare/src/flush.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/cloudflare/src/flush.ts b/packages/cloudflare/src/flush.ts index 4a44dfbf1b51..6dc7ade0d45f 100644 --- a/packages/cloudflare/src/flush.ts +++ b/packages/cloudflare/src/flush.ts @@ -19,9 +19,6 @@ type FlushLockInternal = FlushLock & { const flushLockRegistries = new WeakMap(); -// Map from instrumented waitUntil to its original function -const originalWaitUntilMap = new WeakMap(); - /** * Returns the original (un-instrumented) waitUntil function for a context. * This should be used when calling waitUntil with flushAndDispose to avoid deadlock. @@ -39,7 +36,7 @@ const originalWaitUntilMap = new WeakMap