Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

[Bug]: Uncaught TypeError in captureSecurityDetails when APIRequestContext reuses a destroyed keep-alive socket

オープン 初心者向け
#43,728 コメント 3 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@microsoft-playwright-automation がすでに取り組んでいます。

2026年10月28日 から。

  • #43729 @microsoft-playwright-automation による — オープン

評価

難易度
2/5
見積もり時間
1〜3時間
初心者へのやさしさ
76/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
node.js, typescript

調査の方向性

packages/playwright-core/src/server/fetch.tsで、報告されたcaptureSecurityDetailsの呼び出しと証明書の読み取り箇所から始めます。issueにある自己完結型のNodeスクリプトで再現し、その後、破棄された再利用TLSソケットがどのようにそのリスナーに到達するかを調べます。リクエストが捕捉可能なエラーパスをたどり、捕捉されないTypeErrorが発生しなければ完了です。

索引モデルが issue の本文から書いたものです。

説明

needs-triage
Version

1.64.1 (also reproduced on 1.61.1; main at 4ed9744 has the same code)

Steps to reproduce

Single self-contained script, no browser needed:

  1. npm i [email protected]
  2. openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 1 -subj "/CN=localhost"
  3. Save the script below as repro.cjs and run node repro.cjs

The patch of http.Agent.prototype.addRequest only exists to put the agent deterministically into a state that happens naturally under load (see Additional context): the most recently pooled keep-alive socket has been destroyed, but its 'close' event has not fired yet, so it is still in agent.freeSockets when the next request is queued.

const fs = require('fs');
const http = require('http');
const https = require('https');
const { request } = require('playwright-core');

process.on('uncaughtException', (error) => {
  console.log('uncaughtException:', error.stack);
  process.exit(1);
});

const server = https.createServer(
  { key: fs.readFileSync('key.pem'), cert: fs.readFileSync('cert.pem') },
  (req, res) => res.end('ok')
);

server.listen(0, async () => {
  const url = `https://localhost:${server.address().port}/`;
  const context = await request.newContext({ ignoreHTTPSErrors: true });

  await Promise.all([context.get(url), context.get(url)]);

  const originalAddRequest = http.Agent.prototype.addRequest;
  http.Agent.prototype.addRequest = function (req, options) {
    const pooledSockets = Object.values(this.freeSockets).find((sockets) => sockets.length > 1);
    pooledSockets?.at(-1).destroy();
    return originalAddRequest.call(this, req, options);
  };

  await context.get(url).catch((error) => console.log('request rejected (expected, catchable):', error.message));
  console.log('no crash');
  process.exit(0);
});
Expected behavior

The request fails through its normal error path: the context.get() promise rejects (e.g. socket hang up) and the caller can catch it.

Actual behavior

An uncaught TypeError is thrown from the request's 'socket' listener:

uncaughtException: TypeError: Cannot read properties of null (reading 'valid_from')
    at captureSecurityDetails (node_modules/playwright-core/lib/coreBundle.js:28068:34)
    at ClientRequest.<anonymous> (node_modules/playwright-core/lib/coreBundle.js:28092:15)
    at ClientRequest.emit (node:events:519:28)
    at tickOnSocket (node:_http_client:925:7)
    at onSocketNT (node:_http_client:989:5)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21)

On 1.61.1 the same crash reads Cannot read properties of null (reading 'subject').

In @playwright/test this fails whichever test is running with that error (no stack in the report), while the test body keeps running until teardown. Wrapping route.fetch() / request.get() in try/catch does not help, because the throw happens in an event listener rather than in the returned promise.

Additional context

Root cause:

  • TLSSocket.getPeerCertificate() returns null once the socket has been destroyed (it returns {} for a resumed session, which the valid_from check already handles).
  • Node's Agent.addRequest() skips destroyed sockets only at the head of freeSockets[name], but with the default scheduling: 'lifo' it then pops the tail without checking it. A socket destroyed earlier in the same event-loop turn stays in freeSockets until its 'close' event fires (that is when the agent removes it), so it can be handed to a new request as a reused socket.
  • fetch.ts calls captureSecurityDetails(socket) synchronously when request.reusedSocket is true (fetch.ts#L629-L631) and reads peerCertificate.valid_from on the null (fetch.ts#L599-L600).

We hit this in CI (Linux, Node 22, @playwright/test 1.61.1) without any patching. A context.route() handler called route.fetch() for an image on cdn.jsdelivr.net. 400 ms earlier, a route.fetch() for the same URL had failed with read ECONNRESET, so the CDN was resetting pooled connections at that moment. The trace shows the uncaught TypeError about 5 ms after the second route.fetch() started. That route.fetch() then rejected with socket hang up (from socketCloseListener), which our handler caught. The test had already failed on the uncaught error.

This is related to #42698 (reused keep-alive sockets failing with ECONNRESET), but different in effect: that failure is a catchable rejection, while this one escapes as an uncaught exception.

A possible fix is to bail out when there is no certificate, so the request fails through its normal error path:

const peerCertificate = socket.getPeerCertificate();
if (!peerCertificate)
  return;
Environment
System:
  OS: Windows 11 10.0.26200
  CPU: (16) x64 Intel(R) Core(TM) Ultra 9 285H
  Memory: 3.99 GB / 31.40 GB
Binaries:
  Node: 22.23.2 - C:\Program Files\nodejs\node.EXE
  npm: 10.9.8 - C:\Program Files\nodejs\npm.CMD
npmPackages:
  playwright-core: ^1.64.1 => 1.64.1

Originally observed on Linux (CI), Node 22, @playwright/test 1.61.1.

主要言語
TypeScript
スター
98.5k
フォーク
6.7k
平均マージ
15時間 42分
マージ済み PR(30日)
313

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

microsoft/playwright のほかの issue

microsoft/playwright の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。