Files
hermes-webui/tests/test_optionz_liveview_perf.py
T
nesquena-hermes 26e133e3e8 [HELD — independent review pending] Release v0.51.340 — bg_task agent wakeup (trio #2968+#2971+#2979) (#3867)
* stage bg_task trio combined (#2979 superset) on master for deep review

* fix(bg_task): unsubscribe SessionChannel on header-write failure (Codex deep-review catch) + regression test

* test: realign on-subscribe-recovery anchor to subscribe_to_session_channel after leak fix

* CHANGELOG: bg_task trio as v0.51.340 LD (HELD pending independent review)

* bg_task trio: apply 3 independent-review (greptile) fixes

1. start_session_turn now threads the session PROFILE model defaults
   (_read_profile_model_config) into the wakeup model-resolve, so a brand-new
   session with an empty model falls back to the profile default not global
   DEFAULT_MODEL. Updated the white-box spy test signature accordingly.
2. /api/session/stream omits the Connection header (HTTP/1.1 keep-alive
   default) to match the #3103 long-lived-SSE pattern.
3. Reaper now prunes _LAST_EMIT_TS for collected sessions so the coalesce
   timestamp map can't grow one permanent entry per session forever.

nesquena APPROVED the PR; these are the 3 non-blocking greptile suggestions.

* test: realign _start_session_turn adapter stub lambda to new profile-defaults signature
2026-06-08 22:36:18 -07:00

554 lines
23 KiB
Python

"""Option Z live-view + SSE backpressure regression tests.
Two defects fixed on top of 481ddb9 (feat/process-complete-event-isla):
Defect B — server-initiated wakeup turn is not shown live (needs refresh).
Option Z starts the wakeup turn server-side via start_session_turn →
_start_chat_stream_for_session, which only emits the turn's token/tool/
stream_end frames to STREAMS[stream_id]. No browser EventSource is ever
attached to that stream (the browser only opens /api/chat/stream when IT
POSTs /api/chat/start). The per-session SSE channel only carried
bg_task_complete, never a signal to attach. Fix: when a process_wakeup
turn starts, emit a lightweight `server_turn_started` {stream_id} frame
onto SESSION_CHANNELS[session_id]; the open tab reuses its existing
chat-stream renderer (attachLiveStream) to attach to that stream_id.
Defect A — SSE thread exhaustion with multiple tabs.
server.py QuietHTTPServer(ThreadingHTTPServer) = one OS thread per
connection, no pool cap. A slow/backgrounded tab whose TCP recv window
is full makes handler.wfile.write()/flush() block forever → the worker
thread is pinned for the whole connection lifetime. Fix: a socket-level
SSE write deadline converts the indefinite block into socket.timeout
(== TimeoutError on py3.10+, already in routes._CLIENT_DISCONNECT_ERRORS)
so the handler loop breaks, `finally` unsubscribes, the thread is
released, and the channel reaper can reclaim it.
"""
from __future__ import annotations
import socket
import threading
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
# ---------------------------------------------------------------------------
# Defect B — server-initiated turn fans `server_turn_started` to SessionChannel
# ---------------------------------------------------------------------------
def test_server_turn_streams_to_session_channel(monkeypatch):
"""A process_wakeup turn that successfully starts must emit a
`server_turn_started` {stream_id} frame onto a subscribed SessionChannel
so an open tab can attach its existing renderer to the server-created
stream. Closed-tab path is unaffected (no subscriber → no-op)."""
from api import background_process as bp
import api.routes as routes
sid = "sess-optz-liveview-fanout"
fake_stream_id = "stream-optz-fanout-1"
# Patch the heavy turn-start core so the test stays unit-fast: pretend a
# turn started and return the same dict shape the real function returns.
def _fake_start_chat_stream_for_session(s, **kwargs):
return {"stream_id": fake_stream_id, "session_id": s.session_id, "_status": 200}
class _FakeSession:
session_id = sid
model = "test-model"
model_provider = None
monkeypatch.setattr(
routes, "_start_chat_stream_for_session", _fake_start_chat_stream_for_session, raising=True
)
monkeypatch.setattr(routes, "get_session", lambda _sid: _FakeSession(), raising=True)
monkeypatch.setattr(
routes, "_resolve_chat_workspace_with_recovery", lambda s, w: "/tmp/ws", raising=True
)
monkeypatch.setattr(
routes,
"_resolve_compatible_session_model_state",
lambda m, p, **_kw: ("test-model", None, False),
raising=True,
)
ch = bp.get_or_create_session_channel(sid)
q = ch.subscribe()
try:
resp = routes.start_session_turn(sid, "[IMPORTANT: bg done]", source="process_wakeup")
assert resp.get("stream_id") == fake_stream_id
event_name, data = q.get(timeout=2.0)
assert event_name == "server_turn_started", (
"server-initiated turn must fan a server_turn_started frame onto "
"the per-session live-view channel"
)
assert data["stream_id"] == fake_stream_id
assert data["session_id"] == sid
finally:
ch.unsubscribe(q)
with bp.SESSION_CHANNELS_LOCK:
bp.SESSION_CHANNELS.pop(sid, None)
def test_server_turn_no_session_channel_is_noop(monkeypatch):
"""Closed-tab path: no SessionChannel exists → start_session_turn must
NOT create one and must still return the started stream (server-side
wakeup, the Option Z headline, is unaffected)."""
from api import background_process as bp
import api.routes as routes
sid = "sess-optz-liveview-notab"
fake_stream_id = "stream-optz-notab-1"
def _fake_start_chat_stream_for_session(s, **kwargs):
return {"stream_id": fake_stream_id, "session_id": s.session_id, "_status": 200}
class _FakeSession:
session_id = sid
model = "test-model"
model_provider = None
monkeypatch.setattr(
routes, "_start_chat_stream_for_session", _fake_start_chat_stream_for_session, raising=True
)
monkeypatch.setattr(routes, "get_session", lambda _sid: _FakeSession(), raising=True)
monkeypatch.setattr(
routes, "_resolve_chat_workspace_with_recovery", lambda s, w: "/tmp/ws", raising=True
)
monkeypatch.setattr(
routes,
"_resolve_compatible_session_model_state",
lambda m, p, **_kw: ("test-model", None, False),
raising=True,
)
assert bp.get_session_channel(sid) is None
resp = routes.start_session_turn(sid, "[IMPORTANT: bg done]", source="process_wakeup")
assert resp.get("stream_id") == fake_stream_id
# Must not have auto-created a channel just to fan a frame nobody hears.
assert bp.get_session_channel(sid) is None
# ---------------------------------------------------------------------------
# Defect A — SSE write deadline drops a stuck writer / releases the thread
# ---------------------------------------------------------------------------
def test_sse_write_deadline_helper_sets_socket_timeout():
from api.streaming import _sse_set_write_deadline, SSE_WRITE_DEADLINE_SECONDS
recorded = {}
class _FakeConn:
def settimeout(self, v):
recorded["timeout"] = v
class _FakeHandler:
connection = _FakeConn()
h = _FakeHandler()
_sse_set_write_deadline(h)
assert recorded["timeout"] == SSE_WRITE_DEADLINE_SECONDS
_sse_set_write_deadline(h, 7.5)
assert recorded["timeout"] == 7.5
def test_sse_write_deadline_helper_never_raises():
"""A handler without a usable connection must not blow up the SSE setup."""
from api.streaming import _sse_set_write_deadline
class _NoConn:
connection = None
class _Broken:
@property
def connection(self):
raise RuntimeError("boom")
_sse_set_write_deadline(_NoConn()) # no exception
_sse_set_write_deadline(_Broken()) # no exception
_sse_set_write_deadline(object()) # no exception
def test_sse_write_timeout_drops_slow_subscriber():
"""Behavioural: a SessionChannel subscriber whose SSE writer raises
socket.timeout (the stuck-tab signal a write deadline produces) results
in the channel being unsubscribed and the worker released — modelled by
running the exact loop/break/finally contract the route uses.
socket.timeout is TimeoutError on py3.10+, which is in
routes._CLIENT_DISCONNECT_ERRORS, so the route's existing
`except _CLIENT_DISCONNECT_ERRORS:` already handles it once a deadline
is set. This test pins that contract.
"""
from api import background_process as bp
from api.routes import _CLIENT_DISCONNECT_ERRORS
assert socket.timeout in (_CLIENT_DISCONNECT_ERRORS) or issubclass(
socket.timeout, _CLIENT_DISCONNECT_ERRORS
), "socket.timeout must be catchable by the SSE route's disconnect handler"
sid = "sess-optz-stuck-writer"
ch = bp.get_or_create_session_channel(sid)
q = ch.subscribe()
assert ch.subscriber_count() == 1
released = threading.Event()
def _route_like_loop():
# Mirror _handle_session_sse_stream's loop+finally exactly.
try:
while True:
ch.emit("server_turn_started", {"stream_id": "x"})
_evt = q.get(timeout=1.0)
# Simulate handler.wfile.write hitting the write deadline:
raise socket.timeout("timed out")
except _CLIENT_DISCONNECT_ERRORS:
pass
finally:
ch.unsubscribe(q)
released.set()
t = threading.Thread(target=_route_like_loop, daemon=True)
t.start()
assert released.wait(timeout=3.0), "stuck-writer handler did not release"
t.join(timeout=2.0)
assert ch.subscriber_count() == 0, "stuck subscriber was not dropped"
with bp.SESSION_CHANNELS_LOCK:
bp.SESSION_CHANNELS.pop(sid, None)
# ---------------------------------------------------------------------------
# Source-grep wiring guards
# ---------------------------------------------------------------------------
def test_all_sse_endpoints_set_write_deadline():
src = (REPO_ROOT / "api" / "routes.py").read_text()
assert "_sse_set_write_deadline" in src
# Every SSE handler must arm the deadline. Count call sites — there are
# 6 long-lived SSE endpoints (chat-stream, terminal, gateway, approval,
# clarify, session).
assert src.count("_sse_set_write_deadline(handler") >= 6, (
"all 6 SSE endpoints must arm the write deadline"
)
def test_streaming_exports_write_deadline_api():
from api import streaming
assert hasattr(streaming, "_sse_set_write_deadline")
assert hasattr(streaming, "SSE_WRITE_DEADLINE_SECONDS")
assert isinstance(streaming.SSE_WRITE_DEADLINE_SECONDS, (int, float))
def test_sse_write_deadline_env_override(monkeypatch):
import importlib
from api import streaming
monkeypatch.setenv("HERMES_SSE_WRITE_DEADLINE", "7.25")
try:
reloaded = importlib.reload(streaming)
assert reloaded.SSE_WRITE_DEADLINE_SECONDS == 7.25
finally:
monkeypatch.delenv("HERMES_SSE_WRITE_DEADLINE", raising=False)
importlib.reload(streaming)
def test_start_session_turn_emits_server_turn_started():
src = (REPO_ROOT / "api" / "routes.py").read_text()
assert "server_turn_started" in src
# Must use the non-creating accessor so the closed-tab path stays a no-op.
assert "get_session_channel" in src
def test_frontend_attaches_renderer_on_server_turn_started():
js = (REPO_ROOT / "static" / "messages.js").read_text()
assert "server_turn_started" in js
# Must reuse the existing chat-stream render path, not hand-roll a 2nd one.
assert "attachLiveStream" in js
# ---------------------------------------------------------------------------
# Root cause (open-tab live-view): lost fire-and-forget server_turn_started
# The fan-out in start_session_turn is SessionChannel.emit with NO replay
# buffer — a tab whose /api/session/stream subscriber is momentarily absent
# at the emit instant (transient SSE drop, reverse-proxy idle-timeout,
# browser connection-pool starvation) misses the frame permanently and the
# server-initiated wakeup never renders live (the user must hard-refresh).
# The server-side wakeup itself ran + persisted fine; ONLY the live-view
# was lost. Fix: on (re)subscribe to /api/session/stream, if the session
# has a live run RIGHT NOW, replay a synthetic server_turn_started
# {recovered: True} to that new subscriber so the open tab self-heals.
#
# Reproduced deterministically with Playwright on the real instance: with
# the per-session EventSource force-closed at the exact wakeup-emit instant
# (no subscriber), `sleep 15`'s wakeup turn did NOT render live; a hard
# refresh showed it WAS persisted (proving server-side wakeup works and
# only live-view was broken). See workspace/liveview-open-tab-fix.md §1.
# ---------------------------------------------------------------------------
def test_active_stream_id_for_session_returns_live_run_stream():
"""The on-subscribe recovery lookup must return the live run's stream_id
when the session has an ACTIVE_RUNS row, and None when idle."""
from api import background_process as bp, config as cfg
sid = "sess-recover-lookup"
stream_id = "stream-recover-lookup-1"
assert bp.active_stream_id_for_session(sid) is None # idle → None
cfg.ACTIVE_RUNS[stream_id] = {"session_id": sid}
try:
assert bp.active_stream_id_for_session(sid) == stream_id
# Unrelated session must not match.
assert bp.active_stream_id_for_session("sess-other") is None
finally:
cfg.ACTIVE_RUNS.pop(stream_id, None)
assert bp.active_stream_id_for_session(sid) is None # cleaned up → None
def test_session_sse_on_subscribe_recovers_lost_server_turn_started():
"""Behavioural contract: a tab that (re)subscribes to the per-session
channel AFTER the fire-and-forget server_turn_started was already
broadcast (so it missed the original frame) must still receive a
recovery server_turn_started for the in-flight stream — modelled by
running the exact recovery block the route uses.
This is the root-cause fix: without it, a momentarily-absent subscriber
loses the frame permanently and the open tab never renders the
server-initiated wakeup turn live (needs a hard refresh).
"""
from api import background_process as bp, config as cfg
sid = "sess-recover-onsub"
stream_id = "stream-recover-onsub-1"
# Simulate: server-side wakeup turn IS live (ACTIVE_RUNS row exists), but
# the original server_turn_started broadcast already happened and reached
# NO subscriber (the tab's EventSource was momentarily down).
cfg.ACTIVE_RUNS[stream_id] = {"session_id": sid}
ch = bp.get_or_create_session_channel(sid)
q = ch.subscribe() # tab (re)connects NOW, after the lost broadcast
try:
# Exactly the route's on-subscribe recovery logic
# (_handle_session_sse_stream): look up the live run and replay.
recover_stream_id = bp.active_stream_id_for_session(sid)
assert recover_stream_id == stream_id
recovery_frame = {
"session_id": sid,
"stream_id": recover_stream_id,
"source": "subscribe_recovery",
"recovered": True,
}
# The route _sse()s this directly to the new subscriber's connection;
# the contract under test is "a freshly-subscribed tab gets a
# server_turn_started for the in-flight stream so it can attach".
assert recovery_frame["stream_id"] == stream_id
assert recovery_frame["recovered"] is True
assert recovery_frame["session_id"] == sid
# And when the session is idle (no live run) the recovery is a no-op
# — no spurious attach frame for a session with nothing running.
cfg.ACTIVE_RUNS.pop(stream_id, None)
assert bp.active_stream_id_for_session(sid) is None
finally:
ch.unsubscribe(q)
with bp.SESSION_CHANNELS_LOCK:
bp.SESSION_CHANNELS.pop(sid, None)
cfg.ACTIVE_RUNS.pop(stream_id, None)
def test_session_sse_handler_wires_on_subscribe_recovery():
"""Source-grep: the per-session SSE handler must perform on-subscribe
recovery via active_stream_id_for_session and emit a recovered
server_turn_started, AFTER subscribing (so it can't race the original)."""
src = (REPO_ROOT / "api" / "routes.py").read_text()
assert "active_stream_id_for_session" in src
# The recovery must be inside the session SSE handler and use the
# recovered marker so the frontend uses the replay attach path.
handler_ix = src.index("def _handle_session_sse_stream")
handler_src = src[handler_ix:handler_ix + 6000]
assert "active_stream_id_for_session" in handler_src
assert '"recovered": True' in handler_src
assert "server_turn_started" in handler_src
# Recovery CALL must come AFTER the channel subscription so a frame emitted
# between subscribe and recovery is still caught by the queue (no lost-frame
# gap). The handler subscribes via the atomic ``subscribe_to_session_channel``
# helper (TOCTOU-safe get-or-create+subscribe under one lock); assert on that
# call site vs the recovery call site ``= active_stream_id_for_session(``.
assert handler_src.index("subscribe_to_session_channel(") < handler_src.index(
"= active_stream_id_for_session("
)
def test_frontend_recovered_frame_uses_reconnecting_attach():
"""The frontend server_turn_started handler must honour `recovered`:
a recovered (replay) frame attaches via the reconnecting path so the
renderer rebuilds the in-progress stream from the run journal instead
of expecting token 0 (which would render a truncated turn)."""
js = (REPO_ROOT / "static" / "messages.js").read_text()
assert "recovered" in js
h_ix = js.index("addEventListener('server_turn_started'")
h_src = js[h_ix:h_ix + 1600]
assert "d.recovered" in h_src
assert "reconnecting" in h_src
# Still reuses the single renderer — no second hand-rolled stream.
assert "attachLiveStream" in h_src
# ---------------------------------------------------------------------------
# Copilot review #3 — _emit_to_session_streams owner-unknown broadcast
# Resolution: skip non-matching AND owner-unknown streams on the STREAMS
# loop (rely solely on SESSION_CHANNELS for cross-turn live-view, which the
# repro proved is the sole authoritative carrier post Option X/Z). Removes
# the cross-session-leak surface Copilot flagged.
# ---------------------------------------------------------------------------
def test_emit_to_session_streams_skips_owner_unknown_stream():
"""Copilot #3: a STREAMS entry with NO ACTIVE_RUNS row (owner unknown)
must NOT receive the event on the STREAMS loop — the old code broadcast
to it, relying on every frontend consumer to filter by session_id (a
fragile cross-session leak). The per-session SessionChannel still
delivers (the authoritative cross-turn live-view path)."""
from api import background_process as bp, config as cfg
sid = "sess-copilot3-skip"
unknown_stream_id = "stream-copilot3-no-active-run"
leaked: list = []
class _FakeStreamChannel:
def put_nowait(self, item):
leaked.append(item)
with cfg.STREAMS_LOCK:
cfg.STREAMS[unknown_stream_id] = _FakeStreamChannel()
# Deliberately NO cfg.ACTIVE_RUNS row for unknown_stream_id → owner unknown.
ch = bp.get_or_create_session_channel(sid)
q = ch.subscribe()
try:
emitted = bp._emit_to_session_streams(sid, "bg_task_complete", {"session_id": sid})
# The owner-unknown STREAMS entry must NOT have been written to.
assert leaked == [], (
"owner-unknown stream must be skipped (no cross-session broadcast)"
)
# The per-session SessionChannel still delivered (authoritative path).
ev, data = q.get(timeout=2.0)
assert ev == "bg_task_complete"
assert data["session_id"] == sid
assert emitted >= 1
finally:
ch.unsubscribe(q)
with bp.SESSION_CHANNELS_LOCK:
bp.SESSION_CHANNELS.pop(sid, None)
with cfg.STREAMS_LOCK:
cfg.STREAMS.pop(unknown_stream_id, None)
def test_emit_to_session_streams_still_delivers_to_matching_owner():
"""Regression guard for the Copilot #3 change: an owner-KNOWN stream
whose session matches MUST still receive the event on the STREAMS loop
(in-turn defense-in-depth path is preserved)."""
from api import background_process as bp, config as cfg
sid = "sess-copilot3-match"
stream_id = "stream-copilot3-match-1"
received: list = []
class _FakeStreamChannel:
def put_nowait(self, item):
received.append(item)
with cfg.STREAMS_LOCK:
cfg.STREAMS[stream_id] = _FakeStreamChannel()
cfg.ACTIVE_RUNS[stream_id] = {"session_id": sid}
ch = bp.get_or_create_session_channel(sid)
q = ch.subscribe()
try:
bp._emit_to_session_streams(sid, "bg_task_complete", {"session_id": sid})
assert received, "owner-matching stream must still receive in-turn delivery"
assert received[0][0] == "bg_task_complete"
ev, _data = q.get(timeout=2.0)
assert ev == "bg_task_complete"
finally:
ch.unsubscribe(q)
with bp.SESSION_CHANNELS_LOCK:
bp.SESSION_CHANNELS.pop(sid, None)
with cfg.STREAMS_LOCK:
cfg.STREAMS.pop(stream_id, None)
cfg.ACTIVE_RUNS.pop(stream_id, None)
def test_emit_to_session_streams_does_not_leak_to_other_session_owner():
"""Cross-session isolation: a stream owned by a DIFFERENT session must
never receive this session's event (unchanged behavior, pinned)."""
from api import background_process as bp, config as cfg
sid = "sess-copilot3-self"
other_sid = "sess-copilot3-other"
other_stream_id = "stream-copilot3-other-1"
leaked: list = []
class _FakeStreamChannel:
def put_nowait(self, item):
leaked.append(item)
with cfg.STREAMS_LOCK:
cfg.STREAMS[other_stream_id] = _FakeStreamChannel()
cfg.ACTIVE_RUNS[other_stream_id] = {"session_id": other_sid}
ch = bp.get_or_create_session_channel(sid)
q = ch.subscribe()
try:
bp._emit_to_session_streams(sid, "bg_task_complete", {"session_id": sid})
assert leaked == [], "must not leak to a different session's stream"
finally:
ch.unsubscribe(q)
with bp.SESSION_CHANNELS_LOCK:
bp.SESSION_CHANNELS.pop(sid, None)
with cfg.STREAMS_LOCK:
cfg.STREAMS.pop(other_stream_id, None)
cfg.ACTIVE_RUNS.pop(other_stream_id, None)
def test_emit_to_session_streams_skip_unknown_owner_documented_in_source():
"""Source-grep: the Copilot #3 resolution must be the skip-unknown-owner
form (`if owner_sid != session_id: continue`), not the old
broadcast-on-unknown fallback (`if owner_sid and owner_sid != ...`)."""
src = (REPO_ROOT / "api" / "background_process.py").read_text()
fn_ix = src.index("def _emit_to_session_streams")
fn_src = src[fn_ix:fn_ix + 2600]
assert "if owner_sid != session_id:" in fn_src
assert "if owner_sid and owner_sid != session_id:" not in fn_src
assert "Copilot review #3" in fn_src
# ---------------------------------------------------------------------------
# event_id contract surface (post-rename to bg_task_complete)
# ---------------------------------------------------------------------------
#
# The #2242 thread Q4 reply pins the consumer dedupe key on
# `(session_id, event_id)`. The handler in static/messages.js MUST treat
# event_id as mandatory and MUST NOT surface or ack an event missing one.
# ---------------------------------------------------------------------------
def test_frontend_handler_requires_event_id_to_surface():
"""Source-grep: _handleBgTaskCompleteEvent ignores events without event_id."""
js = (REPO_ROOT / "static" / "messages.js").read_text()
fn_ix = js.index("function _handleBgTaskCompleteEvent")
fn_src = js[fn_ix:fn_ix + 1800]
# event_id is extracted from the payload.
assert "d.event_id" in fn_src
# Missing event_id short-circuits before any dedupe / ack.
assert "if (!evt_id) return;" in fn_src or "if(!evt_id) return;" in fn_src
# Dedupe goes through the ring buffer helper, keyed by (sid, event_id).
assert "_bgTaskCompleteRingBufferAdd(sid, evt_id)" in fn_src
# Ack body carries event_id so server can correlate.
assert "event_id: evt_id" in fn_src