test: guard session-owned runtime invariants
This commit is contained in:
1 parent
52be3e9b5c
commit
1f8e8f48ac
2 files changed
+158
-12
No files matched your search
+39
-12
@@ -857,8 +857,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(typeof _markSessionCompletedInList==='function'){
|
||||
_markSessionCompletedInList(completedSession, activeSid);
|
||||
}
|
||||
stopApprovalPolling();
|
||||
stopClarifyPolling();
|
||||
stopApprovalPollingForSession(activeSid);
|
||||
stopClarifyPollingForSession(activeSid);
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(!_clarifySessionId || _clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
|
||||
if(isActiveSession){
|
||||
@@ -944,8 +944,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// TTS auto-read: speak the last assistant response if enabled (#499)
|
||||
if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
|
||||
}
|
||||
_queueDrainSid=activeSid;renderSessionList();setBusy(false);setStatus('');
|
||||
setComposerStatus('');
|
||||
renderSessionList();
|
||||
if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id]){
|
||||
_queueDrainSid=activeSid;
|
||||
setBusy(false);
|
||||
setStatus('');
|
||||
setComposerStatus('');
|
||||
}
|
||||
playNotificationSound();
|
||||
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
|
||||
});
|
||||
@@ -1026,7 +1031,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
|
||||
// This is distinct from the SSE network 'error' event below.
|
||||
source.close();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
@@ -1104,7 +1109,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_smdEndParser();
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
source.close();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'cancelled');
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
@@ -1143,7 +1148,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const session=data&&data.session;
|
||||
if(!session) return false;
|
||||
if(session.active_stream_id||session.pending_user_message) return false;
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
|
||||
_closeSource();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
|
||||
@@ -1152,7 +1157,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(!isSessionViewed && typeof _markSessionCompletionUnread==='function'){
|
||||
_markSessionCompletionUnread(completedSid, session.message_count);
|
||||
}
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
const isActiveSession=_isSessionCurrentPane(activeSid);
|
||||
if(isActiveSession){
|
||||
S.activeStreamId=null;
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
S.session=session;S.messages=(session.messages||[]).filter(m=>m&&m.role);
|
||||
@@ -1179,7 +1185,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(isSessionViewed) _markSessionViewed(completedSid, session.message_count ?? S.messages.length);
|
||||
syncTopbar();renderMessages({preserveScroll:true});
|
||||
}
|
||||
_queueDrainSid=activeSid;renderSessionList();setBusy(false);setComposerStatus('');
|
||||
renderSessionList();
|
||||
if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id]){
|
||||
_queueDrainSid=activeSid;
|
||||
setBusy(false);
|
||||
setComposerStatus('');
|
||||
}
|
||||
return true;
|
||||
}catch(_){
|
||||
return false;
|
||||
@@ -1193,7 +1204,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_streamFinalized=true;
|
||||
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);clearTimeout(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
|
||||
_closeSource();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
|
||||
@@ -1221,8 +1232,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
delete INFLIGHT[activeSid];
|
||||
clearInflight();
|
||||
clearInflightState(activeSid);
|
||||
stopApprovalPolling();
|
||||
stopClarifyPolling();
|
||||
stopApprovalPollingForSession(activeSid);
|
||||
stopClarifyPollingForSession(activeSid);
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
@@ -1413,6 +1424,7 @@ async function respondApproval(choice) {
|
||||
|
||||
function startApprovalPolling(sid) {
|
||||
stopApprovalPolling();
|
||||
_approvalPollingSessionId = sid || null;
|
||||
// ── SSE (preferred): long-lived connection, server pushes instantly ──
|
||||
try {
|
||||
const es = new EventSource(new URL('api/approval/stream?session_id=' + encodeURIComponent(sid), document.baseURI || location.href).href);
|
||||
@@ -1455,6 +1467,7 @@ function startApprovalPolling(sid) {
|
||||
|
||||
let _approvalEventSource = null;
|
||||
let _approvalSSEHealthTimer = null;
|
||||
let _approvalPollingSessionId = null;
|
||||
|
||||
function _startApprovalFallbackPoll(sid) {
|
||||
_approvalPollTimer = setInterval(async () => {
|
||||
@@ -1469,10 +1482,16 @@ function _startApprovalFallbackPoll(sid) {
|
||||
}, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
|
||||
}
|
||||
|
||||
function stopApprovalPollingForSession(sid) {
|
||||
if(sid && _approvalPollingSessionId && _approvalPollingSessionId!==sid) return;
|
||||
stopApprovalPolling();
|
||||
}
|
||||
|
||||
function stopApprovalPolling() {
|
||||
if (_approvalPollTimer) { clearInterval(_approvalPollTimer); _approvalPollTimer = null; }
|
||||
if (_approvalEventSource) { try { _approvalEventSource.close(); } catch(_){} _approvalEventSource = null; }
|
||||
if (_approvalSSEHealthTimer) { clearInterval(_approvalSSEHealthTimer); _approvalSSEHealthTimer = null; }
|
||||
_approvalPollingSessionId = null;
|
||||
}
|
||||
|
||||
// ── Clarify polling ──
|
||||
@@ -1770,9 +1789,11 @@ async function respondClarify(response) {
|
||||
var _clarifyEventSource = null;
|
||||
var _clarifyFallbackTimer = null;
|
||||
var _clarifyHealthTimer = null;
|
||||
let _clarifyPollingSessionId = null;
|
||||
|
||||
function startClarifyPolling(sid) {
|
||||
stopClarifyPolling();
|
||||
_clarifyPollingSessionId = sid || null;
|
||||
_clarifyMissingEndpointWarned = false;
|
||||
|
||||
// SSE primary path: long-lived connection pushes events instantly.
|
||||
@@ -1852,10 +1873,16 @@ function _startClarifyFallbackPoll(sid) {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function stopClarifyPollingForSession(sid) {
|
||||
if(sid && _clarifyPollingSessionId && _clarifyPollingSessionId!==sid) return;
|
||||
stopClarifyPolling();
|
||||
}
|
||||
|
||||
function stopClarifyPolling() {
|
||||
if (_clarifyEventSource) { try { _clarifyEventSource.close(); } catch(_){} _clarifyEventSource = null; }
|
||||
if (_clarifyFallbackTimer) { clearInterval(_clarifyFallbackTimer); _clarifyFallbackTimer = null; }
|
||||
if (_clarifyHealthTimer) { clearInterval(_clarifyHealthTimer); _clarifyHealthTimer = null; }
|
||||
_clarifyPollingSessionId = null;
|
||||
}
|
||||
|
||||
// ── Notifications and Sound ──────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Regression coverage for #1694 session-owned runtime invariants.
|
||||
|
||||
These source-level tests protect the existing vanilla-JS runtime boundary:
|
||||
stream transports are keyed by stream_id/session_id, while the active pane is only
|
||||
one projection. Background terminal events must update session/sidebar metadata
|
||||
without tearing down the currently viewed pane's runtime state.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
|
||||
|
||||
def read(rel: str) -> str:
|
||||
return (REPO / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
idx = src.find(f"function {name}")
|
||||
if idx == -1:
|
||||
idx = src.find(f"async function {name}")
|
||||
assert idx != -1, f"{name} not found"
|
||||
brace = src.find("{", idx)
|
||||
depth = 0
|
||||
for pos in range(brace, len(src)):
|
||||
ch = src[pos]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[idx : pos + 1]
|
||||
raise AssertionError(f"{name} body did not terminate")
|
||||
|
||||
|
||||
def _event_handler(src: str, event_name: str) -> str:
|
||||
marker = f"source.addEventListener('{event_name}'"
|
||||
idx = src.find(marker)
|
||||
assert idx != -1, f"{event_name} handler not found"
|
||||
next_handler = src.find("source.addEventListener(", idx + len(marker))
|
||||
return src[idx:next_handler if next_handler != -1 else len(src)]
|
||||
|
||||
|
||||
class TestSessionOwnedRuntimeInvariants:
|
||||
def test_sidebar_cancel_uses_row_stream_id_not_active_pane_stream(self):
|
||||
boot = read("static/boot.js")
|
||||
body = _function_body(boot, "cancelSessionStream")
|
||||
assert "session&&session.active_stream_id" in body, (
|
||||
"Sidebar row cancellation must target the row-owned active_stream_id, "
|
||||
"not the currently viewed pane's S.activeStreamId."
|
||||
)
|
||||
assert "S.activeStreamId" not in body[: body.index("if(S.session&&S.session.session_id===sid)")], (
|
||||
"cancelSessionStream must not read or clear active-pane stream state until "
|
||||
"it has proved the row session is the active pane."
|
||||
)
|
||||
|
||||
def test_done_event_does_not_clear_unrelated_active_pane_busy_state(self):
|
||||
messages = read("static/messages.js")
|
||||
done = _event_handler(messages, "done")
|
||||
unconditional = "_queueDrainSid=activeSid;renderSessionList();setBusy(false);setStatus('');"
|
||||
assert unconditional not in done, (
|
||||
"A background session's done event must not unconditionally call setBusy(false); "
|
||||
"that can idle an unrelated active pane that is still running."
|
||||
)
|
||||
assert "if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id])" in done.replace(" ", ""), (
|
||||
"The done handler should only idle composer state when the completed stream "
|
||||
"belongs to the active pane, or when no other active-pane inflight runtime exists."
|
||||
)
|
||||
|
||||
def test_server_session_finalize_does_not_idle_unrelated_active_pane(self):
|
||||
messages = read("static/messages.js")
|
||||
finalize = _function_body(messages, "_restoreSettledSession")
|
||||
assert "_queueDrainSid=activeSid;renderSessionList();setBusy(false);setComposerStatus('');" not in finalize, (
|
||||
"The fallback server-finalize path must not idle the active pane for a "
|
||||
"background session completion."
|
||||
)
|
||||
assert "if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id])" in finalize.replace(" ", ""), (
|
||||
"The fallback server-finalize path should use the same active-pane guard as the live done event."
|
||||
)
|
||||
|
||||
def test_approval_and_clarify_pollers_are_stopped_by_owner_session(self):
|
||||
messages = read("static/messages.js")
|
||||
assert "let _approvalPollingSessionId = null" in messages
|
||||
assert "let _clarifyPollingSessionId = null" in messages
|
||||
assert "function stopApprovalPollingForSession" in messages
|
||||
assert "function stopClarifyPollingForSession" in messages
|
||||
|
||||
approval_stop = _function_body(messages, "stopApprovalPollingForSession")
|
||||
clarify_stop = _function_body(messages, "stopClarifyPollingForSession")
|
||||
assert "_approvalPollingSessionId!==sid" in approval_stop.replace(" ", ""), (
|
||||
"A terminal event for session A must not stop approval polling that now belongs to session B."
|
||||
)
|
||||
assert "_clarifyPollingSessionId!==sid" in clarify_stop.replace(" ", ""), (
|
||||
"A terminal event for session A must not stop clarify polling that now belongs to session B."
|
||||
)
|
||||
|
||||
done = _event_handler(messages, "done")
|
||||
assert "stopApprovalPollingForSession(activeSid)" in done
|
||||
assert "stopClarifyPollingForSession(activeSid)" in done
|
||||
assert "stopApprovalPolling();\n stopClarifyPolling();" not in done, (
|
||||
"The done handler must not blindly stop whatever approval/clarify poller "
|
||||
"the active pane currently owns."
|
||||
)
|
||||
|
||||
def test_live_stream_transport_and_inflight_state_remain_session_keyed(self):
|
||||
messages = read("static/messages.js")
|
||||
close_live = _function_body(messages, "closeLiveStream")
|
||||
attach_start = messages.index("function attachLiveStream")
|
||||
attach_live = messages[attach_start:messages.index("function _isActiveSession", attach_start)]
|
||||
assert "constlive=LIVE_STREAMS[sessionId]" in close_live.replace(" ", ""), (
|
||||
"LIVE_STREAMS must remain keyed by the owning session_id."
|
||||
)
|
||||
assert "constexistingLive=LIVE_STREAMS[activeSid]" in attach_live.replace(" ", ""), (
|
||||
"attachLiveStream should reuse the session-owned live transport for the same stream."
|
||||
)
|
||||
assert re.search(r"INFLIGHT\[activeSid\].*messages", attach_live, re.DOTALL), (
|
||||
"The browser-side inflight projection must remain keyed by the owning session_id."
|
||||
)
|
||||
Reference in new issue
Block a user