fix(lsp): defer LSP client start until bridge handshake complete

Sublime's LSP package reads .sublime-project rows directly at boot, so
managed LSP-pyright / LSP-ruff entries left ``enabled: true`` from the
previous Sublime PID immediately spawn ``local_bridge lsp-stdio``
against a broker socket whose path encodes the dead PID
(``sessions-local-bridge-<host>-<pid>.sock``). The helper exits 1, the
LSP package retries 5x in 180s, then disables pyright/ruff for the rest
of the session — observable as a crash storm in the console before the
user does anything.

Add a ``disable_stale_managed_lsp_rows_on_disk`` helper that flips
``enabled: false`` on every Sessions-managed LSP row whose
``--bridge-socket`` is missing or stale, preserving live rows and any
user-managed (``sessions_remote_stdio_managed: false``) rows untouched.
Wire it into ``register_sessions_transport_hooks`` so plugin_loaded
runs the disable across every open Sessions workspace before the LSP
package gets a chance to spawn the helper. Once
``_on_persistent_bridge_handshake_ready`` fires, the existing refresh
path rewrites the same rows with ``enabled: true`` plus the live broker
socket and triggers ``lsp_restart_server`` so pyright/ruff attach
cleanly.

Also threads a ``managed_lsp_enabled`` keyword through
``build_managed_lsp_settings_block`` /
``merge_sessions_lsp_into_project_data`` /
``refresh_project_file_lsp_block`` so future callers can write the
same disabled row without going through the disk-only helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 09:07:06 +09:00
parent 9204fde2f4
commit a108f383ea
4 changed files with 552 additions and 2 deletions

View File

@@ -77,6 +77,7 @@ from .jupyter_hosting import (
)
from .lsp_project_wiring import (
collect_lsp_diagnostics_snapshot,
disable_stale_managed_lsp_rows_on_disk,
existing_managed_broker_sockets,
explain_lsp_attach_blockers,
format_lsp_diagnostics_panel_text,
@@ -4304,6 +4305,85 @@ def _workspace_runtime_connected(
def register_sessions_transport_hooks() -> None:
"""Wire transport observers (bridge handshake → project LSP refresh)."""
register_bridge_handshake_listener(_on_persistent_bridge_handshake_ready)
_disable_pre_handshake_managed_lsp_rows_on_open_windows()
def _disable_pre_handshake_managed_lsp_rows_on_open_windows() -> None:
"""Force ``enabled: false`` on every Sessions ``.sublime-project`` at boot.
Sublime's LSP package reads the project file directly when the window
activates, so any managed LSP row left ``enabled: true`` from the
previous Sublime PID will spawn ``local_bridge lsp-stdio`` against a
broker socket whose path encodes the dead PID
(``sessions-local-bridge-<host>-<pid>.sock``). The helper exits 1
immediately, the LSP package retries 5x in 180s, and pyright/ruff get
permanently disabled for the session — observable as the LSP crash
storm at boot. Disabling rows on disk before any view activation gives
the bridge handshake time to complete; once
:func:`_on_persistent_bridge_handshake_ready` fires it rewrites the
same rows with ``enabled: true`` and the live broker socket, then
triggers ``lsp_restart_server`` so pyright/ruff attach cleanly.
Best-effort: any I/O / JSON error is swallowed (traced) so a malformed
user project file cannot block plugin load.
"""
windows_fn = getattr(sublime, "windows", None)
if not callable(windows_fn):
return
try:
windows_iter = list(windows_fn())
except Exception as exc: # noqa: BLE001 — Sublime API edge cases at boot
_trace_event(
"lsp.pre_handshake_disable_failed",
stage="enumerate_windows",
error=repr(exc),
)
return
for window in windows_iter:
try:
_disable_pre_handshake_managed_lsp_rows_for_window(window)
except Exception as exc: # noqa: BLE001 — best-effort per window
_trace_event(
"lsp.pre_handshake_disable_failed",
stage="per_window",
error=repr(exc),
)
def _disable_pre_handshake_managed_lsp_rows_for_window(window: object) -> None:
"""Run :func:`disable_stale_managed_lsp_rows_on_disk` for one window."""
project_file_name_fn = getattr(window, "project_file_name", None)
project_path_str = (
project_file_name_fn() if callable(project_file_name_fn) else None
)
if not isinstance(project_path_str, str) or not project_path_str.strip():
return
project_path = Path(project_path_str)
if not project_path.is_file():
return
settings = SessionsSettings()
context = _workspace_context(window, settings, missing_detail_message=False)
if context is None:
return
host_alias = getattr(context.recent_entry, "host_alias", "") or ""
handshake = bridge_handshake_info(host_alias) if host_alias else None
live_socket: Optional[str] = None
if isinstance(handshake, dict):
candidate = handshake.get("broker_socket")
if isinstance(candidate, str) and candidate.strip():
live_socket = candidate.strip()
flipped = disable_stale_managed_lsp_rows_on_disk(
project_path,
live_broker_socket=live_socket,
)
if flipped:
_trace_event(
"lsp.pre_handshake_disable_applied",
host_alias=host_alias,
cache_key=context.cache_key,
disabled_clients=flipped,
live_broker_socket=live_socket,
)
def _on_persistent_bridge_handshake_ready(host_alias: str) -> None:

View File

@@ -190,12 +190,23 @@ def build_managed_lsp_settings_block(
host_alias: str,
local_cache_root: str,
active_python_path: Optional[str] = None,
managed_lsp_enabled: bool = True,
) -> Dict[str, Any]:
"""Return an ``LSP`` settings subtree for managed remote stdio clients.
When ``active_python_path`` is supplied, the pyright client row also gets
``settings.python.pythonPath`` pointing at that remote interpreter so
LSP-pyright uses the chosen environment.
``managed_lsp_enabled`` controls the per-row ``"enabled"`` flag. The
bridge handshake must have completed (broker socket present + listening)
before LSP clients can attach; spawning ``local_bridge lsp-stdio``
against a stale or missing broker_socket exits 1 immediately, and the
Sublime LSP package retries five times in 180s before disabling the
client for the session. To avoid that crash storm at Sublime boot the
refresh path passes ``managed_lsp_enabled=False`` until the broker
socket is observed live, then flips back to ``True`` once
``_on_persistent_bridge_handshake_ready`` fires.
"""
local_uri, remote_uri = lsp_uri_prefix_pair(
local_cache_root=local_cache_root,
@@ -229,7 +240,7 @@ def build_managed_lsp_settings_block(
settings_block["python"] = {"pythonPath": active_python_path}
lsp_root[entry.project_client_key] = {
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
"enabled": True,
"enabled": bool(managed_lsp_enabled),
"selector": entry.sublime_selector,
"command": command,
"settings": settings_block,
@@ -247,8 +258,13 @@ def merge_sessions_lsp_into_project_data(
host_alias: str,
local_cache_root: str,
active_python_path: Optional[str] = None,
managed_lsp_enabled: bool = True,
) -> Dict[str, Any]:
"""Return a copy of ``project_data`` with managed ``settings.LSP`` rows merged."""
"""Return a copy of ``project_data`` with managed ``settings.LSP`` rows merged.
See :func:`build_managed_lsp_settings_block` for the meaning of
``managed_lsp_enabled``.
"""
base = dict(project_data)
settings = _as_str_dict(base.get("settings"))
existing_lsp = _as_str_dict(settings.get("LSP"))
@@ -260,6 +276,7 @@ def merge_sessions_lsp_into_project_data(
host_alias=host_alias,
local_cache_root=local_cache_root,
active_python_path=active_python_path,
managed_lsp_enabled=managed_lsp_enabled,
)
merged_lsp: Dict[str, Any] = dict(existing_lsp)
_normalize_managed_lsp_client_aliases(merged_lsp)
@@ -393,6 +410,7 @@ def refresh_project_file_lsp_block(
host_alias: str,
local_cache_root: str,
active_python_path: Optional[str] = None,
managed_lsp_enabled: bool = True,
) -> Dict[str, Any]:
"""Read project JSON from disk, merge managed LSP, write back, return merged.
@@ -403,6 +421,9 @@ def refresh_project_file_lsp_block(
Cargo.lock / .sublime-project touch, and we got user feedback that the
noise was excessive. The short-circuit preserves the merged return value
for callers that depend on it.
See :func:`build_managed_lsp_settings_block` for the meaning of
``managed_lsp_enabled``.
"""
raw = project_file_path.read_text(encoding="utf-8")
existing = _parse_sublime_project_json(raw)
@@ -417,6 +438,7 @@ def refresh_project_file_lsp_block(
host_alias=host_alias,
local_cache_root=local_cache_root,
active_python_path=active_python_path,
managed_lsp_enabled=managed_lsp_enabled,
)
rendered = json.dumps(merged, indent=2, sort_keys=True) + "\n"
if rendered != raw:
@@ -424,6 +446,81 @@ def refresh_project_file_lsp_block(
return merged
def disable_stale_managed_lsp_rows_on_disk(
project_file_path: Path,
*,
live_broker_socket: Optional[str] = None,
) -> List[str]:
"""Set ``enabled: false`` on managed LSP rows whose broker socket is dead.
Called at Sublime startup before the bridge handshake completes (and
before LSP-pyright / LSP-ruff get a chance to spawn the Sessions
``local_bridge lsp-stdio`` helper against a stale ``--bridge-socket``
path left over from the previous Sublime PID). Without this gate the
helper exits 1 immediately, the LSP package retries 5 times in 180s,
then disables both clients for the entire session — observable as a
crash storm in the console at boot.
Returns the list of client keys whose ``enabled`` flag flipped to
``False`` so the caller can emit a single trace summarising the
pre-handshake disable. Writes to disk only when at least one row
changed; preserves user-managed (``sessions_remote_stdio_managed:
False``) rows untouched.
``live_broker_socket`` is the broker socket reported by the current
handshake (if any). When provided, rows whose ``--bridge-socket``
already matches the live path are left enabled.
"""
try:
raw = project_file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return []
try:
existing = _parse_sublime_project_json(raw)
except json.JSONDecodeError:
return []
if not isinstance(existing, dict):
return []
settings = existing.get("settings")
if not isinstance(settings, dict):
return []
lsp = settings.get("LSP")
if not isinstance(lsp, dict):
return []
live = (live_broker_socket or "").strip()
flipped: List[str] = []
for client_key, row in lsp.items():
if not isinstance(row, dict):
continue
if not row.get(SESSIONS_REMOTE_LSP_MANAGED_KEY):
continue
if row.get("enabled") is False:
continue
command = row.get("command")
row_socket = ""
if isinstance(command, list):
for i, arg in enumerate(command):
if arg == "--bridge-socket" and i + 1 < len(command):
next_arg = command[i + 1]
row_socket = str(next_arg) if isinstance(next_arg, str) else ""
break
# Row's broker socket already matches a live one — leave enabled.
if live and row_socket == live and Path(row_socket).exists():
continue
# Anything else (empty, stale path, missing file) is unsafe to keep
# enabled until the handshake refresh re-validates the socket.
row["enabled"] = False
flipped.append(str(client_key))
if not flipped:
return []
rendered = json.dumps(existing, indent=2, sort_keys=True) + "\n"
if rendered == raw:
# Spelling difference only (e.g. key order); no semantic change.
return []
project_file_path.write_text(rendered, encoding="utf-8")
return sorted(flipped)
def trace_lsp_workspace_activation(
*,
host_alias: str,
@@ -504,6 +601,7 @@ __all__ = (
"SESSIONS_REMOTE_LSP_MANAGED_KEY",
"build_managed_lsp_settings_block",
"collect_lsp_diagnostics_snapshot",
"disable_stale_managed_lsp_rows_on_disk",
"existing_managed_broker_sockets",
"explain_lsp_attach_blockers",
"format_lsp_diagnostics_panel_text",

View File

@@ -275,6 +275,160 @@ def test_register_sessions_transport_hooks_idempotent(
assert len(calls) == 2
def test_register_sessions_transport_hooks_disables_stale_lsp_rows_on_open_windows(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``plugin_loaded`` flips ``enabled: false`` on every workspace's stale rows.
This is the boot-time gate that prevents the LSP-pyright / LSP-ruff crash
storm: the previous Sublime PID's broker socket path
(``sessions-local-bridge-<host>-<pid>.sock``) is dead, so leaving the row
enabled would let the LSP package spawn ``local_bridge lsp-stdio`` which
exits 1 immediately and gets disabled after 5 retries.
"""
host = "devhost"
proj = tmp_path / "p.sublime-project"
stale_sock = tmp_path / "stale.sock" # never created → stale
proj.write_text(
json.dumps(
{
"folders": [],
"settings": {
"LSP": {
"LSP-pyright": {
"sessions_remote_stdio_managed": True,
"enabled": True,
"command": [
"/fake/bridge",
"lsp-stdio",
"--bridge-socket",
str(stale_sock),
],
},
"LSP-ruff": {
"sessions_remote_stdio_managed": True,
"enabled": True,
"command": [
"/fake/bridge",
"lsp-stdio",
"--bridge-socket",
str(stale_sock),
],
},
}
},
}
),
encoding="utf-8",
)
window = FakeWindow()
window.project_file_name = lambda: str(proj)
ctx = _make_workspace_context(tmp_path, host_alias=host)
monkeypatch.setattr(
commands, "register_bridge_handshake_listener", lambda _cb: None
)
monkeypatch.setattr(commands.sublime, "windows", lambda: [window])
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **k: ctx)
monkeypatch.setattr(commands, "bridge_handshake_info", lambda _h: None)
monkeypatch.setattr(commands, "_trace_event", lambda *a, **k: None)
commands.register_sessions_transport_hooks()
rows = json.loads(proj.read_text(encoding="utf-8"))["settings"]["LSP"]
assert rows["LSP-pyright"]["enabled"] is False
assert rows["LSP-ruff"]["enabled"] is False
def test_register_sessions_transport_hooks_keeps_live_socket_enabled(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If a row's broker socket is already live, ``enabled`` stays ``True``."""
host = "devhost"
live_sock = tmp_path / "live.sock"
live_sock.write_text("", encoding="utf-8")
proj = tmp_path / "p.sublime-project"
proj.write_text(
json.dumps(
{
"settings": {
"LSP": {
"LSP-pyright": {
"sessions_remote_stdio_managed": True,
"enabled": True,
"command": [
"/fake/bridge",
"lsp-stdio",
"--bridge-socket",
str(live_sock),
],
}
}
}
}
),
encoding="utf-8",
)
window = FakeWindow()
window.project_file_name = lambda: str(proj)
ctx = _make_workspace_context(tmp_path, host_alias=host)
monkeypatch.setattr(
commands, "register_bridge_handshake_listener", lambda _cb: None
)
monkeypatch.setattr(commands.sublime, "windows", lambda: [window])
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **k: ctx)
monkeypatch.setattr(
commands,
"bridge_handshake_info",
lambda _h: {"broker_socket": str(live_sock)},
)
monkeypatch.setattr(commands, "_trace_event", lambda *a, **k: None)
commands.register_sessions_transport_hooks()
rows = json.loads(proj.read_text(encoding="utf-8"))["settings"]["LSP"]
assert rows["LSP-pyright"]["enabled"] is True
def test_register_sessions_transport_hooks_skips_non_sessions_window(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Non-Sessions windows must not have their ``.sublime-project`` rewritten."""
proj = tmp_path / "external.sublime-project"
original = {
"settings": {
"LSP": {
"LSP-pyright": {
"enabled": True,
"command": ["custom-pyright"],
}
}
}
}
proj.write_text(json.dumps(original), encoding="utf-8")
raw_before = proj.read_text(encoding="utf-8")
window = FakeWindow()
window.project_file_name = lambda: str(proj)
monkeypatch.setattr(
commands, "register_bridge_handshake_listener", lambda _cb: None
)
monkeypatch.setattr(commands.sublime, "windows", lambda: [window])
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **k: None)
monkeypatch.setattr(commands, "_trace_event", lambda *a, **k: None)
commands.register_sessions_transport_hooks()
assert proj.read_text(encoding="utf-8") == raw_before
def test_sessions_diagnose_lsp_workspace_shows_panel(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:

View File

@@ -12,7 +12,9 @@ from sessions.lsp_project_wiring import (
SESSIONS_LSP_RUFF_CLIENT_KEY,
SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY,
SESSIONS_REMOTE_LSP_MANAGED_KEY,
build_managed_lsp_settings_block,
collect_lsp_diagnostics_snapshot,
disable_stale_managed_lsp_rows_on_disk,
existing_managed_broker_sockets,
explain_lsp_attach_blockers,
format_lsp_diagnostics_panel_text,
@@ -533,3 +535,219 @@ def test_existing_managed_broker_sockets_missing_command_arg(
encoding="utf-8",
)
assert existing_managed_broker_sockets(project_path) == [("LSP-pyright", "")]
def test_build_managed_lsp_settings_block_disabled_when_flag_false(
tmp_path: Path,
) -> None:
"""``managed_lsp_enabled=False`` writes ``enabled: false`` per row."""
block = build_managed_lsp_settings_block(
bridge_path="/bridge",
broker_socket="/sock",
workspace_id="ws1",
remote_workspace_root="/r",
host_alias="dev",
local_cache_root=str(tmp_path / "c"),
managed_lsp_enabled=False,
)
for client_key, row in block.items():
assert row["enabled"] is False, client_key
assert row[SESSIONS_REMOTE_LSP_MANAGED_KEY] is True, client_key
def test_merge_propagates_managed_lsp_disabled(tmp_path: Path) -> None:
"""``merge_sessions_lsp_into_project_data`` forwards the disable flag."""
merged = merge_sessions_lsp_into_project_data(
{"settings": {}},
bridge_path="/b",
broker_socket="",
workspace_id="w",
remote_workspace_root="/r",
host_alias="h",
local_cache_root=str(tmp_path / "c"),
managed_lsp_enabled=False,
)
pyright = merged["settings"]["LSP"][SESSIONS_LSP_PYRIGHT_CLIENT_KEY]
assert pyright["enabled"] is False
ruff = merged["settings"]["LSP"][SESSIONS_LSP_RUFF_CLIENT_KEY]
assert ruff["enabled"] is False
def test_refresh_project_file_lsp_block_propagates_disabled_flag(
tmp_path: Path,
) -> None:
proj = tmp_path / "ws.sublime-project"
(tmp_path / "lc").mkdir()
proj.write_text(json.dumps({"settings": {}}), encoding="utf-8")
merged = refresh_project_file_lsp_block(
proj,
bridge_path="/bridge",
broker_socket="",
workspace_id="w",
remote_workspace_root="/r",
host_alias="h",
local_cache_root=str(tmp_path / "lc"),
managed_lsp_enabled=False,
)
pyright = merged["settings"]["LSP"][SESSIONS_LSP_PYRIGHT_CLIENT_KEY]
assert pyright["enabled"] is False
on_disk = json.loads(proj.read_text(encoding="utf-8"))
assert (
on_disk["settings"]["LSP"][SESSIONS_LSP_PYRIGHT_CLIENT_KEY]["enabled"] is False
)
def test_disable_stale_managed_lsp_rows_flips_enabled_when_socket_missing(
tmp_path: Path,
) -> None:
proj = tmp_path / "ws.sublime-project"
proj.write_text(
json.dumps(
{
"settings": {
"LSP": {
SESSIONS_LSP_PYRIGHT_CLIENT_KEY: {
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
"enabled": True,
"command": [
"/bridge",
"lsp-stdio",
"--bridge-socket",
str(tmp_path / "stale.sock"),
],
},
SESSIONS_LSP_RUFF_CLIENT_KEY: {
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
"enabled": True,
"command": [
"/bridge",
"lsp-stdio",
"--bridge-socket",
str(tmp_path / "stale.sock"),
],
},
}
}
}
),
encoding="utf-8",
)
flipped = disable_stale_managed_lsp_rows_on_disk(proj)
assert flipped == [SESSIONS_LSP_PYRIGHT_CLIENT_KEY, SESSIONS_LSP_RUFF_CLIENT_KEY]
after = json.loads(proj.read_text(encoding="utf-8"))
rows = after["settings"]["LSP"]
assert rows[SESSIONS_LSP_PYRIGHT_CLIENT_KEY]["enabled"] is False
assert rows[SESSIONS_LSP_RUFF_CLIENT_KEY]["enabled"] is False
def test_disable_stale_managed_lsp_rows_keeps_live_socket_enabled(
tmp_path: Path,
) -> None:
live_sock = tmp_path / "live.sock"
live_sock.write_text("", encoding="utf-8")
proj = tmp_path / "ws.sublime-project"
proj.write_text(
json.dumps(
{
"settings": {
"LSP": {
SESSIONS_LSP_PYRIGHT_CLIENT_KEY: {
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
"enabled": True,
"command": [
"/bridge",
"lsp-stdio",
"--bridge-socket",
str(live_sock),
],
},
SESSIONS_LSP_RUFF_CLIENT_KEY: {
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
"enabled": True,
"command": [
"/bridge",
"lsp-stdio",
"--bridge-socket",
str(tmp_path / "stale.sock"),
],
},
}
}
}
),
encoding="utf-8",
)
flipped = disable_stale_managed_lsp_rows_on_disk(
proj, live_broker_socket=str(live_sock)
)
assert flipped == [SESSIONS_LSP_RUFF_CLIENT_KEY]
rows = json.loads(proj.read_text(encoding="utf-8"))["settings"]["LSP"]
assert rows[SESSIONS_LSP_PYRIGHT_CLIENT_KEY]["enabled"] is True
assert rows[SESSIONS_LSP_RUFF_CLIENT_KEY]["enabled"] is False
def test_disable_stale_managed_lsp_rows_skips_user_managed(
tmp_path: Path,
) -> None:
proj = tmp_path / "ws.sublime-project"
proj.write_text(
json.dumps(
{
"settings": {
"LSP": {
SESSIONS_LSP_PYRIGHT_CLIENT_KEY: {
SESSIONS_REMOTE_LSP_MANAGED_KEY: False,
"enabled": True,
"command": ["custom-pyright"],
}
}
}
}
),
encoding="utf-8",
)
assert disable_stale_managed_lsp_rows_on_disk(proj) == []
rows = json.loads(proj.read_text(encoding="utf-8"))["settings"]["LSP"]
assert rows[SESSIONS_LSP_PYRIGHT_CLIENT_KEY]["enabled"] is True
def test_disable_stale_managed_lsp_rows_handles_missing_file(
tmp_path: Path,
) -> None:
missing = tmp_path / "does-not-exist.sublime-project"
assert disable_stale_managed_lsp_rows_on_disk(missing) == []
def test_disable_stale_managed_lsp_rows_handles_malformed_json(
tmp_path: Path,
) -> None:
proj = tmp_path / "ws.sublime-project"
proj.write_text("{not-json", encoding="utf-8")
assert disable_stale_managed_lsp_rows_on_disk(proj) == []
def test_disable_stale_managed_lsp_rows_no_op_when_already_disabled(
tmp_path: Path,
) -> None:
proj = tmp_path / "ws.sublime-project"
original = {
"settings": {
"LSP": {
SESSIONS_LSP_PYRIGHT_CLIENT_KEY: {
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
"enabled": False,
"command": [
"/bridge",
"lsp-stdio",
"--bridge-socket",
"/already/missing.sock",
],
}
}
}
}
proj.write_text(json.dumps(original), encoding="utf-8")
raw_before = proj.read_text(encoding="utf-8")
assert disable_stale_managed_lsp_rows_on_disk(proj) == []
# File contents unchanged when no row needed flipping.
assert proj.read_text(encoding="utf-8") == raw_before