Stage 397: PR #2689 — fix(chat): preserve inflight send state during start race

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-05-21 17:14:33 +00:00
parent 12a92dd50a
commit 3dd2ace4e1
3 changed files with 61 additions and 2 deletions
+7 -2
View File
@@ -415,7 +415,8 @@ async function send(){
if(typeof upsertActiveSessionForLocalTurn==='function'){
upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
}
INFLIGHT[activeSid]={messages:[...S.messages],uploaded:uploadedNames,toolCalls:[]};
const optimisticMessages=[...S.messages];
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]});
}
@@ -486,9 +487,13 @@ async function send(){
// against real active-stream metadata before the background refresh lands.
upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
}
if(!INFLIGHT[activeSid]){
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
}
const currentInflight=INFLIGHT[activeSid];
markInflight(activeSid, streamId);
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:INFLIGHT[activeSid].toolCalls||[]});
saveInflightState(activeSid,{streamId,messages:currentInflight.messages||optimisticMessages,uploaded:uploadedNames,toolCalls:currentInflight.toolCalls||[]});
}
// Refresh session list so background streaming indicators appear immediately for the
// session that was just started and any others that may already be running.
+3
View File
@@ -310,6 +310,9 @@ function _purgeStaleInflightEntries() {
}
}
for (const sid of Object.keys(INFLIGHT)) {
if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) {
continue;
}
if (!sessionsById.has(sid)) {
// Session is absent from _allSessions — it was deleted / archived /
// filtered and can never stream again, so drop the entry.
+51
View File
@@ -0,0 +1,51 @@
"""Regression coverage for send/start optimistic INFLIGHT races."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
marker = f"function {name}"
start = src.index(marker)
brace = src.index("{", start)
depth = 1
i = brace + 1
while depth and i < len(src):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
return src[brace + 1 : i - 1]
def test_send_preserves_optimistic_messages_across_chat_start_await():
"""send() must not dereference INFLIGHT[activeSid] after await without a fallback."""
body = _function_body(MESSAGES_JS, "send")
setup_idx = body.index("const optimisticMessages=[...S.messages];")
inflight_idx = body.index("INFLIGHT[activeSid]={messages:optimisticMessages")
await_idx = body.index("const startData=await api('/api/chat/start'")
save_idx = body.index("saveInflightState(activeSid,{streamId", await_idx)
assert setup_idx < inflight_idx < await_idx < save_idx
post_await = body[await_idx:save_idx]
assert "if(!INFLIGHT[activeSid])" in post_await, (
"send() should recreate the INFLIGHT entry if a session-list refresh pruned it"
)
assert "messages:INFLIGHT[activeSid].messages" not in body[save_idx : save_idx + 220], (
"saveInflightState() should use a guarded local/current inflight object, not a blind nested read"
)
def test_stale_inflight_purge_preserves_current_send_before_stream_id_exists():
"""Sidebar cleanup must not delete the active send before /api/chat/start responds."""
body = _function_body(SESSIONS_JS, "_purgeStaleInflightEntries")
assert "_sendInProgress" in body and "_sendInProgressSid" in body, (
"_purgeStaleInflightEntries() should skip the current send while start is in progress"
)
skip_idx = body.index("_sendInProgress")
delete_idx = body.index("delete INFLIGHT[sid];")
assert skip_idx < delete_idx, "the current-send skip must run before any purge deletion"