From e65740f0741b9b1915af5ad27efe42f961127976 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 02:57:44 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E5=8F=A4=E3=81=84=20VSCODE=5FIPC=5F?= =?UTF-8?q?HOOK=5FCLI=20=E3=81=A7=20--open=20=E3=81=8C=E7=84=A1=E8=A8=80?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=81=99=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tmux/screen のセッション再利用や VS Code ウィンドウのリロード後は VSCODE_IPC_HOOK_CLI が消えたソケットを指したまま残る。従来は変数の有無だけで VS Code 統合ターミナルと判定していたため、code が死んだソケットへ接続を試み、 _launch が stderr を捨てていたのでエラーも出ないまま何も起きなかった。 - _ipc_socket_alive() でソケットの実在を確認してから in_vscode を立てる - 古い場合は理由を warning に出し、SSH 経路では print_command へ degrade - _launch は stderr を握り潰さず親へ継承する - tmux 追随設定 (update-environment / プロンプトフック) をドキュメントに追加 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QQa2WHdEqzbdCtBoQXaRNS --- CHANGELOG.md | 13 ++++++ docs/user/environment-variables.md | 40 +++++++++++++++++ lib/devbase/editor/opener.py | 70 ++++++++++++++++++++++++++---- tests/editor/test_opener.py | 55 +++++++++++++++++++++-- 4 files changed, 165 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2431f34..4144120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,19 @@ 置き換えました。`source "$(devbase shell-rc)"` を使っているスクリプトは `. /bin/rc` に書き換えてください。 +### Fixed +- **tmux / screen 経由のターミナルで `devbase up --open` が無言で失敗する問題を修正** + しました。VS Code はウィンドウごとに IPC ソケット (`$TMPDIR/vscode-ipc-.sock`) + を作り直しますが、tmux サーバーはセッション作成時の環境変数を保持し続けるため、 + 既存セッションに再アタッチした端末では `VSCODE_IPC_HOOK_CLI` が**消えたソケットを + 指したまま**になります。従来は変数の有無だけで VS Code 統合ターミナルと判定して + いたため、`code` が死んだソケットへ接続を試みて何も起きず、`_launch` が stderr を + 捨てていたのでエラーも出ませんでした。ソケットの実在を確認したうえで判定し + (`_ipc_socket_alive`)、古い場合は理由を warning に出して「手元で実行するコマンドの + 提示」へ degrade します。あわせて `_launch` は stderr を握り潰さないようにしました。 + tmux 側の追随設定は `docs/user/environment-variables.md` の + 「tmux / screen 経由で使う場合」を参照してください。 + ### Added - **ワンライナー installer (`install.sh`) を新設**しました (PLAN31_1)。 `curl -fsSL https://dl.basex.jp/i | bash` diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 0a968d6..ca6eb76 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -164,6 +164,7 @@ devbase はホストマシンの認証情報を自動収集し、コンテナ内 | VS Code の Remote-SSH 統合ターミナル(同一ホストの Docker) | **クライアント側(手元)の VS Code** が開く(`code` シムが委譲) | | VS Code の Remote-SSH 統合ターミナル(**跨ホスト**: ssh 先の Docker にコンテナ) | `DEVBASE_EDITOR_SSH_HOST` 設定時にネスト URI で開く(下記「跨ホスト」参照) | | 手元から素の SSH(VS Code 外)で接続中 | クライアントへ自動で開く公式手段が無いため、手元で実行する `code --folder-uri ...` コマンドを提示 | +| tmux / screen 経由のターミナル | `VSCODE_IPC_HOOK_CLI` が古くなっていると VS Code 統合ターミナルとみなさず、SSH 経路の「コマンド提示」へ degrade(下記「tmux / screen 経由で使う場合」参照) | | CI / 非対話(非 TTY) / `code` 不在 | 理由を表示してスキップ(`up` 自体は成功) | #### 跨ホスト(Windows VS Code → Remote-SSH → Mac のコンテナ) @@ -187,6 +188,45 @@ DEVBASE_EDITOR_SSH_HOST=mac2 > 同一ホスト構成(手元 Mac/Linux で直接、または ssh 先の Docker にコンテナが無い場合)では ssh-remote ホストは付かず、従来どおりフラット URI で開きます。 +#### tmux / screen 経由で使う場合 + +VS Code は統合ターミナルごとに `$TMPDIR/vscode-ipc-.sock` を作り、`VSCODE_IPC_HOOK_CLI` でその場所を伝えます。`code` はこのソケット経由でクライアント側の VS Code に依頼するため、**ソケットが死んでいると `code` は何もできません**。 + +tmux / screen はサーバープロセスが**セッション作成時の環境変数を保持し続ける**ため、ここが噛み合いません。VS Code のウィンドウをリロードしたり開き直したりするとソケットは作り直されますが、既存の tmux セッションに再アタッチした端末は**古いパスを引き継いだまま**になります。 + +devbase はソケットの実在を確認してから VS Code 統合ターミナルと判定します(変数の有無だけでは判定しません)。古い場合は警告を出したうえで「手元で実行するコマンドの提示」へ degrade するので、**黙って何も起きないという状態にはなりません**。提示されたコマンドを手元で実行すれば開けます。 + +自動で開く状態に戻すには、tmux 側に環境変数を追随させます。`~/.tmux.conf` に以下を追記してください。 + +```tmux +set -ga update-environment " VSCODE_IPC_HOOK_CLI VSCODE_GIT_IPC_HANDLE VSCODE_GIT_ASKPASS_NODE VSCODE_GIT_ASKPASS_MAIN VSCODE_GIT_ASKPASS_EXTRA_ARGS VSCODE_NONCE GIT_ASKPASS BROWSER TERM_PROGRAM" +``` + +これで **attach のたびに**接続してきたクライアントの値でセッション環境が更新されます。ただし更新されるのはセッション環境であり、**すでに起動しているペインのシェル**には波及しません。既存ペインにも追随させたい場合は、シェルの rc(`~/.bash_profile` 等)にプロンプトフックを置きます。 + +```bash +if [ -n "${TMUX:-}" ]; then + _vscode_sync_env() { + # ソケットが生きている間は何もしない(サブプロセスを起動しない) + [ -n "${VSCODE_IPC_HOOK_CLI:-}" ] && [ -S "${VSCODE_IPC_HOOK_CLI}" ] && return 0 + local line + while IFS= read -r line; do + case "$line" in + VSCODE_IPC_HOOK_CLI=*|VSCODE_GIT_IPC_HANDLE=*|GIT_ASKPASS=*|BROWSER=*) + export "${line%%=*}=${line#*=}" ;; + esac + done < <(tmux show-environment 2>/dev/null) + return 0 + } + case ";${PROMPT_COMMAND:-};" in + *";_vscode_sync_env;"*) ;; + *) PROMPT_COMMAND="_vscode_sync_env;${PROMPT_COMMAND:-}" ;; + esac +fi +``` + +反映するには `tmux kill-server` でサーバーを作り直してください(`~/.tmux.conf` はサーバー起動時にのみ読まれ、既存ペインのシェルも修正前の rc で起動しているため)。 + ## ソースファイル変更検出 devbase はソースファイル(`~/.aws/config` 等)のハッシュを `.env.sources.yml` で管理しています。 diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 0b9c2af..a9e1575 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -8,6 +8,10 @@ Remote-SSH 統合ターミナルでは ``code`` シムがクライアント (例: Windows) に窓を 開く。よって ``code --folder-uri `` を叩くだけで実行コンテキストに 応じた正しいクライアントへ開ける。 +- ただし ``VSCODE_IPC_HOOK_CLI`` は **変数が残っていても実体が消えている**ことがある + (tmux/screen のセッション再利用、VS Code ウィンドウのリロード後の古い端末など)。 + 存在確認まで行わないと ``code`` が死んだソケットへ繋ぎに行き無言で失敗するため、 + :func:`_ipc_socket_alive` で実在を検証してから ``in_vscode`` を立てる。 - コンテナ attach URI は ``{"containerName":"/<実コンテナ名>"}`` を hex 化した authority を持つ (:func:`build_attach_uri`)。 - **跨ホスト (手元 VS Code → Remote-SSH(host) → ssh 先の Docker 上コンテナ) では @@ -67,7 +71,7 @@ class EditorContext: """エディタ起動先の判定に使う実行コンテキスト。""" is_tty: bool - in_vscode: bool # VSCODE_IPC_HOOK_CLI が設定されている + in_vscode: bool # VSCODE_IPC_HOOK_CLI が *生きている* ソケットを指している is_wsl: bool is_ssh: bool is_darwin: bool @@ -98,21 +102,51 @@ def _detect_wsl(environ) -> bool: return False +def _ipc_socket_alive(environ) -> bool: + """``VSCODE_IPC_HOOK_CLI`` が **実在するソケット** を指しているか。 + + 「変数が設定されているか」だけでは不十分。VS Code はウィンドウごとに + ``$TMPDIR/vscode-ipc-.sock`` を作り、ウィンドウを閉じる/リロードすると + 削除するため、**変数だけが古いまま残る**状況が日常的に起きる: + + - tmux / screen: サーバーがセッション作成時の環境変数を保持し続けるため、 + 同じセッションに再アタッチした端末は死んだソケットのパスを引き継ぐ + (``update-environment`` に ``VSCODE_IPC_HOOK_CLI`` を足すと緩和できる) + - VS Code ウィンドウのリロード後に残った古いシェル + - ``nohup`` / デーモン化して生き残ったプロセス + + この状態を ``in_vscode=True`` と誤判定すると :func:`decide_action` が + ``launch`` を選び、``code`` が死んだソケットへ接続を試みて**無言で失敗**する。 + 実在を確認して False に倒せば、SSH 経路なら ``print_command`` へ degrade して + ユーザが手元で実行できるコマンドを提示できる。 + """ + sock = environ.get("VSCODE_IPC_HOOK_CLI") + if not sock: + return False + try: + return os.path.exists(sock) + except OSError: # 権限エラー等は「使えない」とみなす + return False + + def detect_context(environ=None, isatty: Optional[bool] = None, - system: Optional[str] = None) -> EditorContext: + system: Optional[str] = None, + ipc_alive: Optional[bool] = None) -> EditorContext: """env / OS からエディタ起動先判定に必要なコンテキストを抽出する。 引数はテスト用の差し替え口。未指定なら ``os.environ`` / ``sys.stdout`` / - ``platform.system()`` を用いる。 + ``platform.system()`` / :func:`_ipc_socket_alive` を用いる。 """ env = os.environ if environ is None else environ if isatty is None: isatty = _stdout_isatty() if system is None: system = platform.system() + if ipc_alive is None: + ipc_alive = _ipc_socket_alive(env) return EditorContext( is_tty=bool(isatty), - in_vscode=bool(env.get("VSCODE_IPC_HOOK_CLI")), + in_vscode=bool(ipc_alive), is_wsl=_detect_wsl(env), is_ssh=any(env.get(k) for k in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")), is_darwin=(system == "Darwin"), @@ -473,11 +507,17 @@ def decide_action(ctx: EditorContext, editor_available: bool) -> OpenPlan: def _launch(cmd: list, env: dict) -> None: - """エディタを非ブロッキングで起動する (up プロセスを待たせない)。""" + """エディタを非ブロッキングで起動する (up プロセスを待たせない)。 + + stdout は捨てるが **stderr は握り潰さない** (親へ継承する)。``code`` は IPC 接続に + 失敗すると stderr にのみ理由を出すため、ここを DEVNULL にすると「何も起きないが + エラーも出ない」という最も切り分けづらい失敗になる。非ブロッキング起動なので + メッセージは up の出力に遅れて混ざり得るが、無言よりは有用。 + """ subprocess.Popen( # noqa: S603 - argv はコード生成で外部入力を渡さない cmd, env=env, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, ) @@ -485,16 +525,28 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, index: int = 1, compose_file=None, environ=None, isatty: Optional[bool] = None, system: Optional[str] = None, + ipc_alive: Optional[bool] = None, launcher: Optional[Callable[[list, dict], None]] = None) -> str: """dev コンテナへ接続した VS Code を開く / コマンド提示 / スキップする。 戻り値は実行された action ('launch' | 'print_command' | 'skip')。例外は 握り潰して warning にし、``up`` 本体を絶対に失敗させない。``isatty`` / - ``system`` は :func:`detect_context` への差し替え口 (テスト用)。``compose_file`` - は実コンテナ名問い合わせ時に起動と同じ override compose を ``-f`` で渡すため。 + ``system`` / ``ipc_alive`` は :func:`detect_context` への差し替え口 (テスト用)。 + ``compose_file`` は実コンテナ名問い合わせ時に起動と同じ override compose を + ``-f`` で渡すため。 """ env = os.environ if environ is None else environ - ctx = detect_context(env, isatty=isatty, system=system) + ctx = detect_context(env, isatty=isatty, system=system, ipc_alive=ipc_alive) + # 変数だけ残って実体が消えた 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 統合" + "ターミナルとしては扱いません。tmux/screen のセッションを再利用している" + "場合や VS Code のウィンドウをリロードした後の古い端末で起きます。", + stale_ipc, + ) editor = resolve_editor_cmd(env) # launch 用 (which 込み・None あり得る) display = resolve_editor_display(env) # print 用 (必ず非 None) plan = decide_action(ctx, editor_available=bool(editor)) diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 69f8ec7..55ddf38 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -65,12 +65,33 @@ def test_detect_context_ssh(key): assert ctx.is_ssh is True -def test_detect_context_in_vscode(): - ctx = opener.detect_context(environ={"VSCODE_IPC_HOOK_CLI": "/run/x.sock"}, +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") assert ctx.in_vscode is True +def test_detect_context_stale_ipc_socket_is_not_vscode(tmp_path): + """変数だけ残りソケットが消えた状態 (tmux 再アタッチ等) は in_vscode=False。 + + ここを True に倒すと code が死んだソケットへ繋ぎに行き無言で失敗する。 + """ + ctx = opener.detect_context( + environ={"VSCODE_IPC_HOOK_CLI": str(tmp_path / "gone.sock")}, + 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"}, + isatty=True, system="Linux", ipc_alive=True) + assert ctx.in_vscode is True + + # --------------------------------------------------------------------------- # is_open_enabled # --------------------------------------------------------------------------- @@ -535,7 +556,7 @@ def test_open_editor_launch_nested_uri_under_remote_ssh(monkeypatch): environ={"VSCODE_IPC_HOOK_CLI": "/run/x.sock", "SSH_CONNECTION": "192.168.1.16 5 192.168.1.201 22", "DEVBASE_EDITOR_SSH_HOST": "mac2"}, - isatty=True, launcher=lambda cmd, env: calls.append(cmd), + isatty=True, ipc_alive=True, launcher=lambda cmd, env: calls.append(cmd), ) assert result == "launch" uri = calls[0][2] @@ -554,11 +575,37 @@ def test_open_editor_flat_uri_when_ssh_host_unset(monkeypatch): project_name="adminer", dev_service_name="dev", workdir="/work/adminer", environ={"VSCODE_IPC_HOOK_CLI": "/run/x.sock", "SSH_CONNECTION": "192.168.1.16 5 192.168.1.201 22"}, - isatty=True, launcher=lambda cmd, env: calls.append(cmd), + isatty=True, ipc_alive=True, launcher=lambda cmd, env: calls.append(cmd), ) assert "@ssh-remote" not in calls[0][2] +def test_open_editor_stale_ipc_under_ssh_degrades_to_print_command( + monkeypatch, tmp_path, caplog): + """死んだ IPC ソケット + SSH は launch せず print_command へ degrade する。 + + tmux セッションを再利用した端末で `devbase up --open` が「何も起きない」 + という無言の失敗になっていた回帰の防止 (実在チェック導入前は launch していた)。 + """ + import logging + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + calls = [] + with caplog.at_level(logging.INFO): + result = opener.open_editor( + project_name="adminer", dev_service_name="dev", + workdir="/work/adminer", + environ={"VSCODE_IPC_HOOK_CLI": str(tmp_path / "gone.sock"), + "SSH_CONNECTION": "192.168.1.16 5 192.168.1.201 22"}, + isatty=True, launcher=lambda cmd, env: calls.append(cmd), + ) + assert result == "print_command" + assert calls == [] + text = "\n".join(r.getMessage() for r in caplog.records) + # 原因が分かるログが出ていること (無言の失敗にしない) + assert "VSCODE_IPC_HOOK_CLI" in text + assert "code --folder-uri" in text + + def test_open_editor_skip_when_no_editor(monkeypatch): monkeypatch.setattr(opener.shutil, "which", lambda c: None) calls = [] From 36704b4e54dbeb149bb53579efa8661d1ce8d57f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 03:45:47 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=5Fipc=5Fsocket=5Falive=20?= =?UTF-8?q?=E3=81=AE=E5=86=97=E9=95=B7=E3=81=AA=20try/except=20OSError=20?= =?UTF-8?q?=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit os.path.exists() は内部で OSError / ValueError を捕捉して False を返すため、 呼び出し側の try...except OSError は冗長だった。意図をコメントで残しつつ削除する。 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/editor/opener.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index a9e1575..e9d43d7 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -123,10 +123,9 @@ def _ipc_socket_alive(environ) -> bool: sock = environ.get("VSCODE_IPC_HOOK_CLI") if not sock: return False - try: - return os.path.exists(sock) - except OSError: # 権限エラー等は「使えない」とみなす - return False + # os.path.exists() は OSError / ValueError を内部で捕捉して False を返すため、 + # 権限エラーや不正なパスもここで「使えない」と判定される。 + return os.path.exists(sock) def detect_context(environ=None, isatty: Optional[bool] = None,