From da11d8196ea2519f3dc727efd9cfe63a87533c5c Mon Sep 17 00:00:00 2001 From: shaomingbo Date: Sun, 2 Aug 2026 15:27:50 +0800 Subject: [PATCH 1/2] fix(browser): recover stale page before evaluate --- src/browser/page.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/browser/page.ts | 13 +++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts index 549ea1b0f..da493006f 100644 --- a/src/browser/page.test.ts +++ b/src/browser/page.test.ts @@ -93,6 +93,45 @@ describe('Page.evaluate', () => { expect(sendCommandMock).toHaveBeenCalledTimes(2); }); + it('rebinds before retrying when the active page identity goes stale', async () => { + sendCommandFullMock + .mockResolvedValueOnce({ data: { url: 'https://example.com/jobs' }, page: 'stale-page' }) + .mockResolvedValueOnce({ data: { url: 'https://example.com/jobs' }, page: 'fresh-page' }); + sendCommandMock + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('Page not found: stale-page — stale page identity')) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(42); + + const page = new Page('site:example', undefined, undefined, undefined, 'adapter', 'persistent'); + await page.goto('https://example.com/jobs'); + + await expect(page.evaluate('21 + 21')).resolves.toBe(42); + expect(page.getActivePage()).toBe('fresh-page'); + expect(sendCommandFullMock).toHaveBeenCalledTimes(2); + expect(sendCommandFullMock.mock.calls[1]).toEqual([ + 'navigate', + expect.not.objectContaining({ page: expect.anything() }), + ]); + expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({ + code: '21 + 21', + page: 'fresh-page', + })); + }); + + it('does not guess a replacement for an explicitly selected stale page', async () => { + sendCommandMock.mockRejectedValueOnce( + new Error('Page not found: selected-page — stale page identity'), + ); + + const page = new Page('default'); + page.setActivePage('selected-page'); + + await expect(page.evaluate('21 + 21')).rejects.toThrow('stale page identity'); + expect(sendCommandFullMock).not.toHaveBeenCalled(); + expect(sendCommandMock).toHaveBeenCalledTimes(1); + }); + it('serializes function-form evaluate calls with JSON args', async () => { sendCommandMock.mockResolvedValueOnce('/opencli'); diff --git a/src/browser/page.ts b/src/browser/page.ts index 7994ff30b..7d90ad44d 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -29,8 +29,8 @@ function isUnsupportedNetworkCaptureError(err: unknown): boolean { // The extension throws "Page not found: — stale page identity" when our cached // `_page` targetId no longer maps to a live tab — e.g. the user closed the automation // window, or a long-running script left the cache pointing at an evicted target. -// Detect that signature so goto() can drop the stale id and let resolveTab fall back -// to the session lease (or create a fresh tab). +// Detect that signature so page-scoped operations can drop the stale id and let +// resolveTab fall back to the session lease (or create a fresh tab). function isStalePageIdentityError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err); return message.includes('stale page identity') || /^Page not found:\s*\S+\s*$/.test(message); @@ -177,6 +177,15 @@ export class Page extends BasePage { try { return await sendCommand('exec', { code, ...this._cmdOpts() }); } catch (err) { + // resolveTabId rejects a stale target before page code can run, so one retry is + // safe. Revisit the last known URL without the dead identity first; retrying the + // same exec immediately would only send the stale target again. + if (isStalePageIdentityError(err) && this._page !== undefined && this._lastUrl !== null) { + const lastUrl = this._lastUrl; + this._page = undefined; + await this.goto(lastUrl); + return sendCommand('exec', { code, ...this._cmdOpts() }); + } const advice = classifyBrowserError(err); if (advice.kind !== 'target-navigation') throw err; await new Promise((resolve) => setTimeout(resolve, advice.delayMs)); From 9d5e27a4128a267190b9eae67aaabaa5f716532b Mon Sep 17 00:00:00 2001 From: shaomingbo Date: Sun, 2 Aug 2026 16:04:28 +0800 Subject: [PATCH 2/2] fix(browser): rebind stale page during goto settle --- src/browser/page.test.ts | 23 +++++++++++++++++++++++ src/browser/page.ts | 18 ++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts index da493006f..d98f786a6 100644 --- a/src/browser/page.test.ts +++ b/src/browser/page.test.ts @@ -362,6 +362,29 @@ describe('Page active target tracking', () => { expect(retryCall[1]).not.toHaveProperty('page'); }); + it('rebinds when the settle retry exposes a stale page identity', async () => { + sendCommandFullMock + .mockResolvedValueOnce({ data: { url: 'https://example.com/jobs' }, page: 'stale-page' }) + .mockResolvedValueOnce({ data: { url: 'https://example.com/jobs' }, page: 'fresh-page' }); + sendCommandMock + .mockRejectedValueOnce(new Error('Inspected target navigated or closed')) + .mockRejectedValueOnce(new Error('Page not found: stale-page — stale page identity')) + .mockResolvedValueOnce(null); + + const page = new Page('site:boss', undefined, undefined, undefined, 'adapter', 'persistent'); + + await expect(page.goto('https://example.com/jobs')).resolves.toBeUndefined(); + expect(page.getActivePage()).toBe('fresh-page'); + expect(sendCommandFullMock).toHaveBeenCalledTimes(2); + expect(sendCommandFullMock.mock.calls[1]).toEqual([ + 'navigate', + expect.not.objectContaining({ page: expect.anything() }), + ]); + expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({ + page: 'fresh-page', + })); + }); + it('retries on a bare "Page not found:" error without the stale-identity suffix', async () => { // Under concurrent adapter calls the extension can reject with just // "Page not found: " (no "— stale page identity" suffix) when the cached diff --git a/src/browser/page.ts b/src/browser/page.ts index 7d90ad44d..7f5dbfa12 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -122,9 +122,26 @@ export class Page extends BasePage { code: combinedCode, ...this._cmdOpts(), }; + const recoverStalePageIdentity = async (err: unknown): Promise => { + if (!isStalePageIdentityError(err) || this._page === undefined) return false; + + this._page = undefined; + const rebound = await sendCommandFull('navigate', { + url, + ...this._cmdOpts(), + }); + if (rebound.page) this._page = rebound.page; + this._lastUrl = url; + await sendCommand('exec', { + code: combinedCode, + ...this._cmdOpts(), + }); + return true; + }; try { await sendCommand('exec', combinedOpts); } catch (err) { + if (await recoverStalePageIdentity(err)) return; const advice = classifyBrowserError(err); // Only settle-retry on target navigation (SPA client-side redirects). // Extension/daemon errors are already retried by sendCommandRaw — @@ -134,6 +151,7 @@ export class Page extends BasePage { await new Promise((r) => setTimeout(r, advice.delayMs)); await sendCommand('exec', combinedOpts); } catch (retryErr) { + if (await recoverStalePageIdentity(retryErr)) return; if (classifyBrowserError(retryErr).kind !== 'target-navigation') throw retryErr; } }