174 lines
7.7 KiB
Python
174 lines
7.7 KiB
Python
"""A sidebar-idle hint must retain the live handoff without waiting forever.
|
|
|
|
Drive the production scheduling helper with a fake clock and a deferred snapshot
|
|
boundary. The browser lifecycle gate separately covers the real HTTP restore.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from tests.test_issue2454_active_session_spinner import _function_body
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _run(case):
|
|
sessions = (ROOT / "static/sessions.js").read_text(encoding="utf-8")
|
|
messages = (ROOT / "static/messages.js").read_text(encoding="utf-8")
|
|
functions = "\n".join(
|
|
f"function {name}({params}) {{" + _function_body(sessions, f"function {name}(") + "}"
|
|
for name, params in [
|
|
("_isServerIdleSessionRow", "s"),
|
|
("_hasOwnedOpenLiveStream", "sid"),
|
|
("_reconcileActiveSessionIdleStateFromList", "serverRows"),
|
|
]
|
|
)
|
|
if "function _bindSidebarIdleRecovery(" in messages:
|
|
functions += "\nfunction _bindSidebarIdleRecovery(live) {" + _function_body(
|
|
messages, "function _bindSidebarIdleRecovery("
|
|
) + "}"
|
|
script = r"""
|
|
const assert=require('assert');
|
|
const activeSid='current',streamId='turn-1';
|
|
let _terminalStateReached=false,_streamFinalized=false;
|
|
let _pendingStreamEndRecovery=CASE==='stream-end-pending';
|
|
const S={session:{session_id:activeSid},busy:true,activeStreamId:streamId};
|
|
const INFLIGHT={current:{streamId}},original=INFLIGHT.current;
|
|
const timers=[],listeners={},calls=[],statusCalls=[],failures=[];
|
|
function setTimeout(fn,delay){const t={fn,delay,cancelled:false};timers.push(t);return t;}
|
|
function clearTimeout(t){if(t)t.cancelled=true;}
|
|
const source={readyState:1,addEventListener(name,fn){(listeners[name]??=[]).push(fn);}};
|
|
const live={streamId,source},LIVE_STREAMS={current:live};
|
|
let resolveSnapshot;
|
|
async function api(path,options){
|
|
statusCalls.push({path,options});
|
|
if(CASE==='probe-error') throw new Error('status unavailable');
|
|
return {active:CASE==='runtime-active'};
|
|
}
|
|
async function _restoreSettledSession(source,options){
|
|
calls.push({source,options});
|
|
return new Promise(resolve=>{resolveSnapshot=resolve;});
|
|
}
|
|
function _handleStreamError(s){failures.push(s);S.busy=false;S.activeStreamId=null;}
|
|
FUNCTIONS
|
|
if(typeof _bindSidebarIdleRecovery==='function')_bindSidebarIdleRecovery(live);
|
|
const idle=[{session_id:activeSid,is_streaming:false,active_stream_id:null}];
|
|
(async()=>{
|
|
_reconcileActiveSessionIdleStateFromList(idle);
|
|
_reconcileActiveSessionIdleStateFromList(idle);
|
|
if(CASE==='stream-end-pending'){assert.strictEqual(timers.length,0,'existing stream-end recovery must remain the sole owner');console.log('ok');return;}
|
|
assert.strictEqual(timers.length,1,'an OPEN transport must schedule one bounded snapshot recovery');
|
|
assert.ok(timers[0].delay>0&&timers[0].delay<=2000);
|
|
assert.strictEqual(S.busy,true,'the first idle hint must not destroy the live handoff');
|
|
const mode=CASE;
|
|
if(mode==='cancel-rearm'){
|
|
live.cancelIdleRecovery();
|
|
_reconcileActiveSessionIdleStateFromList(idle);
|
|
assert.strictEqual(timers.length,2);
|
|
await timers[0].fn();
|
|
assert.strictEqual(timers[1].cancelled,false,'a queued old callback cannot cancel a replacement ticket');
|
|
const replacement=timers[1].fn();
|
|
await Promise.resolve();
|
|
assert.strictEqual(calls.length,1);
|
|
resolveSnapshot('active');await replacement;
|
|
console.log('ok');return;
|
|
}
|
|
if(mode==='cancel-before'){
|
|
live.cancelIdleRecovery();
|
|
assert.strictEqual(timers[0].cancelled,true);
|
|
console.log('ok');return;
|
|
}
|
|
if(mode==='replace-before')LIVE_STREAMS.current={streamId,source:{readyState:1}};
|
|
const running=timers[0].fn();
|
|
if(mode==='replace-before'){
|
|
await running;assert.strictEqual(calls.length,0);console.log('ok');return;
|
|
}
|
|
await Promise.resolve();
|
|
assert.strictEqual(statusCalls.length,1);
|
|
assert.ok(statusCalls[0].path.includes('/api/chat/stream/status?stream_id=turn-1'));
|
|
assert.deepStrictEqual(statusCalls[0].options,{timeoutMs:8000,retries:0,timeoutToast:false});
|
|
if(mode==='runtime-active'){
|
|
await running;
|
|
assert.strictEqual(calls.length,0,'active runtime must not fall through to session restore');
|
|
assert.strictEqual(failures.length,0);
|
|
assert.strictEqual(S.busy,true);
|
|
assert.strictEqual(INFLIGHT.current,original);
|
|
console.log('ok');return;
|
|
}
|
|
if(mode==='probe-error'){
|
|
await running;
|
|
assert.strictEqual(calls.length,0,'failed runtime probe must not trust idle session flags');
|
|
assert.strictEqual(failures.length,1);
|
|
console.log('ok');return;
|
|
}
|
|
assert.strictEqual(calls.length,1);
|
|
_reconcileActiveSessionIdleStateFromList(idle);
|
|
assert.strictEqual(timers.length,1,'repeated idle hints must not restart a pending deadline');
|
|
const owns=calls[0].options.isCurrent;
|
|
assert.strictEqual(owns(),true);
|
|
assert.deepStrictEqual(calls[0].options.requestOptions,{timeoutMs:8000,retries:0,timeoutToast:false});
|
|
if(mode==='replace-during')LIVE_STREAMS.current={streamId,source:{readyState:1}};
|
|
if(mode==='new-turn')INFLIGHT.current={streamId:null};
|
|
if(mode==='navigate')S.session={session_id:'other'};
|
|
if(mode==='done'){
|
|
for(const fn of listeners.done||[])fn();
|
|
_streamFinalized=true;S.busy=false;S.activeStreamId=null;
|
|
}
|
|
if(['replace-during','new-turn','navigate','done'].includes(mode))assert.strictEqual(owns(),false);
|
|
resolveSnapshot(mode==='active'?'active':mode==='restored'?'restored':'error');
|
|
await running;
|
|
assert.strictEqual(failures.length,mode==='error'?1:0);
|
|
assert.strictEqual(owns(),false,'settled requests must lose authority');
|
|
if(mode==='active'){
|
|
assert.strictEqual(S.busy,true);assert.strictEqual(INFLIGHT.current,original);
|
|
}
|
|
console.log('ok');
|
|
})().catch(e=>{console.error(e.stack||e);process.exit(1);});
|
|
""".replace("FUNCTIONS", functions).replace("CASE", json.dumps(case))
|
|
node = shutil.which("node")
|
|
if not node:
|
|
pytest.skip("node required")
|
|
result = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=20)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize("case", [
|
|
"error", "active", "restored", "runtime-active", "probe-error", "cancel-before", "replace-before",
|
|
"replace-during", "new-turn", "navigate", "done", "stream-end-pending", "cancel-rearm",
|
|
])
|
|
def test_sidebar_idle_open_stream_has_bounded_owner_safe_recovery(case):
|
|
_run(case)
|
|
|
|
|
|
def test_snapshot_restore_rechecks_request_owner_after_await():
|
|
source = (ROOT / "static/messages.js").read_text(encoding="utf-8")
|
|
body = _function_body(source, "async function _restoreSettledSession(")
|
|
script = r"""
|
|
const assert=require('assert');
|
|
const activeSid='current',streamId='turn-1';
|
|
let current=true,resolveSnapshot;
|
|
const S={session:{session_id:activeSid},activeStreamId:streamId};
|
|
let _streamFinalized=false;
|
|
function _isActiveSession(){return true;}
|
|
function _closeSource(){throw Error('stale restore must not close a replacement');}
|
|
function api(){return new Promise(resolve=>{resolveSnapshot=resolve;});}
|
|
async function _restoreSettledSession(source,options=null){BODY}
|
|
(async()=>{
|
|
const pending=_restoreSettledSession({}, {status:true,isCurrent:()=>current});
|
|
current=false;
|
|
resolveSnapshot({session:{session_id:activeSid,active_stream_id:'newer-turn',messages:[]}});
|
|
assert.strictEqual(await pending,'stale','request authority must be rechecked before using the response');
|
|
assert.strictEqual(S.activeStreamId,streamId);
|
|
console.log('ok');
|
|
})().catch(e=>{console.error(e.stack||e);process.exit(1);});
|
|
""".replace("BODY", body)
|
|
node = shutil.which("node")
|
|
if not node:
|
|
pytest.skip("node required")
|
|
result = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=20)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|