From a92cc5756c84a74e3157299aacd7b9dbf85bfe9b Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Sat, 29 Aug 2026 02:50:29 +0600 Subject: [PATCH 1/3] fix(har): do not hang when response extra info never arrives Chromium may advertise `hasExtraInfo` on a response and then never deliver the extra info, or stop reporting the request at all after responseReceived. Two changes make this survivable: - when the request finishes without extra info for some of its responses, resolve their raw header promises with the provisional headers, in the same way as for responses without the extra info flag; - when the HAR recording stops, drop the pending raw header upgrade barriers instead of waiting for them - provisional headers are already recorded as a fallback for exactly this case. Fixes: https://github.com/microsoft/playwright/issues/42448 --- .../src/server/chromium/crNetworkManager.ts | 13 +- .../src/server/har/harTracer.ts | 29 +++- tests/library/har.spec.ts | 126 +++++++++++++++++- 3 files changed, 162 insertions(+), 6 deletions(-) diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index f9a973699f206..215f1632bd33c 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -898,7 +898,18 @@ class ResponseExtraInfoTracker { return; } - // We are not done yet. + // The request is over, so no more extra info can arrive for it. Fall back to + // the provisional headers for the responses without extra info, in the same + // way as for the responses without the extra info flag. Otherwise, waiting for + // the raw headers would hang forever, e.g. when saving the HAR. + // See https://github.com/microsoft/playwright/issues/42448. + for (let i = info.responseReceivedExtraInfo.length; i < info.responses.length; i++) { + const response = info.responses[i]; + response.request().setRawRequestHeaders(null); + response.setResponseHeadersSize(null); + response.setRawResponseHeaders(null); + } + this._stopTracking(info.requestId); } private _stopTracking(requestId: string) { diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 923ab77d2c5e3..d85a795499c29 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -66,6 +66,10 @@ type HarTracerOptions = { export class HarTracer { private _context: BrowserContext | APIRequestContext; private _barrierPromises = new Set>(); + // Raw header upgrade barriers among the barrier promises. Provisional headers + // are recorded as a fallback for them, so they can be dropped when the + // recording stops instead of waiting for the extra info that may never arrive. + private _headerUpgradeBarriers = new Set>(); private _delegate: HarTracerDelegate; private _options: HarTracerOptions; private _pageEntries: har.Page[] = []; @@ -198,14 +202,23 @@ export class HarTracer { this._addBarrier(page, promise); } - private _addBarrier(target: Page | Worker | null, promise: Promise) { + private _addBarrier(target: Page | Worker | null, promise: Promise): Promise | null { if (!target) return null; if (!this._options.waitForContentOnStop) - return; + return null; const race = target.openScope.safeRace(promise); this._barrierPromises.add(race); race.then(() => this._barrierPromises.delete(race)); + return race; + } + + private _addHeaderUpgradeBarrier(target: Page | Worker | null, promise: Promise) { + const race = this._addBarrier(target, promise); + if (!race) + return; + this._headerUpgradeBarriers.add(race); + race.then(() => this._headerUpgradeBarriers.delete(race)); } private _onAPIRequest(event: APIRequestEvent) { @@ -619,13 +632,13 @@ export class HarTracer { } this._recordRequestOverrides(harEntry, request); - this._addBarrier(page || request.serviceWorker(), request.internalRawRequestHeaders().then(headers => { + this._addHeaderUpgradeBarrier(page || request.serviceWorker(), request.internalRawRequestHeaders().then(headers => { this._recordRequestHeadersAndCookies(harEntry, headers); })); // Record available headers including redirect location in case the tracing is stopped before // response extra info is received (in Chromium). this._recordResponseHeaders(harEntry, response.headers()); - this._addBarrier(page || request.serviceWorker(), response.internalRawResponseHeaders().then(headers => { + this._addHeaderUpgradeBarrier(page || request.serviceWorker(), response.internalRawResponseHeaders().then(headers => { this._recordResponseHeaders(harEntry, headers); })); } @@ -653,6 +666,13 @@ export class HarTracer { } async flush() { + // Drop the pending raw header upgrades: provisional headers are already recorded + // for them as a fallback. If the response extra info has not arrived by now, + // the request is most likely not going to finish, and waiting for the upgrade + // would hang the flush, e.g. when saving the HAR at the context close. + // See https://github.com/microsoft/playwright/issues/42448. + for (const barrier of this._headerUpgradeBarriers) + this._barrierPromises.delete(barrier); await Promise.all(this._barrierPromises); } @@ -660,6 +680,7 @@ export class HarTracer { this._started = false; eventsHelper.removeEventListeners(this._eventListeners); this._barrierPromises.clear(); + this._headerUpgradeBarriers.clear(); const context = this._context instanceof BrowserContext ? this._context : undefined; const log: har.Log = { diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 73d2e8bf52aa4..c8a2e17e9e3c0 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -18,7 +18,9 @@ import { browserTest as it, expect } from '../config/browserTest'; import fs from 'fs'; import path from 'path'; -import type { BrowserContext, BrowserContextOptions } from 'playwright-core'; +import http from 'http'; +import { WebSocket, WebSocketServer } from 'ws'; +import type { BrowserContext, BrowserContextOptions, BrowserType } from 'playwright-core'; import type { AddressInfo } from 'net'; import type { Log } from '../../packages/isomorphic/trace/versions/har'; import { parseHar } from '../config/utils'; @@ -940,6 +942,128 @@ it('should not hang on slow chunked response', async ({ browserName, browser, co expect(log.browser!.version).toBe(browser.version()); }); +// Chromium may advertise `hasExtraInfo` on a response and then never deliver the +// extra info, or stop reporting the request at all after `responseReceived`. +// To cover this, we connect over a CDP websocket proxy that withholds the +// corresponding events for a single request identified by url. +// See https://github.com/microsoft/playwright/issues/42448. +async function recordHarWithCensoredCdpEvents( + browserType: BrowserType, + server: TestServer, + testInfo: any, + censor: 'extraInfo' | 'extraInfoAndFinish'): Promise { + const port = 9339 + testInfo.workerIndex; + const browserServer = await browserType.launch({ args: ['--remote-debugging-port=' + port] }); + const json = await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${port}/json/version/`, resp => { + let data = ''; + resp.on('data', chunk => data += chunk); + resp.on('end', () => resolve(data)); + }).on('error', reject); + }); + const upstreamUrl: string = JSON.parse(json).webSocketDebuggerUrl; + + let targetRequestId: string | undefined; + let dropped = 0; + const wss = new WebSocketServer({ port: 0, perMessageDeflate: false }); + wss.on('connection', (client: WebSocket) => { + const upstream = new WebSocket(upstreamUrl, { perMessageDeflate: false }); + const pending: string[] = []; + upstream.on('open', () => pending.splice(0).forEach(message => upstream.send(message))); + client.on('message', data => { + if (upstream.readyState === WebSocket.OPEN) + upstream.send(data.toString()); + else + pending.push(data.toString()); + }); + upstream.on('message', data => { + const text = data.toString(); + let message: any; + try { + message = JSON.parse(text); + } catch { + client.send(text); + return; + } + const params = message.params || {}; + if (message.method === 'Network.requestWillBeSent' && params.request?.url?.includes('/target.js')) + targetRequestId = params.requestId; + const isTarget = !!targetRequestId && params.requestId === targetRequestId; + if (isTarget && ( + message.method === 'Network.responseReceivedExtraInfo' || + (censor === 'extraInfoAndFinish' && message.method === 'Network.loadingFinished') + )) { + dropped++; + return; + } + client.send(text); + }); + const bye = () => { + try { client.close(); } catch {} + try { upstream.close(); } catch {} + }; + client.on('close', bye); + upstream.on('close', bye); + client.on('error', bye); + upstream.on('error', bye); + }); + + try { + const cdpBrowser = await browserType.connectOverCDP(`ws://127.0.0.1:${(wss.address() as AddressInfo).port}/`); + server.setRoute('/target.js', (req, res) => { + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end('window.targetLoaded = true;'); + }); + const harPath = testInfo.outputPath('test.har'); + const context = await cdpBrowser.newContext({ recordHar: { path: harPath } }); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(url => { + const script = document.createElement('script'); + script.src = url; + document.body.appendChild(script); + }, server.PREFIX + '/target.js'); + + // Wait for the events to be withheld rather than guessing with a timeout: + // closing before the request was issued would pass for the wrong reason. + const expectedDropped = censor === 'extraInfoAndFinish' ? 2 : 1; + const deadline = Date.now() + 10000; + while (dropped < expectedDropped && Date.now() < deadline) + await page.waitForTimeout(50); + expect(dropped, 'number of withheld CDP events').toBe(expectedDropped); + + // Give the tracer a chance to register its raw header barriers before closing. + await page.waitForTimeout(500); + await context.close(); + return JSON.parse(fs.readFileSync(harPath).toString())['log'] as Log; + } finally { + await browserServer.close(); + await new Promise(f => wss.close(() => f(null))); + } +} + +it('should not hang when response extra info never arrives', async ({ browserName, browserType, server }, testInfo) => { + it.skip(browserName !== 'chromium', 'CDP-specific'); + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42448' }); + // `Network.loadingFinished` is delivered, but `Network.responseReceivedExtraInfo` never arrives. + const log = await recordHarWithCensoredCdpEvents(browserType, server, testInfo, 'extraInfo'); + const entry = log.entries.find(e => e.request.url.includes('/target.js'))!; + // Provisional response headers are recorded as a fallback for the raw ones. + expect(entry.response.status).toBe(200); + expect(entry.response.headers.some(h => h.name.toLowerCase() === 'content-type')).toBe(true); +}); + +it('should not hang when a response never finishes', async ({ browserName, browserType, server }, testInfo) => { + it.skip(browserName !== 'chromium', 'CDP-specific'); + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42448' }); + // Neither `Network.responseReceivedExtraInfo` nor `Network.loadingFinished` ever arrive. + const log = await recordHarWithCensoredCdpEvents(browserType, server, testInfo, 'extraInfoAndFinish'); + const entry = log.entries.find(e => e.request.url.includes('/target.js'))!; + // Provisional response headers are recorded as a fallback for the raw ones. + expect(entry.response.status).toBe(200); + expect(entry.response.headers.some(h => h.name.toLowerCase() === 'content-type')).toBe(true); +}); + it('should close the context when saving the har fails', async ({ contextFactory, server }, testInfo) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42231' }); const filePath = testInfo.outputPath('not-a-directory'); From 2148b48384ec8e5021d343910276e3b03a59de52 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Sat, 29 Aug 2026 06:35:07 +0600 Subject: [PATCH 2/3] fix(har): keep waiting for extra info after loadingFinished Revert the eager resolution of pending responses with provisional headers when a request finishes: responseReceivedExtraInfo can still arrive after loadingFinished. The HarTracer flush() change alone is enough to avoid hanging, as provisional headers are recorded as a fallback. Fixes: https://github.com/microsoft/playwright/pull/42450 (review) --- .../src/server/chromium/crNetworkManager.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index 215f1632bd33c..f9a973699f206 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -898,18 +898,7 @@ class ResponseExtraInfoTracker { return; } - // The request is over, so no more extra info can arrive for it. Fall back to - // the provisional headers for the responses without extra info, in the same - // way as for the responses without the extra info flag. Otherwise, waiting for - // the raw headers would hang forever, e.g. when saving the HAR. - // See https://github.com/microsoft/playwright/issues/42448. - for (let i = info.responseReceivedExtraInfo.length; i < info.responses.length; i++) { - const response = info.responses[i]; - response.request().setRawRequestHeaders(null); - response.setResponseHeadersSize(null); - response.setRawResponseHeaders(null); - } - this._stopTracking(info.requestId); + // We are not done yet. } private _stopTracking(requestId: string) { From 70ff634cb976b90ceb566849a63b835686a54d93 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Sat, 29 Aug 2026 06:51:47 +0600 Subject: [PATCH 3/3] fix(har): also drop pending sizes and compression barriers on flush With the response extra info never arriving, the request can still finish normally (Network.loadingFinished is delivered). The HAR tracer then adds sizes and compression calculation barriers that await the extra-info-fed response headers size promise, which never resolves. Since tracing flush runs before the pages are closed, these barriers hang context.close() the same way the raw header barriers did. Treat all barriers that only upgrade already-recorded provisional data as droppable on flush, not just the raw header ones. The extra info is not force-resolved server side because it can still arrive after loadingFinished. Fixes: https://github.com/microsoft/playwright/issues/42448 --- .../src/server/har/harTracer.ts | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index d85a795499c29..af0ecaa8b3e75 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -66,10 +66,11 @@ type HarTracerOptions = { export class HarTracer { private _context: BrowserContext | APIRequestContext; private _barrierPromises = new Set>(); - // Raw header upgrade barriers among the barrier promises. Provisional headers - // are recorded as a fallback for them, so they can be dropped when the - // recording stops instead of waiting for the extra info that may never arrive. - private _headerUpgradeBarriers = new Set>(); + // Barriers that only upgrade already-recorded provisional data (raw headers, + // sizes, compression) and wait for the network "extra info" that may never + // arrive, see https://github.com/microsoft/playwright/issues/42448. They can + // be dropped when the recording stops instead of hanging the flush. + private _upgradeBarriers = new Set>(); private _delegate: HarTracerDelegate; private _options: HarTracerOptions; private _pageEntries: har.Page[] = []; @@ -213,12 +214,12 @@ export class HarTracer { return race; } - private _addHeaderUpgradeBarrier(target: Page | Worker | null, promise: Promise) { + private _addUpgradeBarrier(target: Page | Worker | null, promise: Promise) { const race = this._addBarrier(target, promise); if (!race) return; - this._headerUpgradeBarriers.add(race); - race.then(() => this._headerUpgradeBarriers.delete(race)); + this._upgradeBarriers.add(race); + race.then(() => this._upgradeBarriers.delete(race)); } private _onAPIRequest(event: APIRequestEvent) { @@ -379,7 +380,7 @@ export class HarTracer { } }; if (compressionCalculationBarrier) - this._addBarrier(page || request.serviceWorker(), compressionCalculationBarrier.barrier); + this._addUpgradeBarrier(page || request.serviceWorker(), compressionCalculationBarrier.barrier); const promise = response.internalBody().then(buffer => { if (this._options.omitScripts && request.resourceType() === 'script') { @@ -409,7 +410,7 @@ export class HarTracer { this._computeHarEntryTotalTime(harEntry); if (!this._options.omitSizes) { - this._addBarrier(page || request.serviceWorker(), response.internalSizes().then(sizes => { + this._addUpgradeBarrier(page || request.serviceWorker(), response.internalSizes().then(sizes => { harEntry.response.bodySize = sizes.responseBodySize; harEntry.response.headersSize = sizes.responseHeadersSize; harEntry.response._transferSize = sizes.transferSize; @@ -632,13 +633,13 @@ export class HarTracer { } this._recordRequestOverrides(harEntry, request); - this._addHeaderUpgradeBarrier(page || request.serviceWorker(), request.internalRawRequestHeaders().then(headers => { + this._addUpgradeBarrier(page || request.serviceWorker(), request.internalRawRequestHeaders().then(headers => { this._recordRequestHeadersAndCookies(harEntry, headers); })); // Record available headers including redirect location in case the tracing is stopped before // response extra info is received (in Chromium). this._recordResponseHeaders(harEntry, response.headers()); - this._addHeaderUpgradeBarrier(page || request.serviceWorker(), response.internalRawResponseHeaders().then(headers => { + this._addUpgradeBarrier(page || request.serviceWorker(), response.internalRawResponseHeaders().then(headers => { this._recordResponseHeaders(harEntry, headers); })); } @@ -666,12 +667,13 @@ export class HarTracer { } async flush() { - // Drop the pending raw header upgrades: provisional headers are already recorded - // for them as a fallback. If the response extra info has not arrived by now, - // the request is most likely not going to finish, and waiting for the upgrade - // would hang the flush, e.g. when saving the HAR at the context close. + // Drop the pending data upgrade barriers: provisional data is already + // recorded for them as a fallback. The extra info they wait for can arrive + // late (e.g. after loadingFinished), so it is not resolved on the server + // side - but once the recording stops, waiting for the upgrade would hang + // the flush, e.g. when saving the HAR at the context close. // See https://github.com/microsoft/playwright/issues/42448. - for (const barrier of this._headerUpgradeBarriers) + for (const barrier of this._upgradeBarriers) this._barrierPromises.delete(barrier); await Promise.all(this._barrierPromises); } @@ -680,7 +682,7 @@ export class HarTracer { this._started = false; eventsHelper.removeEventListeners(this._eventListeners); this._barrierPromises.clear(); - this._headerUpgradeBarriers.clear(); + this._upgradeBarriers.clear(); const context = this._context instanceof BrowserContext ? this._context : undefined; const log: har.Log = {