From abb95255e2327c9bb6e139309511d47a7d3845eb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 20 Aug 2026 01:12:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(editor):=20IPC=20=E3=82=BD=E3=82=B1?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=81=8C=E6=AD=BB=E3=82=93=E3=81=A7=E3=81=84?= =?UTF-8?q?=E3=81=9F=E3=82=89=20tmux=20=E3=82=BB=E3=83=83=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E7=92=B0=E5=A2=83=E3=81=8B=E3=82=89=E6=8B=BE?= =?UTF-8?q?=E3=81=84=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tmux サーバーはセッション作成時の環境変数を保持し続けるが、update-environment に 登録された変数は attach のたびに接続元クライアントの値へ更新される。そのため 「ペインのシェルは古い値、tmux のセッション環境は新しい値」という状態が日常的に 起きる。これまではシェル側のプロンプトフックを入れていない限り、devbase up --open が毎回 print_command へ degrade していた。 - _tmux_env() を追加。TMUX 未設定なら tmux を起動しない。`-NAME` (削除済み) や tmux 不在・非ゼロ終了は None へ倒す。タイムアウト 2 秒で up を止めない - resolve_ipc_socket() を追加。env の値が生きていればそれを使い、死んでいて tmux 内なら セッション環境の値を試す - EditorContext に ipc_socket を追加し、open_editor は拾い直した値を起動する code の env にも渡す。ここを差し替えないと code 自身が古いソケットへ繋ぎに行って失敗する - 接続確認を _socket_connectable() としてパス単位に切り出し - 実環境で検証: 死んだ 53b8ca02 を tmux 側の 8dd26478 へ解決し in_vscode=True になる tmux 外の挙動は変わらない (tmux を一切起動せず、従来どおり警告して degrade)。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01622CAKyYfonYuZ6AqRJvDg --- CHANGELOG.md | 8 ++ docs/user/environment-variables.md | 12 ++- lib/devbase/editor/opener.py | 89 ++++++++++++++++++-- tests/editor/test_opener.py | 125 +++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c6dcc..2d401fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ## [Unreleased] ### Added +- **tmux 内では `VSCODE_IPC_HOOK_CLI` が古くても VS Code を自動で開く**ようにしました。 + tmux サーバーはセッション作成時の環境変数を保持し続けますが、`update-environment` に + 登録した変数は attach のたびに更新されるため、**ペインのシェルは古い値・tmux の + セッション環境は新しい値**という状態が日常的に起きます。ソケットへ接続できなかった + 場合に `tmux show-environment` を参照して生きた値を拾い直し、起動する `code` の + 環境変数にも反映します (変数を差し替えないと `code` 自身が古いソケットへ繋ぎに行って + 失敗するため)。これまではシェル側のプロンプトフックを入れていないと毎回 + 「手元で実行するコマンドの提示」へ degrade していました。tmux 外の挙動は変わりません。 - **`tmux-first` コマンドを base イメージへ追加**しました。VS Code のウィンドウが 異常終了 (クラッシュ / ホスト再起動 / 接続断) すると、VS Code サーバー側に pty が 取り残され、tmux クライアントだけがセッションへ繋がったまま残ります。統合ターミナルの diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 4815297..e0cd444 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -164,7 +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 経由で使う場合」参照) | +| tmux 経由のターミナル | `VSCODE_IPC_HOOK_CLI` が古くなっていても、tmux のセッション環境から生きた値を拾い直して開く。拾えなければ「コマンド提示」へ degrade(下記「tmux / screen 経由で使う場合」参照) | | CI / 非対話(非 TTY) / `code` 不在 | 理由を表示してスキップ(`up` 自体は成功) | #### 跨ホスト(Windows VS Code → Remote-SSH → Mac のコンテナ) @@ -194,7 +194,11 @@ VS Code は統合ターミナルごとに `$TMPDIR/vscode-ipc-.sock` を tmux / screen はサーバープロセスが**セッション作成時の環境変数を保持し続ける**ため、ここが噛み合いません。VS Code のウィンドウをリロードしたり開き直したりするとソケットは作り直されますが、既存の tmux セッションに再アタッチした端末は**古いパスを引き継いだまま**になります。 -devbase は**実際にソケットへ接続できるかを確認**してから VS Code 統合ターミナルと判定します(変数の有無でも、ファイルの実在でも判定しません)。古い場合は警告を出したうえで「手元で実行するコマンドの提示」へ degrade するので、**黙って何も起きないという状態にはなりません**。提示されたコマンドを手元で実行すれば開けます。 +devbase は**実際にソケットへ接続できるかを確認**してから VS Code 統合ターミナルと判定します(変数の有無でも、ファイルの実在でも判定しません)。 + +死んでいた場合、**tmux 内なら `tmux show-environment` を見に行きます**。`update-environment`(後述)を設定していれば、tmux のセッション環境は attach のたびに更新されるので、**ペインのシェルが古くても tmux 側には生きた値が入っています**。拾えた場合はその値を使い、起動する `code` にも渡します(変数を差し替えないと `code` 自身が古いソケットへ繋ぎに行って失敗するため)。 + +拾えなかった場合は警告を出したうえで「手元で実行するコマンドの提示」へ degrade するので、**黙って何も起きないという状態にはなりません**。提示されたコマンドを手元で実行すれば開けます。 ソケットの死に方は 2 通りあります。 @@ -211,7 +215,9 @@ devbase は**実際にソケットへ接続できるかを確認**してから V 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` 等)にプロンプトフックを置きます。 +これで **attach のたびに**接続してきたクライアントの値でセッション環境が更新されます。**`devbase up` にはこの設定だけで十分**です(devbase がセッション環境を直接読むため)。 + +ただし更新されるのはセッション環境であり、**すでに起動しているペインのシェル**には波及しません。`code` を手で叩く、git の askpass を使うなど **devbase 以外**でも追随させたい場合は、シェルの rc(`~/.bash_profile` 等)にプロンプトフックを置きます。なお **rc を書き換えても、すでに動いているシェルには反映されません**(起動時に一度読むだけのため)。そのペインで直ちに効かせたいときは `source ~/.bash_profile` するか、開き直してください。 ```bash if [ -n "${TMUX:-}" ]; then diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 138834b..7371416 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -79,10 +79,13 @@ class EditorContext: """エディタ起動先の判定に使う実行コンテキスト。""" is_tty: bool - in_vscode: bool # VSCODE_IPC_HOOK_CLI が *生きている* ソケットを指している + in_vscode: bool # 生きている IPC ソケットが見つかっている is_wsl: bool is_ssh: bool is_darwin: bool + # 実際に使う IPC ソケット。env の VSCODE_IPC_HOOK_CLI が死んでいて tmux の + # セッション環境から拾い直した場合、env の値とは異なる (None は未解決)。 + ipc_socket: Optional[str] = None @dataclass(frozen=True) @@ -114,6 +117,10 @@ def _detect_wsl(environ) -> bool: # 即座に完了するため短くてよい。`devbase up` の最後に走るので体感を優先する。 _IPC_CONNECT_TIMEOUT = 0.5 +# tmux show-environment の待ち時間上限 (秒)。ローカルの tmux サーバーへの +# 問い合わせなので即答するが、応答が無いときに up を止めないよう上限を置く。 +_TMUX_TIMEOUT = 2.0 + def _ipc_socket_alive(environ) -> bool: """``VSCODE_IPC_HOOK_CLI`` が **応答するソケット** を指しているか。 @@ -139,7 +146,11 @@ def _ipc_socket_alive(environ) -> bool: **実際に connect して**判定する。False に倒せば SSH 経路なら ``print_command`` へ degrade して、ユーザが手元で実行できるコマンドを提示できる。 """ - sock = environ.get("VSCODE_IPC_HOOK_CLI") + return _socket_connectable(environ.get("VSCODE_IPC_HOOK_CLI")) + + +def _socket_connectable(sock: Optional[str]) -> bool: + """UNIX ドメインソケットのパスへ実際に接続できるか。""" if not sock: return False family = getattr(socket, "AF_UNIX", None) @@ -158,13 +169,66 @@ def _ipc_socket_alive(environ) -> bool: return False +def _tmux_env(name: str, environ) -> Optional[str]: + """tmux の **セッション環境** から変数を 1 つ読む。tmux 外なら None。 + + tmux サーバーはセッション作成時の環境変数を保持し続けるが、``update-environment`` + に登録された変数は **attach のたびに**接続してきたクライアントの値へ更新される。 + そのため「すでに動いているペインのシェルは古い値、tmux のセッション環境は新しい値」 + という状態が普通に起きる。ここはその新しい方を読むための口。 + + ``tmux show-environment `` は未設定の変数を ``-NAME`` の形で返すため、 + 値として扱わないようにする。 + """ + if not environ.get("TMUX"): + return None + try: + out = subprocess.run( # noqa: S603,S607 - 引数は固定、PATH 上の tmux を使う + ["tmux", "show-environment", name], + capture_output=True, text=True, timeout=_TMUX_TIMEOUT, check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if out.returncode != 0: + return None + line = out.stdout.strip() + prefix = f"{name}=" + if not line.startswith(prefix): + # "-NAME" (削除済み) や想定外の出力 + return None + return line[len(prefix):] or None + + +def resolve_ipc_socket(environ) -> Optional[str]: + """実際に使える VS Code IPC ソケットのパスを返す (無ければ None)。 + + 1. ``VSCODE_IPC_HOOK_CLI`` が生きていればそれを使う + 2. 死んでいて tmux 内なら、tmux のセッション環境の値を試す + + 2 が要るのは、tmux のセッション環境だけが新しく、ペインのシェルが古い値を + 抱えたままという状態が頻繁に起きるため (:func:`_tmux_env` 参照)。シェル側の + プロンプトフックで追随させる運用もあるが、それが入っていない環境でも + ``devbase up --open`` が自動で開けるように devbase 側でも拾いにいく。 + """ + current = environ.get("VSCODE_IPC_HOOK_CLI") + if current and _socket_connectable(current): + return current + candidate = _tmux_env("VSCODE_IPC_HOOK_CLI", environ) + if candidate and candidate != current and _socket_connectable(candidate): + return candidate + return None + + def detect_context(environ=None, isatty: Optional[bool] = None, system: Optional[str] = None, ipc_alive: Optional[bool] = None) -> EditorContext: """env / OS からエディタ起動先判定に必要なコンテキストを抽出する。 引数はテスト用の差し替え口。未指定なら ``os.environ`` / ``sys.stdout`` / - ``platform.system()`` / :func:`_ipc_socket_alive` を用いる。 + ``platform.system()`` / :func:`resolve_ipc_socket` を用いる。 + + ``ipc_alive`` を明示した場合はソケット解決を一切行わない。``True`` を渡した + ときの ``ipc_socket`` は env の値をそのまま入れる (テスト用の差し替え口)。 """ env = os.environ if environ is None else environ if isatty is None: @@ -172,13 +236,17 @@ def detect_context(environ=None, isatty: Optional[bool] = None, if system is None: system = platform.system() if ipc_alive is None: - ipc_alive = _ipc_socket_alive(env) + sock = resolve_ipc_socket(env) + ipc_alive = sock is not None + else: + sock = env.get("VSCODE_IPC_HOOK_CLI") if ipc_alive else None return EditorContext( is_tty=bool(isatty), 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"), + ipc_socket=sock, ) @@ -566,9 +634,20 @@ 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) + # tmux のセッション環境から拾い直せた場合は、起動する code にもその値を渡す。 + # 変数を差し替えないと code 自身が古いソケットへ繋ぎに行って失敗する。 + stale_ipc = env.get("VSCODE_IPC_HOOK_CLI") + if ctx.ipc_socket and ctx.ipc_socket != stale_ipc: + env = dict(env) + env["VSCODE_IPC_HOOK_CLI"] = ctx.ipc_socket + logger.info( + "VSCODE_IPC_HOOK_CLI が古かったため tmux のセッション環境から拾い直しました " + "(%s → %s)。ペインのシェルに追随させたい場合は環境変数ガイドの " + "tmux 設定を参照してください。", + stale_ipc or "(未設定)", ctx.ipc_socket, + ) # 変数だけ残って接続先が死んでいる 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 統合" diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index f1610c6..adf123e 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -6,6 +6,7 @@ import os import shutil from dataclasses import dataclass +from types import SimpleNamespace import pytest @@ -138,6 +139,130 @@ def test_detect_context_ipc_path_is_regular_file_is_not_vscode(tmp_path): assert ctx.in_vscode is False +# --------------------------------------------------------------------------- +# tmux セッション環境からの IPC ソケット拾い直し +# --------------------------------------------------------------------------- + +class _FakeRun: + """``subprocess.run`` の差し替え。tmux の出力を模す。""" + + def __init__(self, stdout="", returncode=0, exc=None): + self.stdout, self.returncode, self.exc = stdout, returncode, exc + self.calls = [] + + def __call__(self, cmd, **kw): + self.calls.append(cmd) + if self.exc: + raise self.exc + return SimpleNamespace(stdout=self.stdout, returncode=self.returncode) + + +def test_tmux_env_returns_none_outside_tmux(monkeypatch): + """tmux 外では tmux を起動しない (TMUX 未設定なら即 None)。""" + run = _FakeRun() + monkeypatch.setattr(opener.subprocess, "run", run) + assert opener._tmux_env("VSCODE_IPC_HOOK_CLI", {}) is None + assert run.calls == [] + + +def test_tmux_env_reads_value(monkeypatch): + run = _FakeRun(stdout="VSCODE_IPC_HOOK_CLI=/tmp/live.sock\n") + monkeypatch.setattr(opener.subprocess, "run", run) + got = opener._tmux_env("VSCODE_IPC_HOOK_CLI", {"TMUX": "/tmp/tmux-501/default,1,0"}) + assert got == "/tmp/live.sock" + assert run.calls[0][:2] == ["tmux", "show-environment"] + + +def test_tmux_env_treats_removed_marker_as_unset(monkeypatch): + """未設定の変数は ``-NAME`` で返るので値として扱わない。""" + monkeypatch.setattr(opener.subprocess, "run", + _FakeRun(stdout="-VSCODE_IPC_HOOK_CLI\n")) + assert opener._tmux_env("VSCODE_IPC_HOOK_CLI", {"TMUX": "x"}) is None + + +def test_tmux_env_survives_tmux_failure(monkeypatch): + """tmux が無い / 落ちても例外を投げず None を返す。""" + monkeypatch.setattr(opener.subprocess, "run", _FakeRun(exc=OSError("no tmux"))) + assert opener._tmux_env("VSCODE_IPC_HOOK_CLI", {"TMUX": "x"}) is None + monkeypatch.setattr(opener.subprocess, "run", _FakeRun(returncode=1)) + assert opener._tmux_env("VSCODE_IPC_HOOK_CLI", {"TMUX": "x"}) is None + + +def test_resolve_ipc_socket_prefers_live_env_value(monkeypatch, listening_ipc_socket): + """env の値が生きているなら tmux は見に行かない。""" + run = _FakeRun() + monkeypatch.setattr(opener.subprocess, "run", run) + got = opener.resolve_ipc_socket({"VSCODE_IPC_HOOK_CLI": listening_ipc_socket, + "TMUX": "x"}) + assert got == listening_ipc_socket + assert run.calls == [] + + +def test_resolve_ipc_socket_falls_back_to_tmux(monkeypatch, tmp_path, + listening_ipc_socket): + """env が死んでいて tmux 側が生きていれば拾い直す。""" + monkeypatch.setattr( + opener.subprocess, "run", + _FakeRun(stdout=f"VSCODE_IPC_HOOK_CLI={listening_ipc_socket}\n")) + got = opener.resolve_ipc_socket( + {"VSCODE_IPC_HOOK_CLI": str(tmp_path / "gone.sock"), "TMUX": "x"}) + assert got == listening_ipc_socket + + +def test_resolve_ipc_socket_none_when_tmux_value_also_dead(monkeypatch, tmp_path): + monkeypatch.setattr( + opener.subprocess, "run", + _FakeRun(stdout=f"VSCODE_IPC_HOOK_CLI={tmp_path / 'also-gone.sock'}\n")) + assert opener.resolve_ipc_socket( + {"VSCODE_IPC_HOOK_CLI": str(tmp_path / "gone.sock"), "TMUX": "x"}) is None + + +def test_open_editor_passes_recovered_socket_to_launcher( + monkeypatch, tmp_path, listening_ipc_socket): + """拾い直した値は起動する code の env にも渡す。 + + ここを差し替えないと code 自身が古いソケットへ繋ぎに行って失敗する。 + """ + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "adminer-dev-1") + monkeypatch.setattr( + opener.subprocess, "run", + _FakeRun(stdout=f"VSCODE_IPC_HOOK_CLI={listening_ipc_socket}\n")) + seen = {} + result = opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ={"VSCODE_IPC_HOOK_CLI": str(tmp_path / "gone.sock"), "TMUX": "x"}, + isatty=True, + launcher=lambda cmd, env: seen.update(cmd=cmd, env=env), + ) + assert result == "launch" + assert seen["env"]["VSCODE_IPC_HOOK_CLI"] == listening_ipc_socket + + +def test_open_editor_without_tmux_does_not_recover(monkeypatch, tmp_path, caplog): + """tmux 外では拾い直さず、従来どおり警告を出す。""" + import logging + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "adminer-dev-1") + run = _FakeRun() + monkeypatch.setattr(opener.subprocess, "run", run) + calls = [] + with caplog.at_level(logging.INFO): + opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ={"VSCODE_IPC_HOOK_CLI": str(tmp_path / "gone.sock")}, + isatty=True, launcher=lambda cmd, env: calls.append((cmd, env)), + ) + assert run.calls == [] # tmux を叩いていない + text = "\n".join(r.getMessage() for r in caplog.records) + assert "接続できません" in text + assert "拾い直しました" not in text + # env の VSCODE_IPC_HOOK_CLI は書き換えられていない + assert calls[0][1]["VSCODE_IPC_HOOK_CLI"].endswith("gone.sock") + + def test_detect_context_ipc_alive_override(): """``ipc_alive`` 明示時は実在チェックを行わない (テスト用差し替え口)。""" ctx = opener.detect_context(environ={"VSCODE_IPC_HOOK_CLI": "/run/x.sock"}, From ce5132fbd9c87a789c2953796a4a77bef0c7a1ad Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 20 Aug 2026 01:25:25 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(editor):=20=5Ftmux=5Fenv=20=E3=81=AB=20?= =?UTF-8?q?env=20=E3=82=92=E6=98=8E=E7=A4=BA=E3=81=97=E4=BE=8B=E5=A4=96?= =?UTF-8?q?=E6=8F=A1=E3=82=8A=E6=BD=B0=E3=81=97=E3=82=92=E5=BA=83=E3=81=92?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `TMUX` を environ で判定しているのに subprocess へ env を渡しておらず、 暗黙に os.environ の TMUX で tmux クライアントが起動していた。 `env={**os.environ, **environ}` を明示して同じ tmux サーバーを見るようにする (PATH 等を失わないよう os.environ に合成する)。 - `text=True` の decode 失敗 (UnicodeDecodeError) は OSError/SubprocessError の どちらでもないため素通りし、best-effort のはずの経路で `up` 全体が落ち得た。 同ファイル他箇所と同じ `except Exception` に揃える。 - 回帰テスト 1 件追加 (非 UTF-8 出力) + env 引き継ぎのアサーション追加。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01622CAKyYfonYuZ6AqRJvDg --- lib/devbase/editor/opener.py | 5 ++++- tests/editor/test_opener.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 7371416..6cfa776 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -185,9 +185,12 @@ def _tmux_env(name: str, environ) -> Optional[str]: try: out = subprocess.run( # noqa: S603,S607 - 引数は固定、PATH 上の tmux を使う ["tmux", "show-environment", name], + # TMUX を見て判定した以上、tmux クライアントも同じ環境に向ける + # (PATH 等は失わないよう os.environ に environ を上書き合成する)。 + env={**os.environ, **environ}, capture_output=True, text=True, timeout=_TMUX_TIMEOUT, check=False, ) - except (OSError, subprocess.SubprocessError): + except Exception: # noqa: BLE001 - tmux 不在/非UTF-8出力等で up を倒さない return None if out.returncode != 0: return None diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index adf123e..74bf58c 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -149,9 +149,11 @@ class _FakeRun: def __init__(self, stdout="", returncode=0, exc=None): self.stdout, self.returncode, self.exc = stdout, returncode, exc self.calls = [] + self.kwargs = [] def __call__(self, cmd, **kw): self.calls.append(cmd) + self.kwargs.append(kw) if self.exc: raise self.exc return SimpleNamespace(stdout=self.stdout, returncode=self.returncode) @@ -171,6 +173,10 @@ def test_tmux_env_reads_value(monkeypatch): got = opener._tmux_env("VSCODE_IPC_HOOK_CLI", {"TMUX": "/tmp/tmux-501/default,1,0"}) assert got == "/tmp/live.sock" assert run.calls[0][:2] == ["tmux", "show-environment"] + # 判定に使った environ をそのまま tmux クライアントへ渡す (別サーバーを見ない)。 + env = run.kwargs[0]["env"] + assert env["TMUX"] == "/tmp/tmux-501/default,1,0" + assert "PATH" in env # os.environ 由来のキーは失わない def test_tmux_env_treats_removed_marker_as_unset(monkeypatch): @@ -188,6 +194,13 @@ def test_tmux_env_survives_tmux_failure(monkeypatch): assert opener._tmux_env("VSCODE_IPC_HOOK_CLI", {"TMUX": "x"}) is None +def test_tmux_env_survives_non_utf8_output(monkeypatch): + """非 UTF-8 出力で text=True が投げる UnicodeDecodeError も握り潰す。""" + exc = UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + monkeypatch.setattr(opener.subprocess, "run", _FakeRun(exc=exc)) + assert opener._tmux_env("VSCODE_IPC_HOOK_CLI", {"TMUX": "x"}) is None + + def test_resolve_ipc_socket_prefers_live_env_value(monkeypatch, listening_ipc_socket): """env の値が生きているなら tmux は見に行かない。""" run = _FakeRun()