fix: clean session attachment and stream recovery leftovers

This commit is contained in:
Michael Lam
2026-05-15 13:30:46 -07:00
parent 7ac4bf4f4a
commit c991f36021
5 changed files with 48 additions and 5 deletions
+6
View File
@@ -4628,6 +4628,12 @@ def handle_post(handler, parsed) -> bool:
p.with_suffix('.json.bak').unlink(missing_ok=True)
except Exception:
logger.debug("Failed to unlink session file %s", p)
try:
from api.upload import _session_attachment_dir
shutil.rmtree(_session_attachment_dir(sid), ignore_errors=True)
except Exception:
logger.debug("Failed to clean attachment dir for deleted session %s", sid)
# Prune the per-session agent lock so deleted sessions don't leak
# Lock entries in SESSION_AGENT_LOCKS forever.
with SESSION_AGENT_LOCKS_LOCK:
+9 -4
View File
@@ -76,10 +76,7 @@ def _attachment_root() -> Path:
def _upload_destination(session_id: str, safe_name: str) -> Path:
root = _attachment_root()
dest_dir = (root / _re.sub(r'[^\w.\-]', '_', str(session_id or 'session'))[:120]).resolve()
if not dest_dir.is_relative_to(root):
raise ValueError('Invalid attachment directory')
dest_dir = _session_attachment_dir(session_id)
dest_dir.mkdir(parents=True, exist_ok=True)
dest = (dest_dir / safe_name).resolve()
if not dest.is_relative_to(dest_dir):
@@ -87,6 +84,14 @@ def _upload_destination(session_id: str, safe_name: str) -> Path:
return dest
def _session_attachment_dir(session_id: str, *, root: Path | None = None) -> Path:
root = (root or _attachment_root()).resolve()
dest_dir = (root / _re.sub(r'[^\w.\-]', '_', str(session_id or 'session'))[:120]).resolve()
if not dest_dir.is_relative_to(root):
raise ValueError('Invalid attachment directory')
return dest_dir
def handle_upload(handler):
import traceback as _tb
try:
+1
View File
@@ -601,6 +601,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
function _reattachOrRestoreAfterDeferredStreamError(){
if(_terminalStateReached||_streamFinalized) return;
if((S.session&&S.session.session_id)!==activeSid) return;
(async()=>{
try{
if(streamId){
+21 -1
View File
@@ -179,6 +179,25 @@ def test_session_delete():
assert e.code in (404, 500), f"Expected 404 or 500, got {e.code}"
def test_session_delete_removes_attachment_inbox(cleanup_test_sessions):
"""Deleting a session also removes its chat attachment inbox directory."""
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post_multipart("/api/upload", {"session_id": sid}, {
"file": ("delete-me.txt", b"temporary attachment")
})
assert status == 200, f"Upload failed {status}: {result}"
attachment_dir = pathlib.Path(result["path"]).parent
assert attachment_dir.name == sid
assert attachment_dir.exists()
delete_result, delete_status = post("/api/session/delete", {"session_id": sid})
assert delete_status == 200
assert delete_result.get("ok") is True
assert not attachment_dir.exists()
def test_session_delete_nonexistent():
"""Deleting a nonexistent session should return ok:True (idempotent)."""
result, status = post("/api/session/delete", {"session_id": "doesnotexist"})
@@ -321,7 +340,7 @@ def test_upload_text_file(cleanup_test_sessions):
def test_upload_respects_attachment_dir_env(monkeypatch, tmp_path):
"""HERMES_WEBUI_ATTACHMENT_DIR routes chat uploads to a per-session inbox."""
from api.upload import _upload_destination
from api.upload import _session_attachment_dir, _upload_destination
inbox = tmp_path / "attachment-inbox"
monkeypatch.setenv("HERMES_WEBUI_ATTACHMENT_DIR", str(inbox))
@@ -330,6 +349,7 @@ def test_upload_respects_attachment_dir_env(monkeypatch, tmp_path):
assert dest == inbox.resolve() / "session-123" / "notes.md"
assert dest.parent.exists()
assert _session_attachment_dir("session-123") == inbox.resolve() / "session-123"
def test_upload_too_large(cleanup_test_sessions):
+11
View File
@@ -173,3 +173,14 @@ class TestReconnectAccumulatorPreservation:
assert 'cancelAnimationFrame' in fn, (
"_handleStreamError must cancel any pending rAF before renderMessages() runs"
)
def test_deferred_stream_recovery_bails_after_session_switch(self):
"""Deferred hidden-tab recovery must not reattach an old stream after
the user has switched to a different session in the same tab."""
src = read('static/messages.js')
m = re.search(r'function _reattachOrRestoreAfterDeferredStreamError\(\)\{.*?\n \}', src, re.DOTALL)
assert m, "_reattachOrRestoreAfterDeferredStreamError not found"
fn = m.group(0)
assert 'S.session&&S.session.session_id' in fn
assert '!==activeSid' in fn
assert fn.index('!==activeSid') < fn.index('api(`/api/chat/stream/status?stream_id=')