fix(console): retry a late profile-mismatch 409 once when focus returned mid-request

This commit is contained in:
carlotestor committed 2026-09-24 17:09:08 +00:00
1 parent 6ebd12c251
commit 075f95aaf2
3 files changed
+78 -4

No files matched your search

+11 -4
View File
@@ -8139,6 +8139,9 @@ let _approvalSSEHealthTimer = null;
let _approvalPollingSessionId = null;
// Session whose poller stopped on a profile-mismatch 409; re-armed on focus/visibility.
let _approvalProfilePausedSessionId = null;
// Bumped on every focus/visibility return. A mismatch 409 for a request that began before
// the latest return may predate a cookie switch-back, so it earns one retry before pausing.
let _promptPollerFocusEpoch = 0;
function _startApprovalFallbackPoll(sid) {
_approvalProfilePausedSessionId = null;
@@ -8151,6 +8154,7 @@ function _startApprovalFallbackPoll(sid) {
}
if (_approvalFallbackPollInFlight) return;
_approvalFallbackPollInFlight = true;
const focusEpoch = _promptPollerFocusEpoch;
try {
const generation = _approvalPromptGeneration(sid);
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
@@ -8172,8 +8176,9 @@ function _startApprovalFallbackPoll(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();
_approvalProfilePausedSessionId = sid;
// Focus returned mid-request: the cookie may be back, so retry once (after finally).
if (focusEpoch !== _promptPollerFocusEpoch) queueMicrotask(_tick);
else { stopApprovalPolling(); _approvalProfilePausedSessionId = sid; }
}
}
finally { if (_approvalPollTimer === pollTimer) _approvalFallbackPollInFlight = false; }
@@ -9337,6 +9342,7 @@ function _startClarifyFallbackPoll(sid) {
}
if (_clarifyFallbackPollInFlight) return;
_clarifyFallbackPollInFlight = true;
const focusEpoch = _promptPollerFocusEpoch;
try {
const generation = _clarifyPromptGeneration(sid);
const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
@@ -9363,8 +9369,8 @@ function _startClarifyFallbackPoll(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)) {
if (_clarifyFallbackTimer === pollTimer) {
stopClarifyPolling();
_clarifyProfilePausedSessionId = sid;
if (focusEpoch !== _promptPollerFocusEpoch) queueMicrotask(_tick);
else { stopClarifyPolling(); _clarifyProfilePausedSessionId = sid; }
}
return;
}
@@ -9441,6 +9447,7 @@ function stopClarifyPolling() {
// profile just 409s once and pauses them again.
function _resumeProfilePausedPromptPollers() {
if (typeof document !== 'undefined' && document.hidden) return;
_promptPollerFocusEpoch++;
const current = (S.session && S.session.session_id) || null;
const approvalSid = _approvalProfilePausedSessionId;
const clarifySid = _clarifyProfilePausedSessionId;
@@ -124,6 +124,7 @@ var _clarifyEventSource = null, _clarifyFallbackTimer = null, _clarifyHealthTime
var _clarifyFallbackPollInFlight = false, _clarifyPollingSessionId = null, _clarifyMissingEndpointWarned = false;
var _approvalPendingBySession = new Map();
var _approvalProfilePausedSessionId = null, _clarifyProfilePausedSessionId = null;
var _promptPollerFocusEpoch = 0;
function _approvalPromptGeneration(){ return 0; }
function _clarifyPromptGeneration(){ return 0; }
function _approvalPollingSessionMissingOrMismatched(sid){ return !sid || !S.session || S.session.session_id !== sid; }
@@ -335,3 +336,68 @@ process.stdout.write(String(started));
out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30)
assert out.returncode == 0, out.stderr
assert out.stdout == "0"
def _run_late_409_after_focus(kind, cookie_back):
"""The request starts under the old profile, focus returns while it is in flight,
then the old-profile 409 lands. The poller must retry once under the current
cookie instead of pausing for good; it pauses only if the retry also mismatches."""
node = shutil.which("node")
if not node:
pytest.skip("node not installed")
approval = kind == "approval"
fns = [
_extract_fn(SESSIONS_JS, "_sessionProfileMismatchFromError"),
_extract_fn(MESSAGES_JS, "startApprovalPolling"),
_extract_fn(MESSAGES_JS, "_startApprovalFallbackPoll"),
_extract_fn(MESSAGES_JS, "stopApprovalPollingForSession"),
_extract_fn(MESSAGES_JS, "stopApprovalPolling"),
_extract_fn(MESSAGES_JS, "startClarifyPolling"),
_extract_fn(MESSAGES_JS, "_startClarifyFallbackPoll"),
_extract_fn(MESSAGES_JS, "stopClarifyPollingForSession"),
_extract_fn(MESSAGES_JS, "stopClarifyPolling"),
_extract_fn(MESSAGES_JS, "_resumeProfilePausedPromptPollers"),
]
harness = _HARNESS.replace("STATUS", "409").replace("BODY", json.dumps(_MISMATCH))
harness = harness.replace("async function api(){", """var mismatch = true, releaseFirst = null;
async function api(){
if (!releaseFirst) { await new Promise(r => { releaseFirst = r; }); return _mismatchThrow(); }
if (!mismatch) { calls.api++; return {pending: {id: 'p1'}}; }
return _mismatchThrow();
}
function _mismatchThrow(){""")
harness = harness.replace("function showApprovalForSession(){}", "function showApprovalForSession(){ calls.shown = (calls.shown||0) + 1; }")
harness = harness.replace("function showClarifyForSession(){}", "function showClarifyForSession(){ calls.shown = (calls.shown||0) + 1; }")
start = "startApprovalPolling" if approval else "startClarifyPolling"
timer = "_approvalPollTimer" if approval else "_clarifyFallbackTimer"
script = harness + "\n".join(fns) + f"""
const tick = () => new Promise(r => setTimeout(r, 0));
(async () => {{
{start}('sid1');
await tick(); // first request is in flight under the old cookie
mismatch = {'false' if cookie_back else 'true'}; // other tab switches the cookie (back or not)
_resumeProfilePausedPromptPollers(); // focus returns: nothing paused yet, so nothing to restart
releaseFirst(); // the late old-profile 409 lands
for (let i = 0; i < 5; i++) await tick();
process.stdout.write(JSON.stringify({{api: calls.api, shown: calls.shown || 0,
running: {timer} !== null}}));
}})();
"""
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("kind", ["approval", "clarify"])
def test_late_409_after_focus_retries_once_and_shows_pending(kind):
r = _run_late_409_after_focus(kind, cookie_back=True)
assert r["api"] == 2, "a 409 for a request that predates focus must be retried once"
assert r["shown"] == 1, "the pending prompt must be shown after the retry"
assert r["running"] is True, "polling must keep running after the successful retry"
@pytest.mark.parametrize("kind", ["approval", "clarify"])
def test_late_409_after_focus_pauses_when_retry_still_mismatches(kind):
r = _run_late_409_after_focus(kind, cookie_back=False)
assert r["shown"] == 0
assert r["running"] is False, "a retry that still mismatches must pause, not loop"
@@ -24,6 +24,7 @@ const S = {busy:true,session:{session_id:'s'}};
const _approvalPromptBelongsToActiveSession=()=>false;
let _approvalSessionId = 's', _approvalCurrentId = 'id';
let _approvalFallbackPollInFlight=false, _approvalPollTimer;
let _promptPollerFocusEpoch=0;
let _clarifyPollingSessionId, _clarifyPollTimer, _clarifyFallbackPollInFlight=false;
const _promptActiveSessionId=()=> 's';
const _approvalPollingSessionMissingOrMismatched=()=>false;