fix: suppress timeout toasts for passive pollers

This commit is contained in:
Dennis Soong 2026-05-28 11:08:38 +08:00
parent 4d6269eae6
commit 5f901f579a
10 changed files with 74 additions and 15 deletions

View file

@ -3,6 +3,10 @@
## [Unreleased]
### Fixed
- Passive background refreshes such as sidebar/project polling, health checks, cron-status watches, and client-event logging no longer surface generic timeout toasts; explicit user actions still show timeout errors. (Related to #3024)
## [v0.51.151] — 2026-05-28 — Release DW (stage-batch33 — 3-PR mid-risk batch: SSE reattach + title-lang + composer cap)
### Fixed

View file

@ -1145,7 +1145,7 @@ function _startCronWatch(jobId) {
_cronWatchStart = Date.now();
_cronWatchInterval = setInterval(async () => {
try {
const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`);
const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false});
if (!data.running) {
_stopCronWatch();
if (_currentCronDetail && _currentCronDetail.id === jobId) {
@ -1200,7 +1200,7 @@ function _formatElapsed(seconds) {
function _checkCronWatchOnDetail(jobId) {
// When opening a detail view, check if job is running
api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`).then(data => {
api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false}).then(data => {
if (data.running && _currentCronDetail && _currentCronDetail.id === jobId) {
_startCronWatch(jobId);
}

View file

@ -2278,8 +2278,8 @@ async function renderSessionList(opts={}){
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
const allProfilesQS = _showAllProfiles ? '?all_profiles=1' : '';
const [sessData, projData] = await Promise.all([
api('/api/sessions' + allProfilesQS),
api('/api/projects' + allProfilesQS),
api('/api/sessions' + allProfilesQS,{timeoutToast:false}),
api('/api/projects' + allProfilesQS,{timeoutToast:false}),
]);
// Discard stale response — a newer renderSessionList() call superseded us.
if (_gen !== _renderSessionListGen) return;
@ -2352,7 +2352,7 @@ async function refreshActiveSessionIfExternallyUpdated(reason){
const localLast = Number(S.session.last_message_at || S.session.updated_at || 0);
_activeSessionExternalRefreshInFlight = true;
try{
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`,{timeoutToast:false});
if(!data || !data.session) return;
if(!S.session || S.session.session_id !== sid) return;
if(S.busy || S.activeStreamId) return;

View file

@ -475,7 +475,7 @@ async function refreshDashboardStatus(force=false){
return _dashboardStatusCache;
}
try{
const status=await api('/api/dashboard/status');
const status=await api('/api/dashboard/status',{timeoutToast:false});
_dashboardStatusCache=status||{running:false};
}catch(_){
_dashboardStatusCache={running:false};
@ -4587,7 +4587,7 @@ async function pollSystemHealth(){
if(document.visibilityState !== 'visible') return;
if(!_systemHealthPanelIsVisible()) return;
try{
const payload=await api('/api/system/health');
const payload=await api('/api/system/health',{timeoutToast:false});
renderSystemHealth(payload);
}catch(_){
setSystemHealthUnavailable('Unavailable');
@ -4653,7 +4653,7 @@ function dismissAgentHealthAlert(){
async function pollAgentHealth(){
if(document.visibilityState !== 'visible') return;
try{
const payload=await api('/api/health/agent');
const payload=await api('/api/health/agent',{timeoutToast:false});
if(payload.alive === true){
_agentHealthLastState='alive';
_setAgentHealthDismissed(false);

View file

@ -3,6 +3,7 @@ async function api(path,opts={}){
const rel = path.startsWith('/') ? path.slice(1) : path;
const url=new URL(rel,document.baseURI||location.href);
const timeoutMs=Object.prototype.hasOwnProperty.call(opts,'timeoutMs')?opts.timeoutMs:30000;
const timeoutToast=opts.timeoutToast!==false;
// Retry up to 2 times on network errors (e.g. stale keep-alive after long idle).
// Server errors (4xx/5xx) and client-side timeouts are NOT retried.
let lastErr;
@ -15,6 +16,8 @@ async function api(path,opts={}){
try{
const fetchOpts={...opts};
delete fetchOpts.timeoutMs;
delete fetchOpts.timeoutToast;
const useTimeout=Number.isFinite(Number(timeoutMs))&&Number(timeoutMs)>0;
if(useTimeout&&typeof AbortController!=='undefined'){
controller=new AbortController();
@ -69,7 +72,7 @@ async function api(path,opts={}){
const err=(e&&e.name==='TimeoutError')?e:new Error('Request timed out. Please try again.');
err.name='TimeoutError';
err.timeout=true;
if(typeof showToast==='function') showToast('Request timed out. Please try again.',5000,'error');
if(timeoutToast&&typeof showToast==='function') showToast('Request timed out. Please try again.',5000,'error');
throw err;
}
// Only retry on network errors (TypeError from fetch), not on HTTP errors
@ -98,7 +101,7 @@ function recordClientSSEError(source, details={}){
url_path:(typeof location!=='undefined'&&location.pathname)||'/',
reason:details.reason||'EventSource.onerror',
};
void api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000}).catch(()=>{});
void api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000,timeoutToast:false}).catch(()=>{});
}catch(_){}
}

View file

@ -159,13 +159,48 @@ def test_api_rejects_stalled_response_body_with_timeout():
assert any(event.get("aborted") for event in payload["events"]), payload
def test_api_can_suppress_timeout_toast_for_background_pollers():
"""Passive pollers need abort/reject cleanup without a user-visible toast."""
api_fn = _extract_js_function(_source(WORKSPACE_JS), "api")
script = textwrap.dedent(
f"""
const events=[];
global.document={{baseURI:'http://example.test/hermes/'}};
global.location={{href:'http://example.test/hermes/',pathname:'/hermes/',search:''}};
global.window={{location:global.location}};
global.showToast=(msg,ms,type)=>events.push({{msg:String(msg),ms,type}});
global.fetch=(url,opts)=>new Promise(()=>{{
if(opts&&opts.signal)opts.signal.addEventListener('abort',()=>events.push({{aborted:true}}));
}});
{api_fn}
api('/api/sessions',{{timeoutMs:20,timeoutToast:false}})
.then(()=>{{console.error('resolved unexpectedly');process.exit(2);}})
.catch(err=>{{
console.log(JSON.stringify({{message:String(err&&err.message||err),events}}));
process.exit(0);
}});
setTimeout(()=>{{console.error('api did not reject after timeoutMs');process.exit(3);}},250);
"""
)
result = _node_eval(script, timeout=1.0)
assert result.returncode == 0, result.stderr or result.stdout
payload = json.loads(result.stdout.strip())
assert "timed out" in payload["message"].lower()
assert any(event.get("aborted") for event in payload["events"]), payload
assert not any("msg" in event for event in payload["events"]), payload
def test_api_has_default_timeout_and_per_call_override_contract():
src = _source(WORKSPACE_JS)
body = _extract_js_function(src, "api")
assert "timeoutMs" in body, "api() must accept opts.timeoutMs as a per-call override"
assert "timeoutToast" in body, "api() must let passive callers suppress timeout toasts"
assert "30000" in body, "api() must default browser API calls to a 30s timeout"
assert "AbortController" in body, "api() must abort hung fetches with AbortController"
assert "delete fetchOpts.timeoutMs" in body, "api() must strip timeoutMs before calling fetch()"
assert "delete fetchOpts.timeoutToast" in body, "api() must strip timeoutToast before calling fetch()"
fetch_call = re.search(r"fetch\(url\.href,\{.*?\.\.\.fetchOpts.*?\}\)", body, re.DOTALL)
assert fetch_call, "api() must call fetch() with sanitized fetchOpts"
assert "...opts" not in fetch_call.group(0), "api() must not spread raw opts into fetch()"
@ -182,6 +217,23 @@ def test_update_flows_keep_explicit_longer_timeouts():
assert "api('/api/updates/force',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src
def test_passive_background_polls_suppress_timeout_toasts():
"""Passive refreshes should be best-effort and not emit generic timeout toasts."""
workspace = _source(WORKSPACE_JS)
sessions = _source(SESSIONS_JS)
ui = _source(UI_JS)
panels = _source(PANELS_JS)
assert "api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000,timeoutToast:false})" in workspace
assert "api('/api/sessions' + allProfilesQS,{timeoutToast:false})" in sessions
assert "api('/api/projects' + allProfilesQS,{timeoutToast:false})" in sessions
assert "api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`,{timeoutToast:false})" in sessions
assert "api('/api/dashboard/status',{timeoutToast:false})" in ui
assert "api('/api/system/health',{timeoutToast:false})" in ui
assert "api('/api/health/agent',{timeoutToast:false})" in ui
assert "api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false})" in panels
def test_new_session_inflight_cleanup_still_runs_after_api_rejects():
"""newSession() must keep its finally cleanup path so timeout rejections unpin the UI."""
src = _source(SESSIONS_JS)

View file

@ -25,7 +25,7 @@ def test_dashboard_rail_item_sits_between_insights_and_settings_spacer():
def test_dashboard_frontend_fetches_status_with_sixty_second_cache():
assert "DASHBOARD_STATUS_TTL_MS=60000" in UI_JS
assert "function refreshDashboardStatus" in UI_JS
assert "api('/api/dashboard/status')" in UI_JS
assert "api('/api/dashboard/status',{timeoutToast:false})" in UI_JS
assert "setInterval(refreshDashboardStatus,DASHBOARD_STATUS_TTL_MS)" in UI_JS
assert 'fetch("/api/dashboard/status"' not in UI_JS
assert "fetch('/api/dashboard/status'" not in UI_JS

View file

@ -134,10 +134,10 @@ def test_static_sessions_js_uses_all_profiles_query_when_toggle_on():
assert "_showAllProfiles ? '?all_profiles=1' : ''" in src, (
"Expected fetch path to flip on the toggle state"
)
assert "api('/api/sessions' + allProfilesQS)" in src, (
assert "api('/api/sessions' + allProfilesQS,{timeoutToast:false})" in src, (
"Expected /api/sessions fetch to use the variant query"
)
assert "api('/api/projects' + allProfilesQS)" in src, (
assert "api('/api/projects' + allProfilesQS,{timeoutToast:false})" in src, (
"Expected /api/projects fetch to use the variant query"
)

View file

@ -159,7 +159,7 @@ def test_system_health_panel_markup_and_styles_live_under_insights_not_top_chrom
def test_system_health_frontend_polls_visible_and_renders_progress_labels():
assert "const SYSTEM_HEALTH_INTERVAL_MS=5000" in UI_JS
assert "api('/api/system/health')" in UI_JS
assert "api('/api/system/health',{timeoutToast:false})" in UI_JS
assert "document.visibilityState !== 'visible'" in UI_JS
assert "document.querySelector('main.main.showing-insights')" in UI_JS
assert "document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility)" in UI_JS

View file

@ -184,7 +184,7 @@ def test_agent_health_banner_markup_and_styles_exist():
def test_agent_health_frontend_polls_only_visible_and_distinguishes_states():
assert "const AGENT_HEALTH_INTERVAL_MS=30000" in UI_JS
assert "api('/api/health/agent')" in UI_JS
assert "api('/api/health/agent',{timeoutToast:false})" in UI_JS
assert "document.visibilityState !== 'visible'" in UI_JS
assert "document.addEventListener('visibilitychange',_syncAgentHealthMonitorVisibility)" in UI_JS
assert "if(payload.alive === true)" in UI_JS