Skip to content
Merged
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
30 changes: 20 additions & 10 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -4148,16 +4148,7 @@ async function _runRenderSessionListRefresh(opts, _gen){
retryTimeouts:true,
retryStatuses:[502,503,504],
};
const sessData = _sessionListHasLoadedOnce
? await api('/api/sessions' + sessionListQS,{timeoutToast:false})
: await api('/api/sessions' + sessionListQS,sessionRequestOpts);
let projData={projects:_allProjects||[]};
try{
const projectQS = _showAllProfiles ? '?all_profiles=1' : '';
projData = await api('/api/projects' + projectQS,{timeoutToast:false});
}catch(projectError){
console.warn('renderProjectsList',projectError);
}
const {sessData, projData}=await _loadSidebarSessionListPayload(sessionListQS, sessionRequestOpts);
// Discard stale response — a newer renderSessionList() call superseded us.
if (_gen !== _renderSessionListGen) return;
// #4671: while a profile switch is mid-flight, drop ANY payload — even one whose
Expand Down Expand Up @@ -4211,6 +4202,25 @@ async function _runRenderSessionListRefresh(opts, _gen){
}
}

async function _loadSidebarSessionListPayload(sessionListQS, sessionRequestOpts){
const projectPromise = (async() => {
try{
const projectQS = _showAllProfiles ? '?all_profiles=1' : '';
return await api('/api/projects' + projectQS,{timeoutToast:false});
}catch(projectError){
console.warn('renderProjectsList',projectError);
return {projects:_allProjects||[]};
}
})();

const sessData = _sessionListHasLoadedOnce
? await api('/api/sessions' + sessionListQS,{timeoutToast:false})
: await api('/api/sessions' + sessionListQS,sessionRequestOpts);
const projData = await projectPromise;

return {sessData,projData};
}

async function _drainRenderSessionListQueue(initialRequest){
let request=initialRequest;
try{
Expand Down
330 changes: 330 additions & 0 deletions tests/test_issue4759_parallel_sidebar_boot_fetch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
"""Regression tests for #4759: parallelize first-load sidebar boot fetches."""

from __future__ import annotations

import json
import shutil
import subprocess
from pathlib import Path

import pytest


ROOT = Path(__file__).resolve().parents[1]
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
NODE = shutil.which("node")


def _extract_function(source_text: str, function_name: str) -> str:
marker = f"async function {function_name}("
start = source_text.find(marker)
if start < 0:
marker = f"function {function_name}("
start = source_text.find(marker)
assert start >= 0, f"{function_name}() not found"
brace_start = source_text.find("{", start)
assert brace_start >= 0, f"{function_name} body not found"

depth = 0
in_string = None
escaped = False
in_line_comment = False
in_block_comment = False

for index in range(brace_start, len(source_text)):
char = source_text[index]
nxt = source_text[index + 1] if index + 1 < len(source_text) else ""

if in_line_comment:
if char == "\n":
in_line_comment = False
continue
if in_block_comment:
if char == "*" and nxt == "/":
in_block_comment = False
continue
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == in_string:
in_string = None
continue
if char == "/" and nxt == "/":
in_line_comment = True
continue
if char == "/" and nxt == "*":
in_block_comment = True
continue
if char in ("'", '"', "`"):
in_string = char
continue

if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return source_text[start : index + 1]

raise AssertionError(f"could not extract {function_name}()")


def _run_node(script: str):
completed = subprocess.run(
[NODE, "-e", script],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)
assert completed.returncode == 0, completed.stderr or completed.stdout
return json.loads(completed.stdout.strip())


@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_cold_boot_starts_projects_fetch_before_sessions_resolve():
"""The project request must start before the session request settles."""
fetch_helper = _extract_function(SESSIONS_JS, "_loadSidebarSessionListPayload")
script = f"""
const calls = [];
let resolveSessions;
let resolveProjects;

global._showAllProfiles = false;
global._allProjects = [];
global._sessionListHasLoadedOnce = false;
global.api = (url) => {{
if (url.startsWith('/api/projects')) {{
calls.push('projects');
return new Promise(resolve => {{
resolveProjects = resolve;
}});
}}
if (url.startsWith('/api/sessions')) {{
calls.push('sessions');
return new Promise(resolve => {{
resolveSessions = resolve;
}});
}}
return Promise.reject(new Error('unexpected endpoint ' + url));
}};
{fetch_helper}

(async () => {{
const run = _loadSidebarSessionListPayload('?v=1', {{
timeoutToast:false,
timeoutMs:90000,
retries:1,
retryTimeouts:true,
retryStatuses:[502,503,504],
}});

await Promise.resolve();
const orderAtSettleBoundary = calls.slice();

resolveSessions({{
sessions:[{{session_id:'s1'}}],
other_profile_count:0,
archived_count:0,
archived_webui_count:0,
archived_cli_count:0,
}});
resolveProjects({{projects:[]}});
const payload = await run;
console.log(JSON.stringify({{orderAtSettleBoundary,payload,calls:calls}}));
}})().catch(error => {{
console.error(error);
process.exit(1);
}});
"""

body = _run_node(script)
assert body["orderAtSettleBoundary"] == ["projects", "sessions"]
assert body["payload"]["sessData"]["sessions"] == [{"session_id": "s1"}]
assert body["payload"]["projData"]["projects"] == []


@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_project_failure_falls_back_without_blocking_session_payload():
"""Project endpoint failure should still return session payload and fallback projects."""
fetch_helper = _extract_function(SESSIONS_JS, "_loadSidebarSessionListPayload")
script = f"""
const calls = [];
global._showAllProfiles = true;
global._allProjects = [];
global._sessionListHasLoadedOnce = false;
global.api = (url) => {{
if (url.startsWith('/api/projects')) {{
calls.push('projects');
return Promise.reject(new Error('project endpoint down'));
}}
if (url.startsWith('/api/sessions')) {{
calls.push('sessions');
return Promise.resolve({{
sessions:[{{session_id:'s1'}}],
other_profile_count:0,
archived_count:0,
archived_webui_count:0,
archived_cli_count:0,
}});
}}
return Promise.reject(new Error('unexpected endpoint ' + url));
}};
{fetch_helper}

(async () => {{
const payload = await _loadSidebarSessionListPayload('?v=1', {{
timeoutToast:false,
timeoutMs:90000,
retries:1,
retryTimeouts:true,
retryStatuses:[502,503,504],
}});
console.log(JSON.stringify({{calls, payload}}));
}})().catch(error => {{
console.error(error);
process.exit(1);
}});
"""

body = _run_node(script)
assert body["calls"] == ["projects", "sessions"]
assert body["payload"]["sessData"]["sessions"][0]["session_id"] == "s1"
assert body["payload"]["projData"]["projects"] == []


@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_existing_session_request_options_are_preserved():
"""The cold-load /api/sessions request keeps the existing timeout/retry policy."""
fetch_helper = _extract_function(SESSIONS_JS, "_loadSidebarSessionListPayload")
refresh_fn = _extract_function(SESSIONS_JS, "_runRenderSessionListRefresh")
script = f"""
global._SESSION_LIST_BOOT_TIMEOUT_MS = 90000;
global._sessionListHasLoadedOnce = false;
global._renderSessionListGen = 1;
global._profileSwitchListEmbargo = false;
global._pendingSessionListPayload = null;
global._showAllProfiles = false;
global._allProjects = [];
global._contentSearchResults = [];
global.$ = () => ({{ value: '' }});
global._isSessionListUserInteracting = () => false;
global._schedulePendingSessionListApply = () => {{}};
global._showSessionListLoadError = error => {{ throw new Error(error && error.message ? error.message : String(error)); }};
global._applySessionListPayload = () => {{}};
global._sessionListQueryString = () => '?v=1';
const calls = [];
global.api = (url, opts) => {{
calls.push({{endpoint:url.replace('/api/',''), opts}});
if(url.startsWith('/api/sessions')){{
return Promise.resolve({{
sessions:[{{session_id:'s1'}}],
other_profile_count:0,
archived_count:0,
archived_webui_count:0,
archived_cli_count:0,
}});
}}
return Promise.resolve({{projects:[]}});
}};
{fetch_helper}
{refresh_fn}

(async () => {{
await _runRenderSessionListRefresh({{}}, 1);
const sessionCall = calls.find(entry => entry.endpoint === 'sessions?') || calls.find(entry => entry.endpoint.startsWith('sessions'));
const projectCall = calls.find(entry => entry.endpoint === 'projects?') || calls.find(entry => entry.endpoint.startsWith('projects'));
console.log(JSON.stringify({{
sessionOpts: sessionCall ? sessionCall.opts : null,
projectOpts: projectCall ? projectCall.opts : null,
calls,
}}));
}})().catch(error => {{
console.error(error);
process.exit(1);
}});
"""

body = _run_node(script)
session_opts = body["sessionOpts"] or {}
project_opts = body["projectOpts"] or {}

assert session_opts["timeoutToast"] is False
assert session_opts["timeoutMs"] == 90000
assert session_opts["retries"] == 1
assert session_opts["retryTimeouts"] is True
assert session_opts["retryStatuses"] == [502, 503, 504]
assert project_opts == {"timeoutToast": False}


@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_warm_refresh_parallelizes_projects_fetch_and_keeps_minimal_session_opts():
"""Warm refresh uses minimal session opts, but still starts projects immediately."""
fetch_helper = _extract_function(SESSIONS_JS, "_loadSidebarSessionListPayload")
script = f"""
const calls = [];
let resolveSessions;
let resolveProjects;
global._showAllProfiles = false;
global._allProjects = [];
global._sessionListHasLoadedOnce = true;
global.api = (url, opts) => {{
if (url.startsWith('/api/projects')) {{
calls.push({{ endpoint:'projects', opts: opts || null }});
return new Promise(resolve => {{
resolveProjects = resolve;
}});
}}
if (url.startsWith('/api/sessions')) {{
calls.push({{ endpoint:'sessions', opts: opts || null }});
return new Promise(resolve => {{
resolveSessions = resolve;
}});
}}
return Promise.reject(new Error('unexpected endpoint ' + url));
}};
{fetch_helper}

(async () => {{
const run = _loadSidebarSessionListPayload('?v=1', {{
timeoutToast:false,
timeoutMs:90000,
retries:1,
retryTimeouts:true,
retryStatuses:[502,503,504],
}});

await Promise.resolve();
const orderAtSettleBoundary = calls.map(call => call.endpoint);
const sessionOpts = calls.find(call => call.endpoint === 'sessions').opts;
const projectOpts = calls.find(call => call.endpoint === 'projects').opts;

resolveSessions({{
sessions:[{{session_id:'warm-1'}}],
other_profile_count:0,
archived_count:0,
archived_webui_count:0,
archived_cli_count:0,
}});
resolveProjects({{projects:[{{name:'demo'}}]}});
const payload = await run;
console.log(JSON.stringify({{
orderAtSettleBoundary,
sessionOpts,
projectOpts,
payload,
}}));
}})().catch(error => {{
console.error(error);
process.exit(1);
}});
"""

body = _run_node(script)
assert body["orderAtSettleBoundary"] == ["projects", "sessions"]
assert body["sessionOpts"] == {"timeoutToast": False}
assert body["projectOpts"] == {"timeoutToast": False}
assert body["payload"]["sessData"]["sessions"] == [{"session_id": "warm-1"}]
assert body["payload"]["projData"]["projects"] == [{"name": "demo"}]
Loading
Loading