fix(browser): stop an abandoned wake from clearing a replacement page's lock

Round five. Lifecycle came back clean; one finding left.

A wake cleared its entry from the waking map unconditionally. An abandoned wake
can outlive a close and the replacement page's own wake, so that cleanup could
drop the replacement's lock — and the next command would then see a live window
with no wake in flight and drive a renderer still being rebuilt, defeating the
coalescing fix from the round-three pass. Clear the entry only when it is still
the one this wake installed, matching how releases already do it.

Mutation-checked.
This commit is contained in:
Jinwoo-H
2026-08-19 19:07:12 -04:00
parent a1c9853e41
commit 59a3c5138b
2 changed files with 53 additions and 1 deletions
@@ -552,6 +552,52 @@ describe('OffscreenBrowserBackend reclamation', () => {
expect(await second).toBe(true)
})
it('does not let an abandoned wake clear a replacement page wake lock', async () => {
// Why: dropping the replacement's lock would let the next command see a
// live window with no wake in flight and drive a half-rebuilt renderer.
let releaseFirstSwap = (): void => {}
let releaseSecondSwap = (): 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<typeof vi.fn> }
bridge.onProcessSwap.mockImplementationOnce(
async () => new Promise<void>((resolve) => (releaseFirstSwap = resolve))
)
const abandoned = h.backend.wakeTab('a')
await Promise.resolve()
await h.backend.closeTab('a')
await h.backend.createTab({ url: 'https://replacement', browserPageId: 'a' })
h.clock.value += 120_000
await h.backend.reclaimIdlePages()
bridge.onProcessSwap.mockImplementationOnce(
async () => new Promise<void>((resolve) => (releaseSecondSwap = resolve))
)
const replacementWake = h.backend.wakeTab('a')
await Promise.resolve()
// The old wake finishing must not release the replacement's lock.
releaseFirstSwap()
expect(await abandoned).toBe(false)
let thirdSettled = false
const third = h.backend.wakeTab('a').then((value) => {
thirdSettled = true
return value
})
await Promise.resolve()
await Promise.resolve()
expect(thirdSettled).toBe(false)
releaseSecondSwap()
expect(await replacementWake).toBe(true)
expect(await third).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)
@@ -181,7 +181,13 @@ export class OffscreenBrowserBackend implements BrowserBackend {
try {
return await wake
} finally {
this.waking.delete(browserPageId)
// Why: an abandoned wake can outlive a close and a replacement page's own
// wake. Clearing the entry blindly would drop the replacement's lock, and
// the next command would see a live window with no wake in flight and
// drive a renderer that is still being rebuilt.
if (this.waking.get(browserPageId) === wake) {
this.waking.delete(browserPageId)
}
}
}