Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 35 additions & 11 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -4821,6 +4821,7 @@ let _activeSessionExternalRefreshInFlight = false;
let _deferredActiveSessionExternalRefreshReason = '';
let _sessionEventsSSE = null;
let _sessionEventsRefreshTimer = 0;
let _sessionEventsRefreshPendingRequest = null;
let _sessionEventsReconnectTimer = 0;
let _sessionEventsNeedsRefreshOnOpen = false;
let _sessionEventsReconnectAttempt = 0;
Expand All @@ -4834,7 +4835,20 @@ function _sessionEventsReconnectDelayMs(){
return Math.min(_sessionEventsReconnectMaxMs, Math.floor(base * 0.75) + jitter);
}
let _sessionListRefreshInFlight = false;
let _sessionListRefreshPendingReason = '';
let _sessionListRefreshPendingRequest = null;

function _mergeSessionListRefreshOptions(prev, next){
const merged = {...(prev||{}), ...(next||{})};
if((prev&&prev.force===true)||(next&&next.force===true)) merged.force = true;
if((prev&&prev.refreshActive===true)||(next&&next.refreshActive===true)) merged.refreshActive = true;
return merged;
}

function _refreshSessionListAfterSidebarResume(reason){
// A direct resume refresh satisfies any pending onopen catch-up from the same close.
_sessionEventsNeedsRefreshOnOpen = false;
void refreshSessionList(reason, {force:true});
}

function startStreamingPoll(){
if(_streamingPollTimer) return;
Expand Down Expand Up @@ -5025,7 +5039,10 @@ async function refreshSessionList(reason='manual', opts={}){
const refreshActive = !!(opts && opts.refreshActive);
if(!force && typeof document !== 'undefined' && document.hidden) return;
if(_sessionListRefreshInFlight){
_sessionListRefreshPendingReason = reason || 'session-list';
_sessionListRefreshPendingRequest = {
reason: reason || 'session-list',
opts:_mergeSessionListRefreshOptions(_sessionListRefreshPendingRequest && _sessionListRefreshPendingRequest.opts, opts),
};
return;
}
_sessionListRefreshInFlight = true;
Expand All @@ -5034,17 +5051,23 @@ async function refreshSessionList(reason='manual', opts={}){
if(refreshActive) await refreshActiveSessionIfExternallyUpdated(reason||'session-list');
}finally{
_sessionListRefreshInFlight = false;
const pendingReason = _sessionListRefreshPendingReason;
_sessionListRefreshPendingReason = '';
if(pendingReason) _scheduleSessionEventsRefresh(pendingReason);
const pendingRequest = _sessionListRefreshPendingRequest;
_sessionListRefreshPendingRequest = null;
if(pendingRequest) _scheduleSessionEventsRefresh(pendingRequest.reason, pendingRequest.opts);
}
}

function _scheduleSessionEventsRefresh(reason){
function _scheduleSessionEventsRefresh(reason, opts={}){
_sessionEventsRefreshPendingRequest = {
reason: reason || (_sessionEventsRefreshPendingRequest && _sessionEventsRefreshPendingRequest.reason) || 'event',
opts:_mergeSessionListRefreshOptions(_sessionEventsRefreshPendingRequest && _sessionEventsRefreshPendingRequest.opts, opts),
};
if(_sessionEventsRefreshTimer) return;
_sessionEventsRefreshTimer = setTimeout(() => {
_sessionEventsRefreshTimer = 0;
void refreshSessionList(reason||'event', {refreshActive:true});
const request = _sessionEventsRefreshPendingRequest || {reason:'event', opts:{}};
_sessionEventsRefreshPendingRequest = null;
void refreshSessionList(request.reason||'event', request.opts);
}, 300);
}

Expand Down Expand Up @@ -5113,14 +5136,15 @@ function _installSidebarSseFocusHook(){
// to prevent, in the multi-window scenario this fix targets (#4151).
ensureSessionEventsSSE();
if(!_gatewaySSE) startGatewaySSE();
void refreshSessionList('focus');
void _refreshSessionListAfterSidebarResume('focus');
});
}

function _closeSessionEventsSSE(){
if(_sessionEventsSSE){
try{if(_sessionEventsSSE.readyState!==2)_sessionEventsSSE.close();}catch(_){ }
_sessionEventsSSE = null;
_sessionEventsNeedsRefreshOnOpen = true;
}
}
Comment thread
rodboev marked this conversation as resolved.

Expand All @@ -5131,7 +5155,7 @@ function ensureSessionEventsSSE(){
_closeSessionEventsSSE();
}else{
ensureSessionEventsSSE();
void refreshSessionList('visible');
void _refreshSessionListAfterSidebarResume('visible');
}
});
document._hermesSessionEventsVisibilityHook = true;
Expand All @@ -5147,7 +5171,7 @@ function ensureSessionEventsSSE(){
_sessionEventsReconnectAttempt = 0;
if(!_sessionEventsNeedsRefreshOnOpen) return;
_sessionEventsNeedsRefreshOnOpen = false;
void refreshSessionList('reconnect');
void _refreshSessionListAfterSidebarResume('reconnect');
};
_sessionEventsSSE.addEventListener('sessions_changed', (ev) => {
const activeProfile = S.activeProfile || 'default';
Expand All @@ -5163,7 +5187,7 @@ function ensureSessionEventsSSE(){
// Non-JSON payload (or transient malformed event). Keep legacy behavior:
// refresh once event was seen.
}
_scheduleSessionEventsRefresh(eventTargetsActiveSession?'event-active-session':'event');
_scheduleSessionEventsRefresh(eventTargetsActiveSession?'event-active-session':'event', {force:true, refreshActive:true});
});
_sessionEventsSSE.onerror = () => {
_sessionEventsNeedsRefreshOnOpen = true;
Expand Down
3 changes: 2 additions & 1 deletion tests/test_issue3916_external_refresh_poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ def test_poll_path_skips_non_external_sessions():
def test_session_events_refresh_active_session():
src = SESSIONS_JS.read_text(encoding="utf-8")
body = _function_body(src, "_scheduleSessionEventsRefresh")
assert "refreshSessionList(reason||'event', {refreshActive:true})" in body
assert "void refreshSessionList(request.reason||'event', request.opts)" in body
assert "_scheduleSessionEventsRefresh(eventTargetsActiveSession?'event-active-session':'event', {force:true, refreshActive:true})" in src
1 change: 1 addition & 0 deletions tests/test_issue4151_pwa_focus_sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def test_focus_hook_closes_both_global_sidebar_streams():
assert "window.addEventListener('focus'" in block
assert "ensureSessionEventsSSE()" in block
assert "startGatewaySSE()" in block
assert "_refreshSessionListAfterSidebarResume('focus')" in block


def test_blur_close_is_debounced_and_rechecks_focus_at_fire_time():
Expand Down
197 changes: 193 additions & 4 deletions tests/test_webui_external_refresh_frontend.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import json
import re
import subprocess
import textwrap
from pathlib import Path


Expand All @@ -7,6 +11,33 @@
PANELS_JS = Path("static/panels.js").read_text(encoding="utf-8")


def _function_body(src: str, name: str) -> str:
# Extracted functions intentionally avoid braces in strings so this stays source-light.
m = re.search(rf"(?:async\s+)?function\s+{re.escape(name)}\b", src)
assert m, f"{name} function not found"
sig_end = src.find(")", m.end())
assert sig_end != -1, f"{name} function signature not terminated"
brace_start = src.find("{", sig_end)
assert brace_start != -1, f"{name} function body not found"
depth = 0
for idx in range(brace_start, len(src)):
ch = src[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[m.start():idx + 1]
raise AssertionError(f"{name} function body not terminated")


def _run_node(script: str) -> dict:
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
if result.returncode != 0:
Comment thread
rodboev marked this conversation as resolved.
raise AssertionError(f"node failed: {result.stderr}")
return json.loads(result.stdout)


def test_load_session_supports_force_reload_for_external_refresh():
assert "async function loadSession(sid)" in SESSIONS_JS
assert "const opts = arguments[1] || {};" in SESSIONS_JS
Expand Down Expand Up @@ -93,17 +124,25 @@ def test_session_time_refresh_has_own_visibility_hook():
def test_session_list_external_refresh_uses_sse_invalidation_not_polling():
"""New sessions should refresh the sidebar from server invalidation events."""
assert "async function refreshSessionList(reason='manual', opts={})" in SESSIONS_JS
assert "let _sessionListRefreshPendingRequest = null;" in SESSIONS_JS
assert "function _mergeSessionListRefreshOptions(prev, next)" in SESSIONS_JS
assert "function _refreshSessionListAfterSidebarResume(reason)" in SESSIONS_JS
assert "_sessionEventsNeedsRefreshOnOpen = false" in SESSIONS_JS
assert "void refreshSessionList(reason, {force:true})" in SESSIONS_JS
assert "function ensureSessionEventsSSE()" in SESSIONS_JS
assert "new EventSource('api/sessions/events')" in SESSIONS_JS
assert "addEventListener('sessions_changed'" in SESSIONS_JS
assert "function _scheduleSessionEventsRefresh(reason)" in SESSIONS_JS
assert "function _scheduleSessionEventsRefresh(reason, opts={})" in SESSIONS_JS
assert "let _sessionEventsRefreshPendingRequest = null;" in SESSIONS_JS
assert "_sessionEventsNeedsRefreshOnOpen = true" in SESSIONS_JS
assert "void refreshSessionList('reconnect')" in SESSIONS_JS
assert "void _refreshSessionListAfterSidebarResume('focus')" in SESSIONS_JS
assert "void _refreshSessionListAfterSidebarResume('visible')" in SESSIONS_JS
assert "void _refreshSessionListAfterSidebarResume('reconnect')" in SESSIONS_JS
assert "renderSessionList({deferWhileInteracting:!force})" in SESSIONS_JS
assert "const refreshActive = !!(opts && opts.refreshActive)" in SESSIONS_JS
assert "if(refreshActive) await refreshActiveSessionIfExternallyUpdated(reason||'session-list')" in SESSIONS_JS
assert "_sessionListRefreshPendingReason = reason || 'session-list'" in SESSIONS_JS
assert "if(pendingReason) _scheduleSessionEventsRefresh(pendingReason)" in SESSIONS_JS
assert "_sessionListRefreshPendingRequest = {" in SESSIONS_JS
assert "_scheduleSessionEventsRefresh(pendingRequest.reason, pendingRequest.opts)" in SESSIONS_JS
assert "ensureSessionEventsSSE();" in SESSIONS_JS
assert "document._hermesSessionEventsVisibilityHook" in SESSIONS_JS
ensure_fn = SESSIONS_JS[SESSIONS_JS.find("function ensureSessionEventsSSE()") :]
Expand All @@ -120,6 +159,156 @@ def test_session_list_external_refresh_uses_sse_invalidation_not_polling():
assert "function _sessionEventTargetsActiveSession(payload)" in SESSIONS_JS
assert "typeof payload.session_id === 'string'" in SESSIONS_JS
assert "eventTargetsActiveSession?'event-active-session':'event'" in ensure_fn
assert "_scheduleSessionEventsRefresh(eventTargetsActiveSession?'event-active-session':'event', {force:true, refreshActive:true})" in ensure_fn


def test_session_events_refresh_forces_hidden_sidebar_render_from_event_path():
"""The production sessions_changed path must force the hidden sidebar list render."""
merge_body = (
_function_body(SESSIONS_JS, "_mergeSessionListRefreshOptions")
if "function _mergeSessionListRefreshOptions" in SESSIONS_JS
else "function _mergeSessionListRefreshOptions(prev, next){ return {...(prev||{}), ...(next||{})}; }"
)
functions = "\n\n".join(
[
merge_body,
_function_body(SESSIONS_JS, "refreshSessionList"),
_function_body(SESSIONS_JS, "_scheduleSessionEventsRefresh"),
]
)
script = textwrap.dedent(
f"""
const record = [];
global.document = {{hidden: true}};
global.renderSessionList = async (opts) => record.push({{kind: 'render', opts}});
global.refreshActiveSessionIfExternallyUpdated = async (reason) => record.push({{kind: 'active', reason}});
global.setTimeout = (cb) => {{
cb();
return 1;
}};
let _sessionListRefreshInFlight = false;
let _sessionListRefreshPendingRequest = null;
let _sessionEventsRefreshTimer = 0;
let _sessionEventsRefreshPendingRequest = null;
{functions}
(async() => {{
_scheduleSessionEventsRefresh('event-active-session', {{force:true, refreshActive:true}});
await Promise.resolve();
process.stdout.write(JSON.stringify(record));
}})().catch((err) => {{
process.stderr.write(String(err.stack || err) + '\\n');
process.exit(1);
}});
"""
)
out = _run_node(script)
assert out == [
{"kind": "render", "opts": {"deferWhileInteracting": False}},
{"kind": "active", "reason": "event-active-session"},
]


def test_session_list_external_refresh_forced_resume_survives_hidden_inflight_refresh():
"""Queued resume refreshes must keep `force:true` even after a hidden invalidation."""
functions = "\n\n".join(
_function_body(SESSIONS_JS, name)
for name in (
"_mergeSessionListRefreshOptions",
"refreshSessionList",
"_scheduleSessionEventsRefresh",
"_refreshSessionListAfterSidebarResume",
)
)
script = textwrap.dedent(
f"""
const record = [];
const state = {{hidden: false}};
global.document = state;
let renderCount = 0;
global.renderSessionList = async (opts) => {{
record.push({{kind: 'render', opts}});
renderCount += 1;
if (renderCount === 1) {{
state.hidden = true;
await refreshSessionList('focus', {{force:true}});
}}
}};
global.refreshActiveSessionIfExternallyUpdated = async (reason) => {{
record.push({{kind: 'active', reason}});
}};
global.setTimeout = (cb) => {{
cb();
return 1;
}};
global.clearTimeout = () => {{}};
let _sessionListRefreshInFlight = false;
let _sessionListRefreshPendingRequest = null;
let _sessionEventsRefreshTimer = 0;
let _sessionEventsRefreshPendingRequest = null;
{functions}
(async() => {{
await refreshSessionList('event', {{refreshActive:true}});
await refreshSessionList('manual');
process.stdout.write(JSON.stringify({{
record,
resumeSrc: _refreshSessionListAfterSidebarResume.toString(),
}}));
}})().catch((err) => {{
process.stderr.write(String(err.stack || err) + '\\n');
process.exit(1);
}});
"""
)
out = _run_node(script)
assert out["record"] == [
{"kind": "render", "opts": {"deferWhileInteracting": True}},
{"kind": "active", "reason": "event"},
{"kind": "render", "opts": {"deferWhileInteracting": False}},
]
assert "refreshSessionList(reason, {force:true})" in out["resumeSrc"]
assert "refreshActive:true" not in out["resumeSrc"]


def test_session_events_refresh_timer_merges_pending_force_and_active_options():
"""The debounce window must not drop force or active-refresh intent."""
functions = "\n\n".join(
_function_body(SESSIONS_JS, name)
for name in (
"_mergeSessionListRefreshOptions",
"refreshSessionList",
"_scheduleSessionEventsRefresh",
)
)
script = textwrap.dedent(
f"""
const record = [];
global.document = {{hidden: true}};
global.renderSessionList = async (opts) => record.push({{kind: 'render', opts}});
global.refreshActiveSessionIfExternallyUpdated = async (reason) => record.push({{kind: 'active', reason}});
let timerCb = null;
global.setTimeout = (cb) => {{ timerCb = cb; return 1; }};
global.clearTimeout = () => {{}};
let _sessionListRefreshInFlight = false;
let _sessionListRefreshPendingRequest = null;
let _sessionEventsRefreshTimer = 0;
let _sessionEventsRefreshPendingRequest = null;
{functions}
(async() => {{
_scheduleSessionEventsRefresh('focus', {{force:true}});
_scheduleSessionEventsRefresh('event-active-session', {{refreshActive:true}});
timerCb();
await Promise.resolve();
process.stdout.write(JSON.stringify(record));
}})().catch((err) => {{
process.stderr.write(String(err.stack || err) + '\\n');
process.exit(1);
}});
"""
)
assert _run_node(script) == [
{"kind": "render", "opts": {"deferWhileInteracting": False}},
{"kind": "active", "reason": "event-active-session"},
]


def test_session_event_profile_filter_tolerates_default_root_aliases():
Expand Down
Loading