playwright-cliでChromeが47個残っていた原因を突き止めてPRを出した話

Chromeプロセスが47個

Claude Codeでブラウザ操作を自動化するとき、playwright-CLIを日常的に使っています。GitHub Enterprise(GHE)でPRのスクリーンショットを撮ったり、E2Eテストを実行したり。

ある日、マシンが重くなったのでps auxを叩いたところ、Chromeのプロセスが47個も残っていました。

$ ps aux | grep chrome | grep user-data-dir | wc -l
47

全部playwright-CLIが起動したもので、daemonを停止したはずなのに生き残っていました。

SAML認証ループとの関係

GHEはSAML認証で保護されています。playwright-CLIでアクセスするたびにSAML認証を求められ、認証がうまくいかないとセッションを再起動していました。

$ playwright-cli open https://ghe.example.com/...
→ SAML認証リダイレクト → 失敗
$ playwright-cli session-stop
$ playwright-cli open ...
→ また認証 → ...

この繰り返しでプロセスが蓄積していたわけです。ただ、なぜsession-stopしたのにChromeが残るのか、なぜ再起動するとSAML認証が必要になるのかは理解できていませんでした。

根本原因の調査

Layer 1: ゾンビChromeがuserDataDirをロックしている

playwright-CLIのソースコードを読み込んだ結果、session-stop時にChromeプロセスが終了しない原因を特定しました。

daemon.tsのstopハンドラを簡略化すると、以下のようになっています。

if (method === 'stop') {
  await connection.send({ id, result: 'ok' });  // ackを返す
  gracefullyProcessExitDoNotHang(0);              // daemonプロセスを終了
  // → browserContext.close() が呼ばれていない
}

daemonプロセスは終了しますが、browserContext.close()が呼ばれていません。macOS/Linuxでは親プロセスが終了しても子プロセスは自動的に終了せず、initに再親付けされます。Chromeは孤児として動き続けます。

Layer 2: ロックされたuserDataDirに再接続できない

PlaywrightのlaunchPersistentContextは、同一userDataDirの多重起動を禁止しています。

$ playwright-cli open https://example.com
Error: Browser is already in use for /Users/me/.playwright-cli/ghe-profile

ゾンビChromeがロックを握っているため、同じプロファイルでの再起動ができません。

Layer 3: SAML Cookieがセッションクッキー

GHEのSAML認証Cookieを調べました。

sqlite3 ~/.playwright-cli/ghe-profile/Default/Cookies 
  "SELECT host_key, name, CASE WHEN expires_utc = 0
   THEN 'SESSION COOKIE' ELSE datetime(...) END FROM cookies
   WHERE host_key LIKE '%example%';"
ghe.example.com | _fi_sess | SESSION COOKIE

_fi_sessexpires=0のセッションCookieです。ブラウザプロセスが終了すると消えます。

つまり、どちらに転んでも詰みます。

  • Chromeを終了する → _fi_sessの消失 → SAML再認証が必要
  • Chromeを殺さない → userDataDirのロックで新セッションを作れない

修正

原因がわかったので、修正は明確です。stopハンドラでbrowserContext.close()を呼んでからプロセスを終了させます。

ただし、単純に追加するだけでは問題があります。browserContext.close()closeイベントを発火します。既存のイベントハンドラがdeleteSessionFilegracefullyProcessExitDoNotHangを呼ぶため、Race conditionが発生します。

6つの観点(コード/セキュリティ/テスト/DDD/リーダブルコード/仕様)でセルフレビューした結果、以下の修正になりました。

if (method === 'stop') {
  stoppingExplicitly = true;
  await deleteSessionFile(clientInfo, sessionConfig);
  // ack を先に返してクライアントをブロックしない
  await connection.send({ id, result: 'ok' }).catch(() => {});
  // ブラウザを閉じてからプロセス終了
  await browserContext.close()
    .catch(e => daemonDebug('failed to close browser context', e));
  if (options?.exitOnClose)
    gracefullyProcessExitDoNotHang(0);
}

// close イベントハンドラ(ブラウザが外部要因で閉じた場合のみ実行)
browserContext.on('close', () => Promise.resolve().then(async () => {
  if (stoppingExplicitly)
    return;  // stop コマンド経由なら何もしない
  await deleteSessionFile(clientInfo, sessionConfig);
  if (options?.exitOnClose)
    gracefullyProcessExitDoNotHang(0);
}));

修正のポイントは3つです。

  1. stoppingExplicitlyフラグでcloseイベントハンドラとの競合を防止
  2. ack送信をbrowserContext.close()の前に移動し、クライアントをブロックしない
  3. エラーをdaemonDebugでログし、.catch(() => {})で握りつぶさない

検証結果

$ playwright-cli open https://example.com --persistent
Daemon started with pid 28662.

$ ps aux | grep chrome | grep ms-playwright | wc -l
6   ← Chrome起動中

$ playwright-cli session-stop
Session 'default' stopped.

$ ps aux | grep chrome | grep ms-playwright | wc -l
0   ← 全プロセス終了

$ playwright-cli open https://example.com --persistent
Daemon started with pid 32639.   ← "already in use" エラーなし

PRとローカルパッチ

playwright-CLIのソースはPlaywright本体のモノレポにあるため、microsoft/playwrightにPRを出しました。

PRがマージ・リリースされるまでは、インストール済みのnode_modules内のコンパイル済みJSに同等のパッチを当てて運用しています。

まとめ

問題 原因
Chromeが47個残る session-stop時にbrowserContext.close()が呼ばれていない
SAML認証が毎回必要 ゾンビChromeがuserDataDirをロック → 新セッションではCookieなし
再起動できない launchPersistentContextが同一userDataDirの多重起動を禁止

修正はbrowserContext.close()を1行追加するだけですが、Race condition対策で最終的に+10行/-5行になりました。

表面的には「Chromeが残る」「SAML認証が毎回必要」という別々の問題に見えますが、根本原因は1つでした。症状ではなく原因を追うことの大切さを改めて感じました。

コメント

タイトルとURLをコピーしました