From 95c303bf5fe00e05e5e9f92698e2f4e42f0360ef Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 18 Aug 2026 03:56:30 -0400 Subject: [PATCH] fix(browser): make a second wake wait for the first to finish rebuilding A wake materializes the window before it swaps the helper session and reloads the retained address, so the page reads as resident well before it is usable. The coalescing promise was consulted after that resident check, so a second command targeting the same page returned immediately and ran against a blank renderer whose session was still being torn down underneath it. Join the in-flight wake first. Mutation-checked: restoring the old order turns the new test red. --- .../offscreen-browser-backend-reclaim.test.ts | 31 +++++++++++++++++++ src/main/browser/offscreen-browser-backend.ts | 12 ++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/main/browser/offscreen-browser-backend-reclaim.test.ts b/src/main/browser/offscreen-browser-backend-reclaim.test.ts index 6bf12e15ea4..69122f18b5d 100644 --- a/src/main/browser/offscreen-browser-backend-reclaim.test.ts +++ b/src/main/browser/offscreen-browser-backend-reclaim.test.ts @@ -499,6 +499,37 @@ describe('OffscreenBrowserBackend reclamation', () => { }) }) + it('makes a second wake wait for the first to finish rebuilding', async () => { + // Why: materialize sets the window before the session swap and the reload, + // so the page looks resident long before it is usable. + let releaseSwap = (): void => {} + const h = createHarness() + await h.backend.createTab({ url: 'https://a', browserPageId: 'a' }) + h.clock.value += 120_000 + await h.backend.reclaimIdlePages() + + const bridge = h.bridge as unknown as { onProcessSwap: ReturnType } + bridge.onProcessSwap.mockImplementationOnce( + async () => new Promise((resolve) => (releaseSwap = resolve)) + ) + const first = h.backend.wakeTab('a') + await Promise.resolve() + await Promise.resolve() + + let secondSettled = false + const second = h.backend.wakeTab('a').then((value) => { + secondSettled = true + return value + }) + await Promise.resolve() + await Promise.resolve() + expect(secondSettled).toBe(false) + + releaseSwap() + expect(await first).toBe(true) + expect(await second).toBe(true) + }) + it('reports false when waking a page it does not own', async () => { const h = createHarness() expect(await h.backend.wakeTab('nope')).toBe(false) diff --git a/src/main/browser/offscreen-browser-backend.ts b/src/main/browser/offscreen-browser-backend.ts index 206dcc0eadf..271a7ea1cb1 100644 --- a/src/main/browser/offscreen-browser-backend.ts +++ b/src/main/browser/offscreen-browser-backend.ts @@ -130,14 +130,18 @@ export class OffscreenBrowserBackend implements BrowserBackend { return false } page.lastActivityAt = this.now() - const releasing = this.releasing.get(browserPageId) - if (!releasing && page.window && !page.window.isDestroyed()) { - return true - } + // Why: a wake materializes the window before it swaps the helper session + // and reloads the address, so the page looks resident well before it is + // usable. Join an in-flight wake first or a second command runs against a + // blank page whose session is still being torn down underneath it. const inFlight = this.waking.get(browserPageId) if (inFlight) { return inFlight } + const releasing = this.releasing.get(browserPageId) + if (!releasing && page.window && !page.window.isDestroyed()) { + return true + } const wake = (async (): Promise => { await releasing if (!this.pages.has(browserPageId)) {