fix(console): stale profile-mismatch response must not stop the replacement poller

Each approval/clarify fallback poller captures its own interval handle; a 409
(or finally) from a request issued by a replaced poller no longer stops or
clears the in-flight guard of the poller that superseded it.
This commit is contained in:
carlotestor committed 2026-09-23 19:11:25 +00:00
1 parent c713c6efc2
commit fbb0843acf
3 files changed
+59 -9

No files matched your search

+10 -7
View File
@@ -8063,13 +8063,15 @@ function _startApprovalFallbackPoll(sid) {
} catch(e) {
// Another profile owns this session now (e.g. a different tab switched the shared
// profile cookie): every further poll would 409, so stop and leave the card as-is.
if (typeof _sessionProfileMismatchFromError === 'function' && _sessionProfileMismatchFromError(e)) {
stopApprovalPollingForSession(sid);
// Only this poller may stop itself: a late 409 from a replaced poller must not kill its successor.
if (typeof _sessionProfileMismatchFromError === 'function' && _sessionProfileMismatchFromError(e)
&& _approvalPollTimer === pollTimer) {
stopApprovalPolling();
}
}
finally { _approvalFallbackPollInFlight = false; }
finally { if (_approvalPollTimer === pollTimer) _approvalFallbackPollInFlight = false; }
};
_approvalPollTimer = setInterval(_tick, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
const pollTimer = _approvalPollTimer = setInterval(_tick, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
_tick();
}
@@ -9212,8 +9214,9 @@ function _startClarifyFallbackPoll(sid) {
};
// Profile-mismatch 409: the session is owned by another profile under the current
// cookie, so polling can never succeed. Stop quietly; the live card stays standing.
// Only this poller may stop itself: a late 409 from a replaced poller must not kill its successor.
if (typeof _sessionProfileMismatchFromError === "function" && _sessionProfileMismatchFromError(e)) {
stopClarifyPollingForSession(sid);
if (_clarifyFallbackTimer === pollTimer) stopClarifyPolling();
return;
}
// A 404 from the active session domain is a STALE-SESSION signal — e.g.
@@ -9264,10 +9267,10 @@ function _startClarifyFallbackPoll(sid) {
console.warn("[clarify] pending poll failed", logDetails);
}
} finally {
_clarifyFallbackPollInFlight = false;
if (_clarifyFallbackTimer === pollTimer) _clarifyFallbackPollInFlight = false;
}
};
_clarifyFallbackTimer = setInterval(_tick, 3000);
const pollTimer = _clarifyFallbackTimer = setInterval(_tick, 3000);
_tick();
}
@@ -209,3 +209,50 @@ def test_clarify_poll_still_warns_on_unexpected_errors():
def test_pwa_startup_is_not_preloaded_next_to_its_own_blocking_script():
assert '<script src="static/pwa-startup.js?v=__WEBUI_VERSION__"></script>' in INDEX_HTML
assert 'rel="preload" href="static/pwa-startup.js' not in INDEX_HTML
def _run_stale_replacement(start_fn, stop_fn, polling_var):
"""Poller A's request is in flight; a same-session refresh replaces it with
poller B; then A's request fails with a profile-mismatch 409."""
node = shutil.which("node")
if not node:
pytest.skip("node not installed")
fns = [
_extract_fn(SESSIONS_JS, "_sessionProfileMismatchFromError"),
_extract_fn(MESSAGES_JS, start_fn),
_extract_fn(MESSAGES_JS, stop_fn),
]
harness = _HARNESS.replace("STATUS", "409").replace("BODY", json.dumps(_MISMATCH))
harness = harness.replace("async function api(){", "var pending = [];\nasync function api(){\n if (pending) return new Promise((_, rej) => pending.push(rej));")
script = harness + "\n".join(fns) + f"""
(async () => {{
{start_fn}('sid1'); // poller A: first request hangs
{stop_fn}(); // same-session refresh
{polling_var} = 'sid1'; // startApprovalPolling() sets this before the fallback poll
{start_fn}('sid1'); // poller B: its request hangs too
const timerB = {"_approvalPollTimer" if "Approval" in start_fn else "_clarifyFallbackTimer"};
const e = new Error('Session belongs to a different profile');
e.status = 409; e.body = JSON.stringify({json.dumps(_MISMATCH)});
pending[0](e); // late 409 for poller A
await new Promise(r => setTimeout(r, 0));
process.stdout.write(JSON.stringify({{
polling: {polling_var},
timerAlive: {"_approvalPollTimer" if "Approval" in start_fn else "_clarifyFallbackTimer"} === timerB,
inFlight: {"_approvalFallbackPollInFlight" if "Approval" in start_fn else "_clarifyFallbackPollInFlight"},
}}));
}})();
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
@pytest.mark.parametrize("start_fn,stop_fn,polling_var", [
("_startApprovalFallbackPoll", "stopApprovalPolling", "_approvalPollingSessionId"),
("_startClarifyFallbackPoll", "stopClarifyPolling", "_clarifyPollingSessionId"),
])
def test_stale_profile_mismatch_does_not_stop_replacement_poller(start_fn, stop_fn, polling_var):
r = _run_stale_replacement(start_fn, stop_fn, polling_var)
assert r["polling"] == "sid1", "a late 409 from a replaced poller must not stop its successor"
assert r["timerAlive"] is True
assert r["inFlight"] is True, "the successor's in-flight guard must not be cleared by the stale request"
+2 -2
View File
@@ -34,11 +34,11 @@ def test_approval_and_clarify_fallback_polls_do_not_overlap():
assert "let _approvalFallbackPollInFlight = false" in src
assert "if (_approvalFallbackPollInFlight) return" in src
assert "_approvalFallbackPollInFlight = true" in src
assert "finally { _approvalFallbackPollInFlight = false; }" in src
assert "finally { if (_approvalPollTimer === pollTimer) _approvalFallbackPollInFlight = false; }" in src
assert "_approvalFallbackPollInFlight = false;\n _approvalPollingSessionId = null;" in src
assert "let _clarifyFallbackPollInFlight = false" in src
assert "if (_clarifyFallbackPollInFlight) return" in src
assert "_clarifyFallbackPollInFlight = true" in src
assert "finally {\n _clarifyFallbackPollInFlight = false;\n }" in src
assert "finally {\n if (_clarifyFallbackTimer === pollTimer) _clarifyFallbackPollInFlight = false;\n }" in src
assert "_clarifyFallbackPollInFlight = false;\n _clarifyPollingSessionId = null;" in src