From 6513a65802a868fccc03f68038bc1c6159b626cc Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 16 Jul 2026 16:33:32 -0700 Subject: [PATCH 1/2] fix(cloudflare): Route DO teardown through original `waitUntil` Avoid a flush-lock deadlock that prevents WebSocket hibernation. fix: #22328 fix: JS-3067 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a20a5cddfd5f..071aed23b0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,11 @@ ## 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)) Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you for your contributions! - feat(replay): Allow skipping the final flush when stopping recording via `stop({ flush: false })` ([#22300](https://github.com/getsentry/sentry-javascript/pull/22300)) +- 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 From 6756efb181a987883a743d103267e009e065ce52 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 16 Jul 2026 16:49:32 -0700 Subject: [PATCH 2/2] fix(cloudflare): Bind Durable Object built-in handlers Bind handlers per instance so a second instance in the same isolate no longer reuses the first instance's captured context and options fix: #22328 fix: JS-3067 --- CHANGELOG.md | 1 + packages/cloudflare/src/durableobject.ts | 12 ++++--- .../cloudflare/test/durableobject.test.ts | 32 +++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 071aed23b0ec..5d4da4d662db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you - feat(replay): Allow skipping the final flush when stopping recording via `stop({ flush: false })` ([#22300](https://github.com/getsentry/sentry-javascript/pull/22300)) - 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)) +- fix(cloudflare): Bind Durable Object built-in handlers (`fetch`/`alarm`/`webSocket*`) per instance so a second instance in the same isolate no longer reuses the first instance's captured context and options ([#22328](https://github.com/getsentry/sentry-javascript/issues/22328)) ## 10.66.0 diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 0c0b1262156c..42bfcab834cf 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -72,9 +72,11 @@ export function instrumentDurableObjectWithSentry< // Any other public methods on the Durable Object instance are RPC calls. + // Bind each built-in handler to this instance before wrapping. + // See https://github.com/getsentry/sentry-javascript/issues/22328 if (obj.fetch && typeof obj.fetch === 'function') { obj.fetch = ensureInstrumented( - obj.fetch, + obj.fetch.bind(obj), original => new Proxy(original, { apply(target, thisArg, args) { @@ -97,28 +99,28 @@ export function instrumentDurableObjectWithSentry< startNewTrace: true, origin: 'auto.faas.cloudflare.durable_object', }, - obj.alarm, + obj.alarm.bind(obj), ); } if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { obj.webSocketMessage = wrapMethodWithSentry( { options, context, spanName: 'webSocketMessage', origin: 'auto.faas.cloudflare.durable_object' }, - obj.webSocketMessage, + obj.webSocketMessage.bind(obj), ); } if (obj.webSocketClose && typeof obj.webSocketClose === 'function') { obj.webSocketClose = wrapMethodWithSentry( { options, context, spanName: 'webSocketClose', origin: 'auto.faas.cloudflare.durable_object' }, - obj.webSocketClose, + obj.webSocketClose.bind(obj), ); } if (obj.webSocketError && typeof obj.webSocketError === 'function') { obj.webSocketError = wrapMethodWithSentry( { options, context, spanName: 'webSocketError', origin: 'auto.faas.cloudflare.durable_object' }, - obj.webSocketError, + obj.webSocketError.bind(obj), (_, error) => captureException(error, { mechanism: { diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index 83b473fabe4d..ec0c9e8ec708 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -81,6 +81,38 @@ describe('instrumentDurableObjectWithSentry', () => { expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); + // Regression for #22328 + // built-in handlers live on the class prototype. + // ensureInstrumented keys its global cache on the original function + // reference, so without per-instance binding a second instance in the + // same isolate reuses the first instance's wrapper. + it('Built-in handlers do not stick to the first instance options across a shared isolate', async () => { + const mockContext = { + waitUntil: vi.fn(), + } as any; + const mockEnv = {} as any; + const initCore = vi.spyOn(SentryCore, 'initAndBind'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + const options = vi.fn().mockReturnValueOnce({ orgId: 1 }).mockReturnValueOnce({ orgId: 2 }); + + const testClass = class { + webSocketMessage() {} + }; + const Instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + + const instance1 = Reflect.construct(Instrumented, [mockContext, mockEnv]); + const instance2 = Reflect.construct(Instrumented, [mockContext, mockEnv]); + + // Each instance must get its own wrapper, not the first instance's cached proxy. + expect(instance2.webSocketMessage).not.toBe(instance1.webSocketMessage); + + await instance1.webSocketMessage(); + await instance2.webSocketMessage(); + + expect(initCore).nthCalledWith(1, expect.any(Function), expect.objectContaining({ orgId: 1 })); + expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); + }); + it('does not create RPC spans without metadata when both RPC options are set', () => { const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined);