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:
russell@unturf.com 2026-06-15 14:11:55 -04:00
parent 049a1e1935
commit 63808f72a5
No known key found for this signature in database
3 changed files with 102 additions and 30 deletions

View file

@ -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;
}
} }
}); });
} }

View file

@ -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;
}
} }
}); });
} }

View file

@ -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;
}
} }
}); });
} }