Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

recordHar: context.close() hangs when a response's raw headers never arrive

Minimal reproduction for a HAR recording hang in Chromium.

Reproduced on 1.59.1 and on main (9642f5766).

Run it

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 withheld

Exit 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).

Expected

context.close() completes, and the HAR entry keeps the provisional headers that HarTracer._onResponse already recorded for exactly this case.

Actual

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.

How the reproduction works

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:

  1. launches Chromium with a debugging port,
  2. proxies the CDP websocket,
  3. 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.

Why the ws dependency

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.

Analysis

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:

  1. Finished but unpaired. _checkFinished() stops tracking only when responses.length <= responseReceivedExtraInfo.length. The unpaired case falls through to // We are not done yet. and nothing ever resolves those responses' raw-header promises.

  2. Never finished. _checkFinished() returns immediately unless loadingFinished or loadingFailed has arrived. If the browser stops reporting a request after responseReceived, 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.

Prior art

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 servedFromCache guard in processResponse(). Our responses are not cache-served (fromDiskCache: false, no requestServedFromCache), 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.

What we saw in production

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.

Suggested fix

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.

About

Minimal repro: Playwright recordHar context.close() hangs when a response's raw headers never arrive (Chromium)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages