Minimal reproduction for a HAR recording hang in Chromium.
Reproduced on 1.59.1 and on main (9642f5766).
npm install
npx playwright install chromium
node repro.js # the production stream: both events withheld
REPRO_MODE=extra node repro.js # only responseReceivedExtraInfo withheld
REPRO_MODE=finished node repro.js # only loadingFinished withheldExit code 1 = hung (bug present), 0 = closed, 2 = INCONCLUSIVE
(the repro could not censor the events, so the run proves nothing — it will not
silently report a pass).
context.close() completes, and the HAR entry keeps the provisional headers
that HarTracer._onResponse already recorded for exactly this case.
context.close() never resolves. Under @playwright/test this surfaces as:
Tearing down "context" exceeded the test timeout of 100000ms.
| withheld | context.close() |
|---|---|
responseReceivedExtraInfo + loadingFinished |
hangs (cut off at 22s) |
responseReceivedExtraInfo only |
hangs (cut off at 22s) |
loadingFinished only |
closes, ~80ms |
The missing responseReceivedExtraInfo is the sufficient condition. A missing
loadingFinished alone is harmless, because the extra-info still pairs and
resolves the raw-header promises.
The trigger in the wild is Chromium advertising hasExtraInfo: true on a
response and then going quiet — sending neither the extra-info nor
loadingFinished. That is timing-dependent and we could not provoke it on
demand, so this repro replays the resulting CDP stream instead:
- launches Chromium with a debugging port,
- proxies the CDP websocket,
- drops the withheld events for one request, identified by URL.
Nothing patches Playwright internals — it drives a normal
chromium.connectOverCDP() session, so it runs against any build. The browser
itself behaves normally; only Playwright's view of it is affected.
All ports are OS-allocated, so nothing collides with services you already have running.
Proxying the CDP websocket needs a WebSocket server, which Node does not
provide natively (it has a client only). ws is the standard implementation and
is the sole dependency beyond Playwright itself. It is used only for the proxy —
the code under test is untouched.
HarTracer._onResponse records the provisional headers synchronously and then
adds two barriers whose only purpose is to upgrade them to raw 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.rawResponseHeaders().then(headers => {
this._recordResponseHeaders(harEntry, headers);
}));Those promises are resolved only by ResponseExtraInfoTracker, either through
_patchHeaders() pairing a response with an extra-info at the same index, or
through the (!hasExtraInfo || servedFromCache) short-circuit in
processResponse(). Two cases escape both:
-
Finished but unpaired.
_checkFinished()stops tracking only whenresponses.length <= responseReceivedExtraInfo.length. The unpaired case falls through to// We are not done yet.and nothing ever resolves those responses' raw-header promises. -
Never finished.
_checkFinished()returns immediately unlessloadingFinishedorloadingFailedhas arrived. If the browser stops reporting a request afterresponseReceived, the tracker never evaluates it.
HarTracer.flush() then awaits the barriers with no bound. _addBarrier does
wrap each promise in target.openScope.safeRace(...), but that cannot help
here: BrowserContext.close() flushes before doClose(), so the scope is
still open while the flush waits.
This looks like a third variant in a family that has been fixed twice before:
- #11435 — HAR hangs on resources served from cache. Fixed by the
servedFromCacheguard inprocessResponse(). Our responses are not cache-served (fromDiskCache: false, norequestServedFromCache), so that guard does not fire. - #21182 — HAR hangs on a slow chunked response. Fixed by populating fields early.
- #15017 — handles extra-info arriving after
loadingFinished. Here it never arrives at all.
Distinct from https://crbug.com/1340398 (cached responses with an erroneous
hasExtraInfo), which processResponse() already guards.
A browser-monitoring fleet running these checks on a schedule hit this repeatedly. The CDP stream for the offending request:
1993ms Network.requestWillBeSent session=<page> type=Script
1993ms Network.requestWillBeSentExtraInfo session=<page>
2017ms Network.responseReceivedExtraInfo session=<page>
2053ms Network.responseReceived session=<worker> hasExtraInfo=true status=200 proto=h2
2054ms Network.dataReceived session=<worker>
2055ms Network.loadingFinished session=<worker> <-- healthy fetch
2488ms Network.requestWillBeSent session=<page> type=Script
2698ms Network.responseReceived session=<worker> hasExtraInfo=true status=200 proto=h2
<nothing further, ever> <-- hangs
The same URL (a Web Worker script) is fetched twice. The first pairs and
completes; the second receives responseReceived with hasExtraInfo: true and
is never spoken of again. Two barriers remain outstanding at teardown, matching
the two raw-header barriers from _onResponse.
We have not established why the browser goes quiet for that second fetch, and this report does not depend on it: whatever the cause, a HAR flush that can block indefinitely on an optional upgrade is a robustness problem on the Playwright side.
Two independent changes, one per case above. Both are running in our production fleet, patched into 1.59.1 — the affected checks went from failing on the teardown timeout to passing in ~4.4s, their pre-incident duration.
1. ResponseExtraInfoTracker._checkFinished — finished but unpaired
// The request is over, so no further extra info can arrive for it.
// Fall back to provisional headers, as the !hasExtraInfo path does.
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);2. HarTracer.flush — still in flight when the recording stops
Track responses whose header upgrade is still pending, and release the ones whose request has not finished when the flush begins:
async flush() {
this._abandonInFlightHeaderUpgrades();
await Promise.all(this._barrierPromises);
}This is not a timeout. The provisional headers are already in the entry, which
is what the existing comment in _onResponse describes as the intended
fallback; the change only makes that fallback reachable at stop time.
Tradeoff: a response whose extra-info has not arrived at the instant the flush begins records provisional rather than raw headers. That set is narrow — anything whose extra-info already arrived is untouched — but it is a behaviour change, and worth a maintainer's judgement on whether the fallback should apply only when the request is unfinished (as written) or more broadly.
Happy to open a PR with both changes plus a regression test. har.spec.ts
already has should not hang on resources served from cache and should not hang on slow chunked response; this would be a third in that family, though it
needs CDP-level event suppression to simulate, which those two do not.