Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,6 +90,16 @@
`. <DEVBASE_ROOT>/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-<uuid>.sock` で失敗していました。
**実際に connect して**生死を判定するようにし (タイムアウト 0.5 秒)、死んでいる
場合は従来どおり警告のうえ degrade します。
- **tmux / screen 経由のターミナルで `devbase up --open` が無言で失敗する問題を修正**
しました。VS Code はウィンドウごとに IPC ソケット (`$TMPDIR/vscode-ipc-<uuid>.sock`)
を作り直しますが、tmux サーバーはセッション作成時の環境変数を保持し続けるため、
Expand Down
16 changes: 14 additions & 2 deletions docs/user/environment-variables.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -194,7 +194,16 @@ VS Code は統合ターミナルごとに `$TMPDIR/vscode-ipc-<uuid>.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` に以下を追記してください。

Expand All@@ -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
Expand Down
68 changes: 50 additions & 18 deletions lib/devbase/editor/opener.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,10 +8,17 @@
Remote-SSH 統合ターミナルでは ``code`` シムがクライアント (例: Windows) に窓を
開く。よって ``code --folder-uri <attach-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 上コンテナ) では
Expand All@@ -36,6 +43,7 @@
import re
import shlex
import shutil
import socket
import subprocess
import sys
from dataclasses import dataclass
Expand DownExpand Up@@ -102,30 +110,52 @@ 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-<uuid>.sock`` を作り、ウィンドウを閉じる/リロードすると
削除するため、**変数だけが古いまま残る**状況が日常的に起きる:
「変数が設定されているか」だけでは不十分で、**ファイルの実在確認でも足りない**。
VS Code はウィンドウごとに ``$TMPDIR/vscode-ipc-<uuid>.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 して
ユーザが手元で実行できるコマンドを提示できる。
このときソケットの死に方は 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,
Expand DownExpand Up@@ -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 あり得る)
Expand Down
65 changes: 59 additions & 6 deletions tests/editor/test_opener.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@

import json
import os
import shutil
from dataclasses import dataclass

import pytest
Expand DownExpand Up@@ -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


Expand All@@ -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"},
Expand Down
Loading