python tier: SAB-backed pause poll — auto-pause now works on python too

leval() in lumbda.py grows a counter-gated check (every 1024th
iteration) that calls a module-level _lumbda_pause_hook. Native
Python users leave the hook None and the check short-circuits to a
single bitwise AND. The pyodide loader installs a hook that reads
_lumbdaPyPauseRequested (a JS callback over Atomics.load on the
SAB) so the REPL's auto-pause-on-tab-switch flow drops into the
same path on python that it already uses on C.

Also adds setPauseFlag to the python tier's returned object so
runner.setPauseFlag propagates the SAB through worker config.

repl.js's autoPauseTab no longer falls back to hard-cancel when
the active tier is python — the SAB-poll path covers both. Asm
remains on hard-cancel since the WAT tier has no in-eval poll
site yet.

Test suite (tests.py, 571 tests) passes — verified the no-op
hook path doesn't change native eval semantics.
This commit is contained in:
russell@unturf.com 2026-06-15 11:16:50 -04:00
parent df154b9a56
commit 1f2ef730a2
No known key found for this signature in database
11 changed files with 154 additions and 19 deletions

View file

@ -662,9 +662,22 @@ def _define_record_type(a, env):
return VOID
# Cooperative pause hook — set to a zero-arg callable returning a
# truthy value when the eval loop should stop. The pyodide loader
# installs one that reads a SharedArrayBuffer atomic (see
# wasm/python/lumbda-py.js). Native Python users leave it None and
# the check below short-circuits to a counter increment.
_lumbda_pause_hook = None
_lumbda_pause_counter = 0
def leval(expr, env):
"""Evaluate expr in env. Tail-call safe via while loop."""
global _lumbda_pause_counter
while True:
_lumbda_pause_counter = (_lumbda_pause_counter + 1) & 0x7FFFFFFF
if not (_lumbda_pause_counter & 0x3FF) and _lumbda_pause_hook is not None:
if _lumbda_pause_hook():
raise LispErr("paused")
# Self-evaluating atoms
if (expr is NIL or expr is VOID or expr is True or expr is False
or isinstance(expr, (int, float, _EOF, list))

View file

@ -29,7 +29,17 @@ async function _bootstrap() {
// the response text back. Sync XHR is the only sync HTTP available
// in a Web Worker; perfect for the blocking eval model lumbda
// primitives expect.
const refs = { bendUrl: null, currentOnChunk: null };
const refs = { bendUrl: null, currentOnChunk: null, pauseFlag: null };
// Cooperative pause check — lumbda.py polls _lumbdaPyPauseRequested
// every ~1024 leval iterations. Returns true when the REPL has
// signalled a pause via the SharedArrayBuffer atomic (index 0).
// Falls back to false when SAB isn't available (no COOP/COEP),
// turning the poll into a no-op.
globalThis._lumbdaPyPauseRequested = () => {
if (!refs.pauseFlag) return false;
try { return Atomics.load(refs.pauseFlag, 0) ? true : false; }
catch (e) { return false; }
};
// Streaming output bridge — sys.stdout in pyodide is replaced
// (during _lumbda_eval) with a class whose write() calls back
// here. We forward to the current onChunk so the worker can
@ -61,6 +71,10 @@ for _e in lumbda.read_all(lumbda.PRELUDE):
# bend!-call primitive — bridges to JS XHR via globalThis._lumbdaPyBendCall.
# Argument: a string payload (the S-expression text). Returns response text.
# Wire the pause hook so leval polls SAB via the JS callback.
from js import _lumbdaPyPauseRequested as _js_pause_requested
lumbda._lumbda_pause_hook = lambda: bool(_js_pause_requested())
from js import _lumbdaPyBendCall as _js_bend_call
def _bend_call_prim(args, env):
if not args:
@ -149,6 +163,12 @@ def _lumbda_eval(src):
}
},
setBendUrl(url) { refs.bendUrl = url || null; },
// Pause hook — wraps the incoming SAB in an Int32Array so the
// _lumbdaPyPauseRequested callback (installed above) can read
// the atomic. Null disables (no COOP/COEP).
setPauseFlag(flag) {
refs.pauseFlag = flag ? new Int32Array(flag) : null;
},
// Portal save/resume bridge to vault — same shape as the C tier
// loader. Pyodide.FS is the same Emscripten FS, so the path
// /tmp/<name>.portal is reachable from JS exactly as in C.

View file

@ -662,9 +662,22 @@ def _define_record_type(a, env):
return VOID
# Cooperative pause hook — set to a zero-arg callable returning a
# truthy value when the eval loop should stop. The pyodide loader
# installs one that reads a SharedArrayBuffer atomic (see
# wasm/python/lumbda-py.js). Native Python users leave it None and
# the check below short-circuits to a counter increment.
_lumbda_pause_hook = None
_lumbda_pause_counter = 0
def leval(expr, env):
"""Evaluate expr in env. Tail-call safe via while loop."""
global _lumbda_pause_counter
while True:
_lumbda_pause_counter = (_lumbda_pause_counter + 1) & 0x7FFFFFFF
if not (_lumbda_pause_counter & 0x3FF) and _lumbda_pause_hook is not None:
if _lumbda_pause_hook():
raise LispErr("paused")
# Self-evaluating atoms
if (expr is NIL or expr is VOID or expr is True or expr is False
or isinstance(expr, (int, float, _EOF, list))

View file

@ -228,11 +228,12 @@ async function autoPauseTab(tab) {
const activeInput = tab.activeInput || null;
let blob = null;
if (pauseFlagView && tier === "c") {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested
// and unwinds via lisp_error("paused"). Python tier needs a
// settrace-based hook before it can join (deferred). Asm tier
// has no in-eval poll site — falls through to hard cancel.
if (pauseFlagView && (tier === "c" || tier === "python")) {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested,
// Python tier's lumbda.py polls _lumbdaPyPauseRequested. Both
// 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);

View file

@ -29,7 +29,17 @@ async function _bootstrap() {
// the response text back. Sync XHR is the only sync HTTP available
// in a Web Worker; perfect for the blocking eval model lumbda
// primitives expect.
const refs = { bendUrl: null, currentOnChunk: null };
const refs = { bendUrl: null, currentOnChunk: null, pauseFlag: null };
// Cooperative pause check — lumbda.py polls _lumbdaPyPauseRequested
// every ~1024 leval iterations. Returns true when the REPL has
// signalled a pause via the SharedArrayBuffer atomic (index 0).
// Falls back to false when SAB isn't available (no COOP/COEP),
// turning the poll into a no-op.
globalThis._lumbdaPyPauseRequested = () => {
if (!refs.pauseFlag) return false;
try { return Atomics.load(refs.pauseFlag, 0) ? true : false; }
catch (e) { return false; }
};
// Streaming output bridge — sys.stdout in pyodide is replaced
// (during _lumbda_eval) with a class whose write() calls back
// here. We forward to the current onChunk so the worker can
@ -61,6 +71,10 @@ for _e in lumbda.read_all(lumbda.PRELUDE):
# bend!-call primitive — bridges to JS XHR via globalThis._lumbdaPyBendCall.
# Argument: a string payload (the S-expression text). Returns response text.
# Wire the pause hook so leval polls SAB via the JS callback.
from js import _lumbdaPyPauseRequested as _js_pause_requested
lumbda._lumbda_pause_hook = lambda: bool(_js_pause_requested())
from js import _lumbdaPyBendCall as _js_bend_call
def _bend_call_prim(args, env):
if not args:
@ -149,6 +163,12 @@ def _lumbda_eval(src):
}
},
setBendUrl(url) { refs.bendUrl = url || null; },
// Pause hook — wraps the incoming SAB in an Int32Array so the
// _lumbdaPyPauseRequested callback (installed above) can read
// the atomic. Null disables (no COOP/COEP).
setPauseFlag(flag) {
refs.pauseFlag = flag ? new Int32Array(flag) : null;
},
// Portal save/resume bridge to vault — same shape as the C tier
// loader. Pyodide.FS is the same Emscripten FS, so the path
// /tmp/<name>.portal is reachable from JS exactly as in C.

View file

@ -228,11 +228,12 @@ async function autoPauseTab(tab) {
const activeInput = tab.activeInput || null;
let blob = null;
if (pauseFlagView && tier === "c") {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested
// and unwinds via lisp_error("paused"). Python tier needs a
// settrace-based hook before it can join (deferred). Asm tier
// has no in-eval poll site — falls through to hard cancel.
if (pauseFlagView && (tier === "c" || tier === "python")) {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested,
// Python tier's lumbda.py polls _lumbdaPyPauseRequested. Both
// 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);

View file

@ -29,7 +29,17 @@ async function _bootstrap() {
// the response text back. Sync XHR is the only sync HTTP available
// in a Web Worker; perfect for the blocking eval model lumbda
// primitives expect.
const refs = { bendUrl: null, currentOnChunk: null };
const refs = { bendUrl: null, currentOnChunk: null, pauseFlag: null };
// Cooperative pause check — lumbda.py polls _lumbdaPyPauseRequested
// every ~1024 leval iterations. Returns true when the REPL has
// signalled a pause via the SharedArrayBuffer atomic (index 0).
// Falls back to false when SAB isn't available (no COOP/COEP),
// turning the poll into a no-op.
globalThis._lumbdaPyPauseRequested = () => {
if (!refs.pauseFlag) return false;
try { return Atomics.load(refs.pauseFlag, 0) ? true : false; }
catch (e) { return false; }
};
// Streaming output bridge — sys.stdout in pyodide is replaced
// (during _lumbda_eval) with a class whose write() calls back
// here. We forward to the current onChunk so the worker can
@ -61,6 +71,10 @@ for _e in lumbda.read_all(lumbda.PRELUDE):
# bend!-call primitive — bridges to JS XHR via globalThis._lumbdaPyBendCall.
# Argument: a string payload (the S-expression text). Returns response text.
# Wire the pause hook so leval polls SAB via the JS callback.
from js import _lumbdaPyPauseRequested as _js_pause_requested
lumbda._lumbda_pause_hook = lambda: bool(_js_pause_requested())
from js import _lumbdaPyBendCall as _js_bend_call
def _bend_call_prim(args, env):
if not args:
@ -149,6 +163,12 @@ def _lumbda_eval(src):
}
},
setBendUrl(url) { refs.bendUrl = url || null; },
// Pause hook — wraps the incoming SAB in an Int32Array so the
// _lumbdaPyPauseRequested callback (installed above) can read
// the atomic. Null disables (no COOP/COEP).
setPauseFlag(flag) {
refs.pauseFlag = flag ? new Int32Array(flag) : null;
},
// Portal save/resume bridge to vault — same shape as the C tier
// loader. Pyodide.FS is the same Emscripten FS, so the path
// /tmp/<name>.portal is reachable from JS exactly as in C.

View file

@ -662,9 +662,22 @@ def _define_record_type(a, env):
return VOID
# Cooperative pause hook — set to a zero-arg callable returning a
# truthy value when the eval loop should stop. The pyodide loader
# installs one that reads a SharedArrayBuffer atomic (see
# wasm/python/lumbda-py.js). Native Python users leave it None and
# the check below short-circuits to a counter increment.
_lumbda_pause_hook = None
_lumbda_pause_counter = 0
def leval(expr, env):
"""Evaluate expr in env. Tail-call safe via while loop."""
global _lumbda_pause_counter
while True:
_lumbda_pause_counter = (_lumbda_pause_counter + 1) & 0x7FFFFFFF
if not (_lumbda_pause_counter & 0x3FF) and _lumbda_pause_hook is not None:
if _lumbda_pause_hook():
raise LispErr("paused")
# Self-evaluating atoms
if (expr is NIL or expr is VOID or expr is True or expr is False
or isinstance(expr, (int, float, _EOF, list))

View file

@ -29,7 +29,17 @@ async function _bootstrap() {
// the response text back. Sync XHR is the only sync HTTP available
// in a Web Worker; perfect for the blocking eval model lumbda
// primitives expect.
const refs = { bendUrl: null, currentOnChunk: null };
const refs = { bendUrl: null, currentOnChunk: null, pauseFlag: null };
// Cooperative pause check — lumbda.py polls _lumbdaPyPauseRequested
// every ~1024 leval iterations. Returns true when the REPL has
// signalled a pause via the SharedArrayBuffer atomic (index 0).
// Falls back to false when SAB isn't available (no COOP/COEP),
// turning the poll into a no-op.
globalThis._lumbdaPyPauseRequested = () => {
if (!refs.pauseFlag) return false;
try { return Atomics.load(refs.pauseFlag, 0) ? true : false; }
catch (e) { return false; }
};
// Streaming output bridge — sys.stdout in pyodide is replaced
// (during _lumbda_eval) with a class whose write() calls back
// here. We forward to the current onChunk so the worker can
@ -61,6 +71,10 @@ for _e in lumbda.read_all(lumbda.PRELUDE):
# bend!-call primitive — bridges to JS XHR via globalThis._lumbdaPyBendCall.
# Argument: a string payload (the S-expression text). Returns response text.
# Wire the pause hook so leval polls SAB via the JS callback.
from js import _lumbdaPyPauseRequested as _js_pause_requested
lumbda._lumbda_pause_hook = lambda: bool(_js_pause_requested())
from js import _lumbdaPyBendCall as _js_bend_call
def _bend_call_prim(args, env):
if not args:
@ -149,6 +163,12 @@ def _lumbda_eval(src):
}
},
setBendUrl(url) { refs.bendUrl = url || null; },
// Pause hook — wraps the incoming SAB in an Int32Array so the
// _lumbdaPyPauseRequested callback (installed above) can read
// the atomic. Null disables (no COOP/COEP).
setPauseFlag(flag) {
refs.pauseFlag = flag ? new Int32Array(flag) : null;
},
// Portal save/resume bridge to vault — same shape as the C tier
// loader. Pyodide.FS is the same Emscripten FS, so the path
// /tmp/<name>.portal is reachable from JS exactly as in C.

View file

@ -662,9 +662,22 @@ def _define_record_type(a, env):
return VOID
# Cooperative pause hook — set to a zero-arg callable returning a
# truthy value when the eval loop should stop. The pyodide loader
# installs one that reads a SharedArrayBuffer atomic (see
# wasm/python/lumbda-py.js). Native Python users leave it None and
# the check below short-circuits to a counter increment.
_lumbda_pause_hook = None
_lumbda_pause_counter = 0
def leval(expr, env):
"""Evaluate expr in env. Tail-call safe via while loop."""
global _lumbda_pause_counter
while True:
_lumbda_pause_counter = (_lumbda_pause_counter + 1) & 0x7FFFFFFF
if not (_lumbda_pause_counter & 0x3FF) and _lumbda_pause_hook is not None:
if _lumbda_pause_hook():
raise LispErr("paused")
# Self-evaluating atoms
if (expr is NIL or expr is VOID or expr is True or expr is False
or isinstance(expr, (int, float, _EOF, list))

View file

@ -228,11 +228,12 @@ async function autoPauseTab(tab) {
const activeInput = tab.activeInput || null;
let blob = null;
if (pauseFlagView && tier === "c") {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested
// and unwinds via lisp_error("paused"). Python tier needs a
// settrace-based hook before it can join (deferred). Asm tier
// has no in-eval poll site — falls through to hard cancel.
if (pauseFlagView && (tier === "c" || tier === "python")) {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested,
// Python tier's lumbda.py polls _lumbdaPyPauseRequested. Both
// 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);