From 3b762ba6957c73a6a20216758e553face5cc92bd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:06:11 -0700 Subject: [PATCH] fix: serialize on-demand slash-worker spawn per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the eager pre-warm removed (PR #66783), slash.exec is the only spawn path — and it runs on the RPC thread pool, so two concurrent worker-routed commands on a fresh session could both see slash_worker=None and each fork a full stdio-MCP-fleet worker (the _attach_worker race loser leaking unclosed). Add a per-session spawn lock with a double-check, plus a regression test racing two slash.exec calls through handle_request. Also maps Ne0teric's contributor email. --- contributors/emails/mpetrelli@gmail.com | 1 + tests/test_tui_gateway_server.py | 54 +++++++++++++++++++++++++ tui_gateway/server.py | 29 ++++++++----- 3 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 contributors/emails/mpetrelli@gmail.com diff --git a/contributors/emails/mpetrelli@gmail.com b/contributors/emails/mpetrelli@gmail.com new file mode 100644 index 000000000000..0a943bf232e4 --- /dev/null +++ b/contributors/emails/mpetrelli@gmail.com @@ -0,0 +1 @@ +Ne0teric diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index d0df4b027712..5aa76b437315 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -12045,6 +12045,60 @@ def test_restart_slash_worker_noop_without_worker(monkeypatch): server._sessions.pop("lazy-noop", None) +def test_slash_exec_concurrent_first_use_spawns_single_worker(monkeypatch): + """With eager pre-warm removed, slash.exec is the only spawn path — two + concurrent worker-routed commands on a fresh session must not each fork a + full MCP-fleet worker. The per-session spawn lock serializes first use.""" + import time as _time + + spawned = [] + barrier = threading.Barrier(2, timeout=5) + + class _SlowWorker: + def __init__(self, *a, **k): + spawned.append(self) + _time.sleep(0.05) # widen the None-observation window + + def run(self, cmd): + return f"ran {cmd}" + + def close(self): + pass + + monkeypatch.setattr(server, "_SlashWorker", _SlowWorker) + monkeypatch.setattr(server, "_mirror_slash_side_effects", lambda *a, **k: None) + session = _session(slash_worker=None) + server._sessions["race-spawn"] = session + + results = [] + + def _exec(n): + barrier.wait() + resp = server.handle_request( + { + "id": str(n), + "method": "slash.exec", + "params": {"command": "/context", "session_id": "race-spawn"}, + } + ) + results.append(resp) + + try: + threads = [threading.Thread(target=_exec, args=(i,)) for i in range(2)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + assert len(spawned) == 1, ( + f"concurrent slash.exec spawned {len(spawned)} workers — first-use " + f"spawn must be serialized per session" + ) + assert session["slash_worker"] is spawned[0] + assert all("result" in r for r in results), results + finally: + server._sessions.pop("race-spawn", None) + + def test_session_close_rpc_claims_then_tears_down(monkeypatch): seen = [] claimed = {"session_key": "k"} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d3b6b152c8b0..0c48059bf241 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -16064,15 +16064,26 @@ def _(rid, params: dict) -> dict: worker = session.get("slash_worker") if not worker: - try: - worker = _SlashWorker( - session["session_key"], - getattr(session.get("agent"), "model", _resolve_model()), - profile_home=session.get("profile_home"), - ) - _attach_worker(params.get("session_id", ""), session, worker) - except Exception as e: - return _err(rid, 5030, f"slash worker start failed: {e}") + # On-demand spawn is now the ONLY spawn path for a fresh session + # (eager pre-warm removed), and slash.exec handlers run on the RPC + # thread pool — two concurrent slash commands on the same session + # could both observe slash_worker=None and each fork a full + # MCP-fleet worker (the loser of the _attach_worker race would leak + # unclosed). Serialize first-use spawn per session. + with _sessions_lock: + spawn_lock = session.setdefault("_slash_spawn_lock", threading.Lock()) + with spawn_lock: + worker = session.get("slash_worker") + if not worker: + try: + worker = _SlashWorker( + session["session_key"], + getattr(session.get("agent"), "model", _resolve_model()), + profile_home=session.get("profile_home"), + ) + _attach_worker(params.get("session_id", ""), session, worker) + except Exception as e: + return _err(rid, 5030, f"slash worker start failed: {e}") try: output = worker.run(cmd)