mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
fix: serialize on-demand slash-worker spawn per session
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.
This commit is contained in:
parent
5aa3536b32
commit
3b762ba695
3 changed files with 75 additions and 9 deletions
1
contributors/emails/mpetrelli@gmail.com
Normal file
1
contributors/emails/mpetrelli@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
Ne0teric
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue