From 63808f72a5882aab73ca1135f990292b1d0a621e Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 15 Jun 2026 14:11:55 -0400 Subject: [PATCH] repl: fix cancel + immediate-resubmit race that wedged the new eval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- wasm/dist-repl/repl.js | 44 ++++++++++++++++++++++++++++++++---------- wasm/repl/repl.js | 44 ++++++++++++++++++++++++++++++++---------- www/repl/repl.js | 44 ++++++++++++++++++++++++++++++++---------- 3 files changed, 102 insertions(+), 30 deletions(-) diff --git a/wasm/dist-repl/repl.js b/wasm/dist-repl/repl.js index 15438fb..9ab99d0 100644 --- a/wasm/dist-repl/repl.js +++ b/wasm/dist-repl/repl.js @@ -434,11 +434,20 @@ function evalInTier(tabId, tier, src, onChunkText, onChunkEol) { onChunkEol && onChunkEol(); } else if (e.data.kind === "done") { 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); } else if (e.data.kind === "error") { 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)); } }; @@ -827,6 +836,15 @@ function cancelAllPendingInActiveTab() { 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 ───────────────────────────────────────────────────────── @@ -1157,14 +1175,20 @@ async function sendInput() { saveSoon(); remaining--; if (remaining === 0) { - sendBtn.disabled = false; - cancelBtn.disabled = true; - pauseBtn.disabled = true; - inputEl.focus(); - // Eval finished cleanly — drop the activeInput marker so - // a subsequent tab switch doesn't try to resume something - // that already completed. - tab.activeInput = null; + // Don't clobber the toolbar / activeInput marker if + // another sendInput started while THIS one was still + // unwinding. The cancel-then-submit-immediately race + // used to leave send re-enabled mid-eval because the + // first run's .then fired after the second's sendInput + // had already disabled the buttons. Check the live + // pending state instead of trusting "remaining===0". + if (!isEvalInFlight(tab)) { + sendBtn.disabled = false; + cancelBtn.disabled = true; + pauseBtn.disabled = true; + inputEl.focus(); + tab.activeInput = null; + } } }); } diff --git a/wasm/repl/repl.js b/wasm/repl/repl.js index 15438fb..9ab99d0 100644 --- a/wasm/repl/repl.js +++ b/wasm/repl/repl.js @@ -434,11 +434,20 @@ function evalInTier(tabId, tier, src, onChunkText, onChunkEol) { onChunkEol && onChunkEol(); } else if (e.data.kind === "done") { 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); } else if (e.data.kind === "error") { 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)); } }; @@ -827,6 +836,15 @@ function cancelAllPendingInActiveTab() { 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 ───────────────────────────────────────────────────────── @@ -1157,14 +1175,20 @@ async function sendInput() { saveSoon(); remaining--; if (remaining === 0) { - sendBtn.disabled = false; - cancelBtn.disabled = true; - pauseBtn.disabled = true; - inputEl.focus(); - // Eval finished cleanly — drop the activeInput marker so - // a subsequent tab switch doesn't try to resume something - // that already completed. - tab.activeInput = null; + // Don't clobber the toolbar / activeInput marker if + // another sendInput started while THIS one was still + // unwinding. The cancel-then-submit-immediately race + // used to leave send re-enabled mid-eval because the + // first run's .then fired after the second's sendInput + // had already disabled the buttons. Check the live + // pending state instead of trusting "remaining===0". + if (!isEvalInFlight(tab)) { + sendBtn.disabled = false; + cancelBtn.disabled = true; + pauseBtn.disabled = true; + inputEl.focus(); + tab.activeInput = null; + } } }); } diff --git a/www/repl/repl.js b/www/repl/repl.js index 15438fb..9ab99d0 100644 --- a/www/repl/repl.js +++ b/www/repl/repl.js @@ -434,11 +434,20 @@ function evalInTier(tabId, tier, src, onChunkText, onChunkEol) { onChunkEol && onChunkEol(); } else if (e.data.kind === "done") { 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); } else if (e.data.kind === "error") { 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)); } }; @@ -827,6 +836,15 @@ function cancelAllPendingInActiveTab() { 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 ───────────────────────────────────────────────────────── @@ -1157,14 +1175,20 @@ async function sendInput() { saveSoon(); remaining--; if (remaining === 0) { - sendBtn.disabled = false; - cancelBtn.disabled = true; - pauseBtn.disabled = true; - inputEl.focus(); - // Eval finished cleanly — drop the activeInput marker so - // a subsequent tab switch doesn't try to resume something - // that already completed. - tab.activeInput = null; + // Don't clobber the toolbar / activeInput marker if + // another sendInput started while THIS one was still + // unwinding. The cancel-then-submit-immediately race + // used to leave send re-enabled mid-eval because the + // first run's .then fired after the second's sendInput + // had already disabled the buttons. Check the live + // pending state instead of trusting "remaining===0". + if (!isEvalInFlight(tab)) { + sendBtn.disabled = false; + cancelBtn.disabled = true; + pauseBtn.disabled = true; + inputEl.focus(); + tab.activeInput = null; + } } }); }