Files
hermes-webui/tests/test_issue3746_session_move_delete_timeout.py
T
nesquena-hermes 6be19804f5 fix(routes): bound session/move lock + safe project-delete unlink during streaming (#3746) (#3922)
Two distinct timeout causes, both surfacing as the client's 30s 'Request timed
out' toast with no server-side signal:

A) /api/session/move acquired the per-session agent lock with a bare unbounded
   'with _get_session_agent_lock(sid):'. The streaming thread holds that same
   lock during checkpoint saves; on slow file I/O (WSL/DrvFs) the move could
   block past the client abort. Now acquires with timeout=5 and returns HTTP 503
   on contention (lock kept, not dropped, since s.save() still races the writer).

B) /api/projects/delete unlinked every assigned session via get_session()+save()
   — O(N) full-messages reserialize. For an actively-streaming session we now
   clear project_id on the LIVE CACHED Session object under LOCK (the streaming
   thread persists it on its next save — the worker always does a final save at
   turn completion) instead of issuing a competing s.save(); falls back to a
   direct save when not cached. Non-streaming sessions unchanged.

Also guards the '+ New project and move' shortcut (sessions.js) against the new
503 so it shows a toast instead of an unhandled rejection, keeping the #2551
authoritative refetch in both the success and catch paths.

Adds tests/test_issue3746_session_move_delete_timeout.py (behavioral lock-timeout
test + structural guards for both handlers + the frontend 503 guard). Widened the
#2551 new-project-refetch test's fixed byte-window to a block-scoped search so the
try/catch wrap (which preserves the refetch) doesn't trip a brittle offset assertion.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-10 02:53:01 -07:00

150 lines
6.3 KiB
Python

"""Regression tests for #3746 — timeouts on session move / project delete
during active streaming.
Two distinct root causes, two fixes:
A) /api/session/move acquired the per-session agent lock with a bare, unbounded
`with _get_session_agent_lock(sid):`. The streaming thread holds that same
lock during checkpoint saves, so on slow file I/O (WSL/DrvFs) the move could
block past the client's 30s abort and surface as a silent "Request timed out"
toast. Fix: bounded `acquire(timeout=5)` → HTTP 503 on contention.
B) /api/projects/delete unlinked every assigned session with a full
get_session() + s.save() (O(N) full-messages reserialize). For a project with
many messageful sessions that throughput alone blows past 30s. Fix: skip
actively-streaming sessions (largest arrays + race the writer) and guard each
per-session save so one slow/failing session can't abort the whole request.
"""
import threading
from pathlib import Path
ROUTES_SRC = (Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8")
# ── A) Behavioral: the bounded lock acquire actually returns instead of blocking ──
class TestBoundedLockAcquire:
"""The fix relies on threading.Lock.acquire(timeout=...) returning False
promptly when the lock is held, instead of blocking forever. Pin that
primitive behavior so the move handler's 503 path is reachable."""
def test_acquire_timeout_returns_false_when_held(self):
lock = threading.Lock()
lock.acquire() # simulate the streaming thread holding it
try:
import time
t0 = time.monotonic()
acquired = lock.acquire(timeout=0.2)
elapsed = time.monotonic() - t0
assert acquired is False, "bounded acquire must give up, not block, when the lock is held"
assert elapsed < 1.0, f"bounded acquire must return near its timeout, took {elapsed:.2f}s"
finally:
lock.release()
def test_acquire_succeeds_when_free(self):
lock = threading.Lock()
acquired = lock.acquire(timeout=0.2)
assert acquired is True
lock.release()
# ── A) Structural: the move handler uses a bounded acquire + 503, not a bare `with` ──
def _move_block():
idx = ROUTES_SRC.find('"/api/session/move"')
assert idx > 0, "session/move handler not found"
end = ROUTES_SRC.find('"/api/projects/create"', idx)
return ROUTES_SRC[idx:end]
def test_move_uses_bounded_lock_acquire():
block = _move_block()
assert ".acquire(timeout=" in block, (
"session/move must acquire the agent lock with a bounded timeout, not block forever (#3746)"
)
# Must NOT still use the bare blocking `with _get_session_agent_lock(...)` form
# around the save (that's the regression we're removing).
assert "with _get_session_agent_lock(body[\"session_id\"]):" not in block, (
"session/move must not use a bare unbounded `with` lock acquire anymore (#3746)"
)
def test_move_returns_503_on_lock_contention():
block = _move_block()
assert "status=503" in block, (
"session/move must return HTTP 503 when the lock can't be acquired in time (#3746)"
)
def test_move_releases_lock_in_finally():
block = _move_block()
# The lock must be released on every path once acquired.
assert "finally:" in block and ".release()" in block, (
"session/move must release the agent lock in a finally block (#3746)"
)
# ── B) Structural: project delete skips streaming sessions + guards each save ──
def _delete_block():
idx = ROUTES_SRC.find('"/api/projects/delete"')
assert idx > 0, "projects/delete handler not found"
end = ROUTES_SRC.find('"/api/session/import"', idx)
return ROUTES_SRC[idx:end]
def test_delete_clears_project_id_on_streaming_sessions_in_cache():
block = _delete_block()
assert "_active_stream_ids()" in block, (
"projects/delete must compute the active stream set to special-case streaming sessions (#3746)"
)
assert 'entry.get("active_stream_id") in active_ids' in block, (
"projects/delete must detect sessions whose active_stream_id is currently streaming (#3746)"
)
# The streaming session's project_id must be cleared on the LIVE CACHED object
# (so the streaming thread persists it) — NOT left dangling, and NOT given a
# competing s.save() that races the streaming writer.
assert "cached.project_id = None" in block, (
"projects/delete must clear project_id on the live cached streaming session so the "
"streaming thread persists the unlink — not leave a dangling pointer to a deleted project (#3746)"
)
assert "with LOCK:" in block, (
"the cached-object mutation must happen under the session cache LOCK (#3746)"
)
def test_delete_guards_each_session_save():
block = _delete_block()
# Each per-session update stays wrapped in try/except so one slow/failing
# session can't abort the whole delete.
assert "try:" in block and "except Exception:" in block, (
"projects/delete must guard each per-session update (#3746)"
)
# The active-profile ownership guard (#1614) must remain intact.
assert '_profiles_match(proj.get("profile"), active_profile)' in block, (
"projects/delete must keep its cross-profile ownership guard (#1614)"
)
# ── Frontend: the '+ New project and move' shortcut guards the new 503 ──
SESSIONS_JS = (Path(__file__).parent.parent / "static" / "sessions.js").read_text(encoding="utf-8")
def test_new_project_and_move_shortcut_guards_503():
"""The '+ New project and move' shortcut must catch a failed move (e.g. 503
when the session is streaming) instead of leaving an unhandled rejection (#3746)."""
idx = SESSIONS_JS.find("Guard the move so a 503")
assert idx > 0, "the new-project-and-move shortcut guard not found"
block = SESSIONS_JS[idx:idx + 700]
assert "try{" in block and "}catch(e){" in block, (
"the new-project-and-move move call must be wrapped in try/catch (#3746)"
)
assert "move failed" in block.lower(), (
"a failed move must surface an actionable toast (#3746)"
)
# The authoritative refetch (#2551) must remain in the success path.
assert "await renderSessionList()" in block, (
"the shortcut must keep its authoritative /api/sessions refetch (#2551)"
)