diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index ffb111f08..2ae2221d8 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -2119,8 +2119,19 @@ export class BrowserManager { if (!reason || page.isClosed()) return; guard.blocked = reason; console.warn(`[browse] ${reason} (navigation from ${req.frame().url() || 'a new page'}; tab reset to about:blank)`); - // Commit is enough to blank the tab; a bounded reset never holds the command for the full navigation timeout. - await page.goto('about:blank', { waitUntil: 'commit', timeout: 5_000 }).catch(() => {}); + // A target that fails fast commits its error page around the reset, so + // let it settle (bounded; a hanging target is cut off by the reset), + // then blank the tab until it stays blank (about:blank loads at once). + await Promise.race([req.response().catch(() => null), new Promise((resolve) => setTimeout(resolve, 1_000))]); + // Leaving an error page can commit a second about:blank after the reset + // resolves; wait for the main frame to stay quiet so the next command's + // navigation is not interrupted by it. + for (let attempt = 0; attempt < 3 && !page.isClosed(); attempt++) { + await page.goto('about:blank', { waitUntil: 'load', timeout: 5_000 }).catch(() => {}); + const late = await page.waitForEvent('framenavigated', { predicate: (frame) => frame === page.mainFrame(), timeout: 250 }) + .then(() => true, () => false); + if (!late && page.url() === 'about:blank') break; + } }).finally(() => { guard.pending.delete(check); }); guard.pending.add(check); }); diff --git a/browse/test/navigation-guard.test.ts b/browse/test/navigation-guard.test.ts index 736b47e43..45da751ca 100644 --- a/browse/test/navigation-guard.test.ts +++ b/browse/test/navigation-guard.test.ts @@ -13,6 +13,9 @@ let base: string; const LINK_LOCAL = 'http://169.254.170.2/v2/credentials'; const MAPPED = 'http://[::ffff:169.254.170.2]/v2/credentials'; +// Port 1 is unsafe in Chromium, so this blocked target fails at once (ERR_UNSAFE_PORT) +// and commits its error page around the guard's reset, as CI's mapped-address hop did. +const FAST_FAIL = 'http://[::ffff:169.254.170.2]:1/v2/credentials'; beforeAll(async () => { server = Bun.serve({ @@ -23,6 +26,7 @@ beforeAll(async () => { const html = (body: string) => new Response(`
${body}`, { headers: { 'Content-Type': 'text/html' } }); if (url.pathname === '/to-link-local') return Response.redirect(LINK_LOCAL, 302); if (url.pathname === '/to-mapped') return Response.redirect(MAPPED, 302); + if (url.pathname === '/to-fast-fail') return Response.redirect(FAST_FAIL, 302); if (url.pathname === '/to-safe') return Response.redirect(`${base}/safe`, 302); if (url.pathname === '/link') return html(`go`); if (url.pathname === '/script') return html(``); @@ -55,6 +59,14 @@ describe('D2: navigation guard', () => { expect(bm.getActiveSession().getPage().url()).toBe('about:blank'); }, 30_000); + test('a blocked target that fails at once still leaves the tab blank for the next command', async () => { + await expect(run('goto', [`${base}/to-fast-fail`])).rejects.toThrow(/cloud metadata or link-local/); + expect(bm.getActiveSession().getPage().url()).toBe('about:blank'); + const result = await run('goto', [`${base}/safe`]); + expect(result).toContain('Navigated to'); + expect(bm.getActiveSession().getPage().url()).toBe(`${base}/safe`); + }, 30_000); + test('clicking a link to a link-local address is refused', async () => { await run('goto', [`${base}/link`]); await expect(run('click', ['#go'])).rejects.toThrow(/cloud metadata or link-local/);