diff --git a/static/messages.js b/static/messages.js index 7760b0ac..bee24ef8 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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(); } diff --git a/tests/test_console_noise_git_info_and_pollers.py b/tests/test_console_noise_git_info_and_pollers.py index aa8e7034..cb36a92f 100644 --- a/tests/test_console_noise_git_info_and_pollers.py +++ b/tests/test_console_noise_git_info_and_pollers.py @@ -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 '' 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" diff --git a/tests/test_performance_polling_hardening.py b/tests/test_performance_polling_hardening.py index 61851288..051464a5 100644 --- a/tests/test_performance_polling_hardening.py +++ b/tests/test_performance_polling_hardening.py @@ -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