repl: timeout the auto-pause snapshot so a non-yielding worker dies

If the C/Python eval was stuck in code without a poll site (sync XHR
inside bend!-call, a builtin that doesn't pass through leval), the
SAB pause flag would set, waitForPending would time out after 5s,
and then portal-snapshot would queue behind the still-busy worker
and never resolve. autoPauseTab hung on that await and never
reached its terminate loop — leaving the worker running while the
user sat on a new tab watching the old loop keep streaming.

Now:
* SAB pause wait drops from 5s to 2s.
* If the eval is still in flight after the pause wait, skip the
  snapshot attempt entirely — the worker isn't yielding and the
  snapshot would just hang the UI.
* Snapshot eval + portal-save bridge are wrapped in withTimeout
  (2s + 1.5s) so even if pause partially worked but snapshot
  stalled, we fall through to the unconditional worker.terminate
  at the bottom of autoPauseTab.
* The hard-cancel branch and the SAB-timeout-fallback now share the
  same pending-rejection loop instead of duplicating; the
  unconditional terminate loop at the end kills the worker.

pauseAndSnapshot (manual ⏸ button) also surfaces the timeout case
with an alert pointing at cancel + reboot instead of locking the UI.
This commit is contained in:
russell@unturf.com 2026-06-15 13:47:20 -04:00
parent ca27d03c60
commit 2ed5be124c
No known key found for this signature in database
3 changed files with 159 additions and 60 deletions

View file

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

View file

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

View file

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