fix(console): retry a late profile-mismatch 409 once when focus returned mid-request
This commit is contained in:
1 parent
6ebd12c251
commit
075f95aaf2
3 files changed
+78
-4
No files matched your search
+11
-4
@@ -8139,6 +8139,9 @@ let _approvalSSEHealthTimer = null;
|
|||||||
let _approvalPollingSessionId = null;
|
let _approvalPollingSessionId = null;
|
||||||
// Session whose poller stopped on a profile-mismatch 409; re-armed on focus/visibility.
|
// Session whose poller stopped on a profile-mismatch 409; re-armed on focus/visibility.
|
||||||
let _approvalProfilePausedSessionId = null;
|
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) {
|
function _startApprovalFallbackPoll(sid) {
|
||||||
_approvalProfilePausedSessionId = null;
|
_approvalProfilePausedSessionId = null;
|
||||||
@@ -8151,6 +8154,7 @@ function _startApprovalFallbackPoll(sid) {
|
|||||||
}
|
}
|
||||||
if (_approvalFallbackPollInFlight) return;
|
if (_approvalFallbackPollInFlight) return;
|
||||||
_approvalFallbackPollInFlight = true;
|
_approvalFallbackPollInFlight = true;
|
||||||
|
const focusEpoch = _promptPollerFocusEpoch;
|
||||||
try {
|
try {
|
||||||
const generation = _approvalPromptGeneration(sid);
|
const generation = _approvalPromptGeneration(sid);
|
||||||
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
|
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.
|
// 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 (typeof _sessionProfileMismatchFromError === 'function' && _sessionProfileMismatchFromError(e)
|
||||||
&& _approvalPollTimer === pollTimer) {
|
&& _approvalPollTimer === pollTimer) {
|
||||||
stopApprovalPolling();
|
// Focus returned mid-request: the cookie may be back, so retry once (after finally).
|
||||||
_approvalProfilePausedSessionId = sid;
|
if (focusEpoch !== _promptPollerFocusEpoch) queueMicrotask(_tick);
|
||||||
|
else { stopApprovalPolling(); _approvalProfilePausedSessionId = sid; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally { if (_approvalPollTimer === pollTimer) _approvalFallbackPollInFlight = false; }
|
finally { if (_approvalPollTimer === pollTimer) _approvalFallbackPollInFlight = false; }
|
||||||
@@ -9337,6 +9342,7 @@ function _startClarifyFallbackPoll(sid) {
|
|||||||
}
|
}
|
||||||
if (_clarifyFallbackPollInFlight) return;
|
if (_clarifyFallbackPollInFlight) return;
|
||||||
_clarifyFallbackPollInFlight = true;
|
_clarifyFallbackPollInFlight = true;
|
||||||
|
const focusEpoch = _promptPollerFocusEpoch;
|
||||||
try {
|
try {
|
||||||
const generation = _clarifyPromptGeneration(sid);
|
const generation = _clarifyPromptGeneration(sid);
|
||||||
const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
|
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.
|
// 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 (typeof _sessionProfileMismatchFromError === "function" && _sessionProfileMismatchFromError(e)) {
|
||||||
if (_clarifyFallbackTimer === pollTimer) {
|
if (_clarifyFallbackTimer === pollTimer) {
|
||||||
stopClarifyPolling();
|
if (focusEpoch !== _promptPollerFocusEpoch) queueMicrotask(_tick);
|
||||||
_clarifyProfilePausedSessionId = sid;
|
else { stopClarifyPolling(); _clarifyProfilePausedSessionId = sid; }
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -9441,6 +9447,7 @@ function stopClarifyPolling() {
|
|||||||
// profile just 409s once and pauses them again.
|
// profile just 409s once and pauses them again.
|
||||||
function _resumeProfilePausedPromptPollers() {
|
function _resumeProfilePausedPromptPollers() {
|
||||||
if (typeof document !== 'undefined' && document.hidden) return;
|
if (typeof document !== 'undefined' && document.hidden) return;
|
||||||
|
_promptPollerFocusEpoch++;
|
||||||
const current = (S.session && S.session.session_id) || null;
|
const current = (S.session && S.session.session_id) || null;
|
||||||
const approvalSid = _approvalProfilePausedSessionId;
|
const approvalSid = _approvalProfilePausedSessionId;
|
||||||
const clarifySid = _clarifyProfilePausedSessionId;
|
const clarifySid = _clarifyProfilePausedSessionId;
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ var _clarifyEventSource = null, _clarifyFallbackTimer = null, _clarifyHealthTime
|
|||||||
var _clarifyFallbackPollInFlight = false, _clarifyPollingSessionId = null, _clarifyMissingEndpointWarned = false;
|
var _clarifyFallbackPollInFlight = false, _clarifyPollingSessionId = null, _clarifyMissingEndpointWarned = false;
|
||||||
var _approvalPendingBySession = new Map();
|
var _approvalPendingBySession = new Map();
|
||||||
var _approvalProfilePausedSessionId = null, _clarifyProfilePausedSessionId = null;
|
var _approvalProfilePausedSessionId = null, _clarifyProfilePausedSessionId = null;
|
||||||
|
var _promptPollerFocusEpoch = 0;
|
||||||
function _approvalPromptGeneration(){ return 0; }
|
function _approvalPromptGeneration(){ return 0; }
|
||||||
function _clarifyPromptGeneration(){ return 0; }
|
function _clarifyPromptGeneration(){ return 0; }
|
||||||
function _approvalPollingSessionMissingOrMismatched(sid){ return !sid || !S.session || S.session.session_id !== sid; }
|
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)
|
out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30)
|
||||||
assert out.returncode == 0, out.stderr
|
assert out.returncode == 0, out.stderr
|
||||||
assert out.stdout == "0"
|
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;
|
const _approvalPromptBelongsToActiveSession=()=>false;
|
||||||
let _approvalSessionId = 's', _approvalCurrentId = 'id';
|
let _approvalSessionId = 's', _approvalCurrentId = 'id';
|
||||||
let _approvalFallbackPollInFlight=false, _approvalPollTimer;
|
let _approvalFallbackPollInFlight=false, _approvalPollTimer;
|
||||||
|
let _promptPollerFocusEpoch=0;
|
||||||
let _clarifyPollingSessionId, _clarifyPollTimer, _clarifyFallbackPollInFlight=false;
|
let _clarifyPollingSessionId, _clarifyPollTimer, _clarifyFallbackPollInFlight=false;
|
||||||
const _promptActiveSessionId=()=> 's';
|
const _promptActiveSessionId=()=> 's';
|
||||||
const _approvalPollingSessionMissingOrMismatched=()=>false;
|
const _approvalPollingSessionMissingOrMismatched=()=>false;
|
||||||
|
|||||||
Reference in new issue
Block a user