From 8f2420eec89c90aa4bf111c6aad25d1e1a6b85c2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 20 Aug 2026 00:26:13 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AD=A4=E5=85=90=20VS=20Code=20IPC=20?= =?UTF-8?q?=E3=82=BD=E3=82=B1=E3=83=83=E3=83=88=E3=82=92=E7=94=9F=E5=AD=98?= =?UTF-8?q?=E6=89=B1=E3=81=84=E3=81=97=E3=81=A6=20up=20--open=20=E3=81=8C?= =?UTF-8?q?=20ECONNREFUSED=20=E3=81=AB=E3=81=AA=E3=82=8B=E5=95=8F=E9=A1=8C?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #97 で VSCODE_IPC_HOOK_CLI の実在確認を入れたが、ソケットの死に方には 「ファイルごと消えている」ほかに「ファイルは残っているが listen しているプロセスが 居ない」の 2 通りがある。後者は VS Code のクラッシュ・強制終了・OS 再起動で後始末 されなかった場合に $TMPDIR へ孤児として残り、os.path.exists() では弾けない。 その結果 in_vscode=True と誤判定して code を IPC 経由で起動し、 Unable to connect to VS Code server: Error in request. Error: connect ECONNREFUSED .../vscode-ipc-.sock で失敗していた。 - _ipc_socket_alive() を connect ベースに変更 (AF_UNIX / タイムアウト 0.5 秒)。 ENOENT / ECONNREFUSED / EACCES / ENOTSOCK / パス長超過をまとめて「使えない」と扱う - AF_UNIX が無いプラットフォームでは従来どおり実在確認へフォールバック - 警告文を「存在しません」から「接続できません」に改め、ファイルが残っていても 接続を拒否する場合があることを明記 - 孤児ソケット / 通常ファイルのケースを回帰テストに追加。既存の in_vscode テストは 実際に listen する AF_UNIX ソケットを使うよう修正 - docs にソケットの死に方 2 通りを追記。tmux 追随フックの -S guard も孤児ソケットを 検出できない点を注記 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01622CAKyYfonYuZ6AqRJvDg --- CHANGELOG.md | 10 +++++ docs/user/environment-variables.md | 16 ++++++- lib/devbase/editor/opener.py | 68 ++++++++++++++++++++++-------- tests/editor/test_opener.py | 65 +++++++++++++++++++++++++--- 4 files changed, 133 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b47bb24..ccb7ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,16 @@ `. /bin/rc` に書き換えてください。 ### Fixed +- **VS Code の異常終了後に残った IPC ソケットで `devbase up --open` が + `ECONNREFUSED` で失敗する問題を修正**しました。`VSCODE_IPC_HOOK_CLI` が指す + ソケットの死に方には「ファイルごと消えている」ほかに「ファイルは残っているが + listen しているプロセスが居ない」の 2 通りがあります。後者は VS Code の + クラッシュ・強制終了・OS 再起動で後始末されなかった場合に `$TMPDIR` へ孤児として + 残り、`ls` では生きているものと区別が付きません。従来の `_ipc_socket_alive` は + ファイルの実在だけを見ていたため後者を「生きている」と誤判定し、`code` が + `connect ECONNREFUSED .../vscode-ipc-.sock` で失敗していました。 + **実際に connect して**生死を判定するようにし (タイムアウト 0.5 秒)、死んでいる + 場合は従来どおり警告のうえ degrade します。 - **tmux / screen 経由のターミナルで `devbase up --open` が無言で失敗する問題を修正** しました。VS Code はウィンドウごとに IPC ソケット (`$TMPDIR/vscode-ipc-.sock`) を作り直しますが、tmux サーバーはセッション作成時の環境変数を保持し続けるため、 diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 1b81429..29634b9 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -194,7 +194,16 @@ VS Code は統合ターミナルごとに `$TMPDIR/vscode-ipc-.sock` を tmux / screen はサーバープロセスが**セッション作成時の環境変数を保持し続ける**ため、ここが噛み合いません。VS Code のウィンドウをリロードしたり開き直したりするとソケットは作り直されますが、既存の tmux セッションに再アタッチした端末は**古いパスを引き継いだまま**になります。 -devbase はソケットの実在を確認してから VS Code 統合ターミナルと判定します(変数の有無だけでは判定しません)。古い場合は警告を出したうえで「手元で実行するコマンドの提示」へ degrade するので、**黙って何も起きないという状態にはなりません**。提示されたコマンドを手元で実行すれば開けます。 +devbase は**実際にソケットへ接続できるかを確認**してから VS Code 統合ターミナルと判定します(変数の有無でも、ファイルの実在でも判定しません)。古い場合は警告を出したうえで「手元で実行するコマンドの提示」へ degrade するので、**黙って何も起きないという状態にはなりません**。提示されたコマンドを手元で実行すれば開けます。 + +ソケットの死に方は 2 通りあります。 + +| 状態 | 起きる場面 | ファイルの実在 | +|---|---|---| +| ファイルごと消えている | VS Code のウィンドウを正常に閉じた(VS Code が削除する) | 無し | +| ファイルは残っているが listen していない | VS Code のクラッシュ・強制終了・OS 再起動で後始末されなかった | **有り** | + +後者は `$TMPDIR` に孤児ソケットとして溜まり、接続しようとすると `ECONNREFUSED` になります。`ls` では生きているものと見分けが付きません。 自動で開く状態に戻すには、tmux 側に環境変数を追随させます。`~/.tmux.conf` に以下を追記してください。 @@ -207,7 +216,10 @@ set -ga update-environment " VSCODE_IPC_HOOK_CLI VSCODE_GIT_IPC_HANDLE VSCODE_GI ```bash if [ -n "${TMUX:-}" ]; then _vscode_sync_env() { - # ソケットが生きている間は何もしない(サブプロセスを起動しない) + # ソケットが生きている間は何もしない(サブプロセスを起動しない)。 + # 注意: -S はファイルの種別しか見ないため、listen していない孤児ソケットは + # 「生きている」と判定されてしまう。この guard に引っかかって追随しない場合は + # 行ごと削除して、毎回 tmux show-environment で同期する。 [ -n "${VSCODE_IPC_HOOK_CLI:-}" ] && [ -S "${VSCODE_IPC_HOOK_CLI}" ] && return 0 local line while IFS= read -r line; do diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index e9d43d7..138834b 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -8,10 +8,17 @@ Remote-SSH 統合ターミナルでは ``code`` シムがクライアント (例: Windows) に窓を 開く。よって ``code --folder-uri `` を叩くだけで実行コンテキストに 応じた正しいクライアントへ開ける。 -- ただし ``VSCODE_IPC_HOOK_CLI`` は **変数が残っていても実体が消えている**ことがある +- ただし ``VSCODE_IPC_HOOK_CLI`` は **変数が残っていても接続先が死んでいる**ことがある (tmux/screen のセッション再利用、VS Code ウィンドウのリロード後の古い端末など)。 - 存在確認まで行わないと ``code`` が死んだソケットへ繋ぎに行き無言で失敗するため、 - :func:`_ipc_socket_alive` で実在を検証してから ``in_vscode`` を立てる。 + 死に方は 2 通りあり、**ファイルの実在確認だけでは後者を検出できない**: + + 1. ソケットファイルごと消えている (VS Code が正常終了時に削除した) + 2. ソケットファイルは残っているが listen しているプロセスが居ない + (クラッシュ・強制終了・OS 再起動などで後始末されなかった) + + 2 の状態で ``code`` を叩くと ``ECONNREFUSED`` で失敗する。よって + :func:`_ipc_socket_alive` では **実際に connect して**生死を判定してから + ``in_vscode`` を立てる。 - コンテナ attach URI は ``{"containerName":"/<実コンテナ名>"}`` を hex 化した authority を持つ (:func:`build_attach_uri`)。 - **跨ホスト (手元 VS Code → Remote-SSH(host) → ssh 先の Docker 上コンテナ) では @@ -36,6 +43,7 @@ import re import shlex import shutil +import socket import subprocess import sys from dataclasses import dataclass @@ -102,12 +110,17 @@ def _detect_wsl(environ) -> bool: return False +# connect() の待ち時間上限 (秒)。相手が生きていれば UNIX ドメインソケットの接続は +# 即座に完了するため短くてよい。`devbase up` の最後に走るので体感を優先する。 +_IPC_CONNECT_TIMEOUT = 0.5 + + def _ipc_socket_alive(environ) -> bool: - """``VSCODE_IPC_HOOK_CLI`` が **実在するソケット** を指しているか。 + """``VSCODE_IPC_HOOK_CLI`` が **応答するソケット** を指しているか。 - 「変数が設定されているか」だけでは不十分。VS Code はウィンドウごとに - ``$TMPDIR/vscode-ipc-.sock`` を作り、ウィンドウを閉じる/リロードすると - 削除するため、**変数だけが古いまま残る**状況が日常的に起きる: + 「変数が設定されているか」だけでは不十分で、**ファイルの実在確認でも足りない**。 + VS Code はウィンドウごとに ``$TMPDIR/vscode-ipc-.sock`` を作るが、変数が + 古いまま残る状況が日常的に起きる: - tmux / screen: サーバーがセッション作成時の環境変数を保持し続けるため、 同じセッションに再アタッチした端末は死んだソケットのパスを引き継ぐ @@ -115,17 +128,34 @@ def _ipc_socket_alive(environ) -> bool: - VS Code ウィンドウのリロード後に残った古いシェル - ``nohup`` / デーモン化して生き残ったプロセス - この状態を ``in_vscode=True`` と誤判定すると :func:`decide_action` が - ``launch`` を選び、``code`` が死んだソケットへ接続を試みて**無言で失敗**する。 - 実在を確認して False に倒せば、SSH 経路なら ``print_command`` へ degrade して - ユーザが手元で実行できるコマンドを提示できる。 + このときソケットの死に方は 2 通りある: + + 1. **ファイルごと消えている** — ウィンドウを正常に閉じると VS Code が削除する + 2. **ファイルは残っているが listen していない** — クラッシュ・強制終了・OS 再起動 + などで後始末されなかった場合。``$TMPDIR`` に孤児ソケットとして溜まる + + 2 を ``in_vscode=True`` と誤判定すると :func:`decide_action` が ``launch`` を選び、 + ``code`` が ``ECONNREFUSED`` で失敗する。実在確認では 1 しか弾けないため、 + **実際に connect して**判定する。False に倒せば SSH 経路なら ``print_command`` へ + degrade して、ユーザが手元で実行できるコマンドを提示できる。 """ sock = environ.get("VSCODE_IPC_HOOK_CLI") if not sock: return False - # os.path.exists() は OSError / ValueError を内部で捕捉して False を返すため、 - # 権限エラーや不正なパスもここで「使えない」と判定される。 - return os.path.exists(sock) + family = getattr(socket, "AF_UNIX", None) + if family is None: + # AF_UNIX が無いプラットフォームでは接続確認ができないので実在確認に留める。 + return os.path.exists(sock) + try: + with socket.socket(family, socket.SOCK_STREAM) as s: + s.settimeout(_IPC_CONNECT_TIMEOUT) + s.connect(sock) + return True + except (OSError, ValueError): + # ファイル不在 (ENOENT) / listen 不在 (ECONNREFUSED) / 権限 (EACCES) / + # ソケットでない (ECONNREFUSED, ENOTSOCK) / パス長超過 (ValueError) を + # まとめて「使えない」と扱う。 + return False def detect_context(environ=None, isatty: Optional[bool] = None, @@ -536,14 +566,16 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, """ env = os.environ if environ is None else environ ctx = detect_context(env, isatty=isatty, system=system, ipc_alive=ipc_alive) - # 変数だけ残って実体が消えた IPC ソケットは無言の失敗になりやすいので明示する - # (tmux セッション再利用・VS Code ウィンドウのリロード後など)。 + # 変数だけ残って接続先が死んでいる IPC ソケットは無言の失敗になりやすいので + # 明示する (tmux セッション再利用・VS Code ウィンドウのリロード後など)。 stale_ipc = env.get("VSCODE_IPC_HOOK_CLI") if stale_ipc and not ctx.in_vscode: logger.warning( - "VSCODE_IPC_HOOK_CLI が指すソケットが存在しません (%s)。VS Code 統合" + "VSCODE_IPC_HOOK_CLI が指すソケットに接続できません (%s)。VS Code 統合" "ターミナルとしては扱いません。tmux/screen のセッションを再利用している" - "場合や VS Code のウィンドウをリロードした後の古い端末で起きます。", + "場合や VS Code のウィンドウをリロードした後の古い端末で起きます。" + "ソケットファイルが残っていても、VS Code の異常終了後は listen して" + "おらず接続を拒否します。", stale_ipc, ) editor = resolve_editor_cmd(env) # launch 用 (which 込み・None あり得る) diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 55ddf38..f1610c6 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -4,6 +4,7 @@ import json import os +import shutil from dataclasses import dataclass import pytest @@ -65,12 +66,32 @@ def test_detect_context_ssh(key): assert ctx.is_ssh is True -def test_detect_context_in_vscode(tmp_path): - # ソケットが実在するときだけ in_vscode が立つ - sock = tmp_path / "vscode-ipc-live.sock" - sock.write_text("") - ctx = opener.detect_context(environ={"VSCODE_IPC_HOOK_CLI": str(sock)}, - isatty=True, system="Linux") +@pytest.fixture +def listening_ipc_socket(): + """listen 中の AF_UNIX ソケットを作り、そのパスを返す。 + + macOS の sun_path は 104 バイト上限で pytest の tmp_path (/private/var/folders/...) + だと超えうるため、短い ``/tmp`` 直下に作る。 + """ + import socket as _socket + import tempfile + d = tempfile.mkdtemp(dir="/tmp", prefix="dbipc") + path = os.path.join(d, "s.sock") + srv = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) + srv.bind(path) + srv.listen(1) + try: + yield path + finally: + srv.close() + shutil.rmtree(d, ignore_errors=True) + + +def test_detect_context_in_vscode(listening_ipc_socket): + # 接続できるソケットを指しているときだけ in_vscode が立つ + ctx = opener.detect_context( + environ={"VSCODE_IPC_HOOK_CLI": listening_ipc_socket}, + isatty=True, system="Linux") assert ctx.in_vscode is True @@ -85,6 +106,38 @@ def test_detect_context_stale_ipc_socket_is_not_vscode(tmp_path): assert ctx.in_vscode is False +def test_detect_context_orphan_ipc_socket_is_not_vscode(): + """ソケットファイルは残っているが listen していない場合も in_vscode=False。 + + VS Code がクラッシュ/強制終了すると $TMPDIR に孤児ソケットが残る。実在確認だけ + では生きていると誤判定し、code が ECONNREFUSED で失敗していた回帰の防止。 + """ + import socket as _socket + import tempfile + d = tempfile.mkdtemp(dir="/tmp", prefix="dbipc") + path = os.path.join(d, "s.sock") + srv = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) + srv.bind(path) + srv.listen(1) + srv.close() # bind したファイルは残るが listen は失われる + try: + assert os.path.exists(path) # 実在確認では弾けないことを明示 + ctx = opener.detect_context(environ={"VSCODE_IPC_HOOK_CLI": path}, + isatty=True, system="Linux") + assert ctx.in_vscode is False + finally: + shutil.rmtree(d, ignore_errors=True) + + +def test_detect_context_ipc_path_is_regular_file_is_not_vscode(tmp_path): + """ソケットですらない通常ファイルを指していても in_vscode=False。""" + f = tmp_path / "not-a-socket" + f.write_text("") + ctx = opener.detect_context(environ={"VSCODE_IPC_HOOK_CLI": str(f)}, + isatty=True, system="Linux") + assert ctx.in_vscode is False + + def test_detect_context_ipc_alive_override(): """``ipc_alive`` 明示時は実在チェックを行わない (テスト用差し替え口)。""" ctx = opener.detect_context(environ={"VSCODE_IPC_HOOK_CLI": "/run/x.sock"},