Description
When clientCertificates is configured and a proxied TLS connection negotiates h2 then fails, the SOCKS MITM proxy in socksClientCertificatesInterceptor.ts crashes with:
Assertion failed: is_write_in_progress()
node::http2::Http2Session::OnStreamAfterWrite (node_http2.cc:1780)
Root Cause
In the h2 503 error response path, the stream cleanup can fire before the DATA write completes:
session.once("stream", (stream) => {
const cleanup = (error) => {
session.close();
this._browserEncrypted.destroy(error);
};
stream.once("end", cleanup); // may fire synchronously on GET requests
stream.once("error", cleanup);
stream.respond({ status: 503 });
stream.end(responseBody); // queues DATA write via setImmediate
});
For GET requests the client sends END_STREAM with HEADERS, so 'end' can fire synchronously BEFORE stream.end() is called. The cleanup listener then queues setImmediate(cleanup) before the DATA write's setImmediate:
SI-CLEANUP → cleanup() → _browserEncrypted.destroy() → clears is_write_in_progress
SI-DATA → finishWrite → OnStreamAfterWrite → CHECK(is_write_in_progress) → SIGABRT
Workaround
Capture the 'end' listener without registering it. Wrap stream.end() to call origEnd() first (which synchronously queues SI-DATA), then queue setImmediate(runCleanup) as SI-CLEANUP. FIFO guarantees SI-DATA runs before SI-CLEANUP.
Reproduction
- Configure
clientCertificates in Playwright config
- Run tests that make HTTPS requests to origins where TLS fails (e.g., cert mismatch)
- The crash is intermittent — a race condition — but reproduces reliably under load (
--repeat-each=100)
Environment
- Playwright 1.60.0
- Node.js 22.x
- macOS / Linux
Description
When
clientCertificatesis configured and a proxied TLS connection negotiates h2 then fails, the SOCKS MITM proxy insocksClientCertificatesInterceptor.tscrashes with:Root Cause
In the h2 503 error response path, the stream cleanup can fire before the DATA write completes:
For GET requests the client sends
END_STREAMwith HEADERS, so'end'can fire synchronously BEFOREstream.end()is called. The cleanup listener then queuessetImmediate(cleanup)before the DATA write'ssetImmediate:SI-CLEANUP→cleanup()→_browserEncrypted.destroy()→ clearsis_write_in_progressSI-DATA→finishWrite→OnStreamAfterWrite→CHECK(is_write_in_progress)→ SIGABRTWorkaround
Capture the
'end'listener without registering it. Wrapstream.end()to callorigEnd()first (which synchronously queues SI-DATA), then queuesetImmediate(runCleanup)as SI-CLEANUP. FIFO guarantees SI-DATA runs before SI-CLEANUP.Reproduction
clientCertificatesin Playwright config--repeat-each=100)Environment