repl: fix cancel + immediate-resubmit race that wedged the new eval
Three converging bugs let a cancel-then-send sequence land in a broken state: 1) cancelAllPendingInActiveTab didn't touch the toolbar — the user had to wait for the cancelled promise's .catch.then to drain before send re-enabled. fox saw clicks register as noops because the button was still disabled. 2) Even after the rejection settled, the stale .then path re-enabled sendBtn from inside the FIRST run's closure — but by then the user had already submitted a SECOND run that set sendBtn disabled. The stale .then clobbered the in-flight state. 3) The OLD worker's last-queued "done" or "error" message could be delivered after a fresh run had already taken state.pending[k]; the OLD handler would then delete the NEW run's pending entry. Fixes: * cancel handler now re-enables send / disables cancel + pause synchronously and clears tab.activeInput. * The completion .then re-checks isEvalInFlight(tab) before resetting toolbar state, so a stale settle from a cancelled run can't override the fresh run's UI. * evalInTier's done/error handler only deletes state.pending[k] when the entry still has the runId we started with. Verified headlessly: long loop → cancel → submit (+ 1 2) immediately → first entry shows error: cancelled, second entry shows result 3, send re-enabled.
This commit is contained in:
parent
049a1e1935
commit
63808f72a5
3 changed files with 102 additions and 30 deletions
|
|
@ -434,11 +434,20 @@ function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
|
||||||
onChunkEol && onChunkEol();
|
onChunkEol && onChunkEol();
|
||||||
} else if (e.data.kind === "done") {
|
} else if (e.data.kind === "done") {
|
||||||
w.removeEventListener("message", handler);
|
w.removeEventListener("message", handler);
|
||||||
delete state.pending[pendingKey];
|
// Only clear pending if it's still our runId — a
|
||||||
|
// cancel-then-resubmit cycle can leave state.pending[k]
|
||||||
|
// pointing at a fresh eval before our terminated worker
|
||||||
|
// gets around to having its last-queued "done" delivered
|
||||||
|
// on the main side.
|
||||||
|
if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) {
|
||||||
|
delete state.pending[pendingKey];
|
||||||
|
}
|
||||||
resolve(e.data.output);
|
resolve(e.data.output);
|
||||||
} else if (e.data.kind === "error") {
|
} else if (e.data.kind === "error") {
|
||||||
w.removeEventListener("message", handler);
|
w.removeEventListener("message", handler);
|
||||||
delete state.pending[pendingKey];
|
if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) {
|
||||||
|
delete state.pending[pendingKey];
|
||||||
|
}
|
||||||
reject(new Error(e.data.message));
|
reject(new Error(e.data.message));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -827,6 +836,15 @@ function cancelAllPendingInActiveTab() {
|
||||||
delete state.pending[k];
|
delete state.pending[k];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Synchronously re-enable send so the user can submit a new job
|
||||||
|
// immediately, without waiting for the cancelled promise chain's
|
||||||
|
// microtasks to drain. The .then below also rechecks
|
||||||
|
// isEvalInFlight so a follow-up sendInput's button state isn't
|
||||||
|
// clobbered when those microtasks eventually fire.
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
pauseBtn.disabled = true;
|
||||||
|
tab.activeInput = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Render ─────────────────────────────────────────────────────────
|
// ─── Render ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -1157,14 +1175,20 @@ async function sendInput() {
|
||||||
saveSoon();
|
saveSoon();
|
||||||
remaining--;
|
remaining--;
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
sendBtn.disabled = false;
|
// Don't clobber the toolbar / activeInput marker if
|
||||||
cancelBtn.disabled = true;
|
// another sendInput started while THIS one was still
|
||||||
pauseBtn.disabled = true;
|
// unwinding. The cancel-then-submit-immediately race
|
||||||
inputEl.focus();
|
// used to leave send re-enabled mid-eval because the
|
||||||
// Eval finished cleanly — drop the activeInput marker so
|
// first run's .then fired after the second's sendInput
|
||||||
// a subsequent tab switch doesn't try to resume something
|
// had already disabled the buttons. Check the live
|
||||||
// that already completed.
|
// pending state instead of trusting "remaining===0".
|
||||||
tab.activeInput = null;
|
if (!isEvalInFlight(tab)) {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
pauseBtn.disabled = true;
|
||||||
|
inputEl.focus();
|
||||||
|
tab.activeInput = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -434,11 +434,20 @@ function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
|
||||||
onChunkEol && onChunkEol();
|
onChunkEol && onChunkEol();
|
||||||
} else if (e.data.kind === "done") {
|
} else if (e.data.kind === "done") {
|
||||||
w.removeEventListener("message", handler);
|
w.removeEventListener("message", handler);
|
||||||
delete state.pending[pendingKey];
|
// Only clear pending if it's still our runId — a
|
||||||
|
// cancel-then-resubmit cycle can leave state.pending[k]
|
||||||
|
// pointing at a fresh eval before our terminated worker
|
||||||
|
// gets around to having its last-queued "done" delivered
|
||||||
|
// on the main side.
|
||||||
|
if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) {
|
||||||
|
delete state.pending[pendingKey];
|
||||||
|
}
|
||||||
resolve(e.data.output);
|
resolve(e.data.output);
|
||||||
} else if (e.data.kind === "error") {
|
} else if (e.data.kind === "error") {
|
||||||
w.removeEventListener("message", handler);
|
w.removeEventListener("message", handler);
|
||||||
delete state.pending[pendingKey];
|
if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) {
|
||||||
|
delete state.pending[pendingKey];
|
||||||
|
}
|
||||||
reject(new Error(e.data.message));
|
reject(new Error(e.data.message));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -827,6 +836,15 @@ function cancelAllPendingInActiveTab() {
|
||||||
delete state.pending[k];
|
delete state.pending[k];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Synchronously re-enable send so the user can submit a new job
|
||||||
|
// immediately, without waiting for the cancelled promise chain's
|
||||||
|
// microtasks to drain. The .then below also rechecks
|
||||||
|
// isEvalInFlight so a follow-up sendInput's button state isn't
|
||||||
|
// clobbered when those microtasks eventually fire.
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
pauseBtn.disabled = true;
|
||||||
|
tab.activeInput = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Render ─────────────────────────────────────────────────────────
|
// ─── Render ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -1157,14 +1175,20 @@ async function sendInput() {
|
||||||
saveSoon();
|
saveSoon();
|
||||||
remaining--;
|
remaining--;
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
sendBtn.disabled = false;
|
// Don't clobber the toolbar / activeInput marker if
|
||||||
cancelBtn.disabled = true;
|
// another sendInput started while THIS one was still
|
||||||
pauseBtn.disabled = true;
|
// unwinding. The cancel-then-submit-immediately race
|
||||||
inputEl.focus();
|
// used to leave send re-enabled mid-eval because the
|
||||||
// Eval finished cleanly — drop the activeInput marker so
|
// first run's .then fired after the second's sendInput
|
||||||
// a subsequent tab switch doesn't try to resume something
|
// had already disabled the buttons. Check the live
|
||||||
// that already completed.
|
// pending state instead of trusting "remaining===0".
|
||||||
tab.activeInput = null;
|
if (!isEvalInFlight(tab)) {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
pauseBtn.disabled = true;
|
||||||
|
inputEl.focus();
|
||||||
|
tab.activeInput = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -434,11 +434,20 @@ function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
|
||||||
onChunkEol && onChunkEol();
|
onChunkEol && onChunkEol();
|
||||||
} else if (e.data.kind === "done") {
|
} else if (e.data.kind === "done") {
|
||||||
w.removeEventListener("message", handler);
|
w.removeEventListener("message", handler);
|
||||||
delete state.pending[pendingKey];
|
// Only clear pending if it's still our runId — a
|
||||||
|
// cancel-then-resubmit cycle can leave state.pending[k]
|
||||||
|
// pointing at a fresh eval before our terminated worker
|
||||||
|
// gets around to having its last-queued "done" delivered
|
||||||
|
// on the main side.
|
||||||
|
if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) {
|
||||||
|
delete state.pending[pendingKey];
|
||||||
|
}
|
||||||
resolve(e.data.output);
|
resolve(e.data.output);
|
||||||
} else if (e.data.kind === "error") {
|
} else if (e.data.kind === "error") {
|
||||||
w.removeEventListener("message", handler);
|
w.removeEventListener("message", handler);
|
||||||
delete state.pending[pendingKey];
|
if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) {
|
||||||
|
delete state.pending[pendingKey];
|
||||||
|
}
|
||||||
reject(new Error(e.data.message));
|
reject(new Error(e.data.message));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -827,6 +836,15 @@ function cancelAllPendingInActiveTab() {
|
||||||
delete state.pending[k];
|
delete state.pending[k];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Synchronously re-enable send so the user can submit a new job
|
||||||
|
// immediately, without waiting for the cancelled promise chain's
|
||||||
|
// microtasks to drain. The .then below also rechecks
|
||||||
|
// isEvalInFlight so a follow-up sendInput's button state isn't
|
||||||
|
// clobbered when those microtasks eventually fire.
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
pauseBtn.disabled = true;
|
||||||
|
tab.activeInput = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Render ─────────────────────────────────────────────────────────
|
// ─── Render ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -1157,14 +1175,20 @@ async function sendInput() {
|
||||||
saveSoon();
|
saveSoon();
|
||||||
remaining--;
|
remaining--;
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
sendBtn.disabled = false;
|
// Don't clobber the toolbar / activeInput marker if
|
||||||
cancelBtn.disabled = true;
|
// another sendInput started while THIS one was still
|
||||||
pauseBtn.disabled = true;
|
// unwinding. The cancel-then-submit-immediately race
|
||||||
inputEl.focus();
|
// used to leave send re-enabled mid-eval because the
|
||||||
// Eval finished cleanly — drop the activeInput marker so
|
// first run's .then fired after the second's sendInput
|
||||||
// a subsequent tab switch doesn't try to resume something
|
// had already disabled the buttons. Check the live
|
||||||
// that already completed.
|
// pending state instead of trusting "remaining===0".
|
||||||
tab.activeInput = null;
|
if (!isEvalInFlight(tab)) {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
pauseBtn.disabled = true;
|
||||||
|
inputEl.focus();
|
||||||
|
tab.activeInput = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue