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:
parent
ca27d03c60
commit
2ed5be124c
3 changed files with 159 additions and 60 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue