diff --git a/wasm/dist-repl/repl.js b/wasm/dist-repl/repl.js index ab00233..1ba93d3 100644 --- a/wasm/dist-repl/repl.js +++ b/wasm/dist-repl/repl.js @@ -244,6 +244,18 @@ async function waitForPending(tabId, tier, timeoutMs) { } } +// Race a promise against a wall-clock timeout. Returns null when the +// promise didn't settle in time so the caller can move on rather than +// hang. Used in autoPauseTab so a worker that won't yield to the +// pause flag (e.g. stuck in sync XHR, or eval-loop poll never fires +// because we're outside the C poll site) can still be terminated. +function withTimeout(p, ms) { + return Promise.race([ + p.catch(() => null), + new Promise((r) => setTimeout(() => r(null), ms)), + ]); +} + // Auto-pause hook for tab-switch-away. The tier may be deep inside a // (let loop ...) — set the SAB pause atomic, wait for the eval-loop // poll to raise lisp_error("paused"), then snapshot env via the @@ -261,32 +273,44 @@ async function autoPauseTab(tab) { // raise their own "paused" error (lisp_error / LispErr) so the // module-global env survives intact. Asm tier has no in-eval // poll site — falls through to hard cancel. - // Signal the eval loop and wait for it to settle. Atomics.store(pauseFlagView, 0, 1); - await waitForPending(tab.id, tier, 5000); + await waitForPending(tab.id, tier, 2000); Atomics.store(pauseFlagView, 0, 0); - // Snapshot the (now-idle) tier's env to /tmp and pull the - // blob across the worker bridge. portal-snapshot! is the - // same lisp the manual save flow uses. - const snapName = `_autopause_t${tab.id}`; - try { - await evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`); - const w = state.workers[workerKey(tab.id, tier)]; - if (w) { - const runId = state.nextRunId++; - const reply = await bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save"); - if (reply && reply.blob) blob = reply.blob; - } - } catch (e) { /* no blob — resume will re-eval from scratch */ } - } else if (isEvalInFlight(tab)) { - // No SAB or asm tier — hard-cancel the in-flight eval(s). + // If the eval is still in flight after 2s, the worker isn't + // yielding to the SAB flag (stuck in sync XHR, native builtin + // without a yield point, etc). Skip the snapshot attempt and + // drop straight to the unconditional terminate below — the + // user gets the cancel-and-replay fallback instead of a hang. + if (!isEvalInFlight(tab)) { + const snapName = `_autopause_t${tab.id}`; + try { + const snapOk = await withTimeout( + evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`), + 2000, + ); + if (snapOk !== null) { + const w = state.workers[workerKey(tab.id, tier)]; + if (w) { + const runId = state.nextRunId++; + const reply = await withTimeout( + bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save"), + 1500, + ); + if (reply && reply.blob) blob = reply.blob; + } + } + } catch (e) { /* no blob — resume will re-eval from scratch */ } + } + } + if (isEvalInFlight(tab)) { + // SAB unavailable, asm tier, OR SAB pause timed out without + // the eval unwinding — hard-cancel the in-flight eval(s) so + // the unconditional terminate loop below actually kills them. for (const k of Object.keys(state.pending)) { if (!k.startsWith(tab.id + ":")) continue; try { state.pending[k].reject(new Error("tab paused")); } catch {} delete state.pending[k]; - try { state.workers[k].terminate(); } catch {} - delete state.workers[k]; } } @@ -718,8 +742,17 @@ async function pauseAndSnapshot() { } pauseBtn.disabled = true; Atomics.store(pauseFlagView, 0, 1); - await waitForPending(tab.id, tier, 5000); + await waitForPending(tab.id, tier, 2000); Atomics.store(pauseFlagView, 0, 0); + if (isEvalInFlight(tab)) { + // The worker didn't yield (stuck in sync XHR, native builtin + // without a poll site, etc). Bail out cleanly rather than + // trying to snapshot on a still-busy worker — that would + // queue portal-snapshot! behind the running eval and lock the + // UI until the eval finishes on its own. + alert("pause request timed out — the eval is in code without a yield point. cancel + reboot if you need to stop it."); + return; + } // Reset activeInput marker so a follow-up tab switch doesn't try // to "auto-resume" what the user has now explicitly saved. tab.activeInput = null; diff --git a/wasm/repl/repl.js b/wasm/repl/repl.js index ab00233..1ba93d3 100644 --- a/wasm/repl/repl.js +++ b/wasm/repl/repl.js @@ -244,6 +244,18 @@ async function waitForPending(tabId, tier, timeoutMs) { } } +// Race a promise against a wall-clock timeout. Returns null when the +// promise didn't settle in time so the caller can move on rather than +// hang. Used in autoPauseTab so a worker that won't yield to the +// pause flag (e.g. stuck in sync XHR, or eval-loop poll never fires +// because we're outside the C poll site) can still be terminated. +function withTimeout(p, ms) { + return Promise.race([ + p.catch(() => null), + new Promise((r) => setTimeout(() => r(null), ms)), + ]); +} + // Auto-pause hook for tab-switch-away. The tier may be deep inside a // (let loop ...) — set the SAB pause atomic, wait for the eval-loop // poll to raise lisp_error("paused"), then snapshot env via the @@ -261,32 +273,44 @@ async function autoPauseTab(tab) { // raise their own "paused" error (lisp_error / LispErr) so the // module-global env survives intact. Asm tier has no in-eval // poll site — falls through to hard cancel. - // Signal the eval loop and wait for it to settle. Atomics.store(pauseFlagView, 0, 1); - await waitForPending(tab.id, tier, 5000); + await waitForPending(tab.id, tier, 2000); Atomics.store(pauseFlagView, 0, 0); - // Snapshot the (now-idle) tier's env to /tmp and pull the - // blob across the worker bridge. portal-snapshot! is the - // same lisp the manual save flow uses. - const snapName = `_autopause_t${tab.id}`; - try { - await evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`); - const w = state.workers[workerKey(tab.id, tier)]; - if (w) { - const runId = state.nextRunId++; - const reply = await bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save"); - if (reply && reply.blob) blob = reply.blob; - } - } catch (e) { /* no blob — resume will re-eval from scratch */ } - } else if (isEvalInFlight(tab)) { - // No SAB or asm tier — hard-cancel the in-flight eval(s). + // If the eval is still in flight after 2s, the worker isn't + // yielding to the SAB flag (stuck in sync XHR, native builtin + // without a yield point, etc). Skip the snapshot attempt and + // drop straight to the unconditional terminate below — the + // user gets the cancel-and-replay fallback instead of a hang. + if (!isEvalInFlight(tab)) { + const snapName = `_autopause_t${tab.id}`; + try { + const snapOk = await withTimeout( + evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`), + 2000, + ); + if (snapOk !== null) { + const w = state.workers[workerKey(tab.id, tier)]; + if (w) { + const runId = state.nextRunId++; + const reply = await withTimeout( + bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save"), + 1500, + ); + if (reply && reply.blob) blob = reply.blob; + } + } + } catch (e) { /* no blob — resume will re-eval from scratch */ } + } + } + if (isEvalInFlight(tab)) { + // SAB unavailable, asm tier, OR SAB pause timed out without + // the eval unwinding — hard-cancel the in-flight eval(s) so + // the unconditional terminate loop below actually kills them. for (const k of Object.keys(state.pending)) { if (!k.startsWith(tab.id + ":")) continue; try { state.pending[k].reject(new Error("tab paused")); } catch {} delete state.pending[k]; - try { state.workers[k].terminate(); } catch {} - delete state.workers[k]; } } @@ -718,8 +742,17 @@ async function pauseAndSnapshot() { } pauseBtn.disabled = true; Atomics.store(pauseFlagView, 0, 1); - await waitForPending(tab.id, tier, 5000); + await waitForPending(tab.id, tier, 2000); Atomics.store(pauseFlagView, 0, 0); + if (isEvalInFlight(tab)) { + // The worker didn't yield (stuck in sync XHR, native builtin + // without a poll site, etc). Bail out cleanly rather than + // trying to snapshot on a still-busy worker — that would + // queue portal-snapshot! behind the running eval and lock the + // UI until the eval finishes on its own. + alert("pause request timed out — the eval is in code without a yield point. cancel + reboot if you need to stop it."); + return; + } // Reset activeInput marker so a follow-up tab switch doesn't try // to "auto-resume" what the user has now explicitly saved. tab.activeInput = null; diff --git a/www/repl/repl.js b/www/repl/repl.js index ab00233..1ba93d3 100644 --- a/www/repl/repl.js +++ b/www/repl/repl.js @@ -244,6 +244,18 @@ async function waitForPending(tabId, tier, timeoutMs) { } } +// Race a promise against a wall-clock timeout. Returns null when the +// promise didn't settle in time so the caller can move on rather than +// hang. Used in autoPauseTab so a worker that won't yield to the +// pause flag (e.g. stuck in sync XHR, or eval-loop poll never fires +// because we're outside the C poll site) can still be terminated. +function withTimeout(p, ms) { + return Promise.race([ + p.catch(() => null), + new Promise((r) => setTimeout(() => r(null), ms)), + ]); +} + // Auto-pause hook for tab-switch-away. The tier may be deep inside a // (let loop ...) — set the SAB pause atomic, wait for the eval-loop // poll to raise lisp_error("paused"), then snapshot env via the @@ -261,32 +273,44 @@ async function autoPauseTab(tab) { // raise their own "paused" error (lisp_error / LispErr) so the // module-global env survives intact. Asm tier has no in-eval // poll site — falls through to hard cancel. - // Signal the eval loop and wait for it to settle. Atomics.store(pauseFlagView, 0, 1); - await waitForPending(tab.id, tier, 5000); + await waitForPending(tab.id, tier, 2000); Atomics.store(pauseFlagView, 0, 0); - // Snapshot the (now-idle) tier's env to /tmp and pull the - // blob across the worker bridge. portal-snapshot! is the - // same lisp the manual save flow uses. - const snapName = `_autopause_t${tab.id}`; - try { - await evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`); - const w = state.workers[workerKey(tab.id, tier)]; - if (w) { - const runId = state.nextRunId++; - const reply = await bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save"); - if (reply && reply.blob) blob = reply.blob; - } - } catch (e) { /* no blob — resume will re-eval from scratch */ } - } else if (isEvalInFlight(tab)) { - // No SAB or asm tier — hard-cancel the in-flight eval(s). + // If the eval is still in flight after 2s, the worker isn't + // yielding to the SAB flag (stuck in sync XHR, native builtin + // without a yield point, etc). Skip the snapshot attempt and + // drop straight to the unconditional terminate below — the + // user gets the cancel-and-replay fallback instead of a hang. + if (!isEvalInFlight(tab)) { + const snapName = `_autopause_t${tab.id}`; + try { + const snapOk = await withTimeout( + evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`), + 2000, + ); + if (snapOk !== null) { + const w = state.workers[workerKey(tab.id, tier)]; + if (w) { + const runId = state.nextRunId++; + const reply = await withTimeout( + bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save"), + 1500, + ); + if (reply && reply.blob) blob = reply.blob; + } + } + } catch (e) { /* no blob — resume will re-eval from scratch */ } + } + } + if (isEvalInFlight(tab)) { + // SAB unavailable, asm tier, OR SAB pause timed out without + // the eval unwinding — hard-cancel the in-flight eval(s) so + // the unconditional terminate loop below actually kills them. for (const k of Object.keys(state.pending)) { if (!k.startsWith(tab.id + ":")) continue; try { state.pending[k].reject(new Error("tab paused")); } catch {} delete state.pending[k]; - try { state.workers[k].terminate(); } catch {} - delete state.workers[k]; } } @@ -718,8 +742,17 @@ async function pauseAndSnapshot() { } pauseBtn.disabled = true; Atomics.store(pauseFlagView, 0, 1); - await waitForPending(tab.id, tier, 5000); + await waitForPending(tab.id, tier, 2000); Atomics.store(pauseFlagView, 0, 0); + if (isEvalInFlight(tab)) { + // The worker didn't yield (stuck in sync XHR, native builtin + // without a poll site, etc). Bail out cleanly rather than + // trying to snapshot on a still-busy worker — that would + // queue portal-snapshot! behind the running eval and lock the + // UI until the eval finishes on its own. + alert("pause request timed out — the eval is in code without a yield point. cancel + reboot if you need to stop it."); + return; + } // Reset activeInput marker so a follow-up tab switch doesn't try // to "auto-resume" what the user has now explicitly saved. tab.activeInput = null;