From 6dbb30012a2c126150cf77390a763946f664f79d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 2 Jun 2026 16:55:35 -0700 Subject: [PATCH] fix(client-certificates): avoid HTTP/2 crash when upstream TLS fails When the upstream TLS handshake fails and the browser negotiated h2, the proxy answers with a 503 over HTTP/2. For GET requests the browser sends END_STREAM together with the HEADERS frame, so the stream 'end' event could fire and run cleanup before the 503 DATA write completed. Destroying the socket mid-write crashes the http2 session with an `is_write_in_progress()` assertion (SIGABRT). Drop the 'end' listener and queue cleanup right after stream.end(), relying on setImmediate FIFO ordering to flush the DATA write first. Fixes: https://github.com/microsoft/playwright/issues/41105 --- .../socksClientCertificatesInterceptor.ts | 8 ++++- tests/library/client-certificates.spec.ts | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 3a5311aa84ab0..5ba0024b381f4 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -235,13 +235,19 @@ class SocksProxyConnection { session.close(); this._browserEncrypted.destroy(error); }; - stream.once('end', cleanup); stream.once('error', cleanup); stream.respond({ [http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'text/html', [http2.constants.HTTP2_HEADER_STATUS]: 503, }); + // Do not clean up on the stream's 'end' event: for GET requests the client sends + // END_STREAM together with the HEADERS frame, so 'end' can fire before stream.end() + // queues the DATA write. Tearing down the socket while that write is still in flight + // crashes the http2 session with an `is_write_in_progress()` assertion. Instead, queue + // the cleanup right after stream.end() so setImmediate FIFO ordering flushes the DATA + // write first. stream.end(responseBody); + setImmediate(() => cleanup()); }); } else { this._browserEncrypted.destroy(error); diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index bd2c603025f11..47194c3d41228 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -842,6 +842,40 @@ test.describe('browser', () => { await page.close(); }); + test('should not crash on HTTP/2 when the TLS connection to the server fails', async ({ browser, startCCServer, asset, browserName, isMac, nodeVersion }) => { + test.skip(browserName === 'webkit' && isMac, 'WebKit on macOS does not proxy localhost'); + test.skip(nodeVersion.major < 20, 'http2.performServerHandshake is not supported in older Node.js versions'); + + // Regression test for https://github.com/microsoft/playwright/issues/41105 + // When the upstream TLS handshake fails, the proxy answers with a 503 over h2. For GET + // requests the browser sends END_STREAM together with the HEADERS frame, so the stream 'end' + // event could fire before the 503 DATA write completed, tearing down the socket mid-write and + // crashing the http2 session with an `is_write_in_progress()` assertion. The crash is a race, + // so issue many sequential requests to reproduce it reliably. + const serverURL = await startCCServer({ http2: true }); + const page = await browser.newPage({ + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + }); + await page.goto(serverURL); + await expect(page.getByText('Playwright client-certificate error: self-signed certificate')).toBeVisible(); + for (let i = 0; i < 50; i++) { + const status = await page.evaluate(async url => { + const response = await fetch(url + '?' + Math.random(), { cache: 'no-store' }); + await response.text(); + return response.status; + }, serverURL); + expect(status).toBe(503); + } + // The browser is still alive (it did not crash). + await page.reload(); + await expect(page.getByText('Playwright client-certificate error: self-signed certificate')).toBeVisible(); + await page.close(); + }); + test('should handle rejected certificate in handshake with HTTP/2', async ({ browser, asset, browserName, platform }) => { const server: http2.Http2SecureServer = createHttp2Server({ key: fs.readFileSync(asset('client-certificates/server/server_key.pem')),