From 1f2ef730a221ba191d9a7ac8383d91b32694849e Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 15 Jun 2026 11:16:50 -0400 Subject: [PATCH] =?UTF-8?q?python=20tier:=20SAB-backed=20pause=20poll=20?= =?UTF-8?q?=E2=80=94=20auto-pause=20now=20works=20on=20python=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lumbda.py | 13 +++++++++++++ wasm/dist-repl/python/lumbda-py.js | 22 +++++++++++++++++++++- wasm/dist-repl/python/lumbda.py | 13 +++++++++++++ wasm/dist-repl/repl.js | 11 ++++++----- wasm/python/lumbda-py.js | 22 +++++++++++++++++++++- wasm/repl/repl.js | 11 ++++++----- www/playground/python/lumbda-py.js | 22 +++++++++++++++++++++- www/playground/python/lumbda.py | 13 +++++++++++++ www/repl/python/lumbda-py.js | 22 +++++++++++++++++++++- www/repl/python/lumbda.py | 13 +++++++++++++ www/repl/repl.js | 11 ++++++----- 11 files changed, 154 insertions(+), 19 deletions(-) diff --git a/lumbda.py b/lumbda.py index 77d5433..7ce472e 100644 --- a/lumbda.py +++ b/lumbda.py @@ -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)) diff --git a/wasm/dist-repl/python/lumbda-py.js b/wasm/dist-repl/python/lumbda-py.js index 34aff77..0e7277c 100644 --- a/wasm/dist-repl/python/lumbda-py.js +++ b/wasm/dist-repl/python/lumbda-py.js @@ -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/.portal is reachable from JS exactly as in C. diff --git a/wasm/dist-repl/python/lumbda.py b/wasm/dist-repl/python/lumbda.py index 77d5433..7ce472e 100644 --- a/wasm/dist-repl/python/lumbda.py +++ b/wasm/dist-repl/python/lumbda.py @@ -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)) diff --git a/wasm/dist-repl/repl.js b/wasm/dist-repl/repl.js index 5d9314b..1ab7f7f 100644 --- a/wasm/dist-repl/repl.js +++ b/wasm/dist-repl/repl.js @@ -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); diff --git a/wasm/python/lumbda-py.js b/wasm/python/lumbda-py.js index 34aff77..0e7277c 100644 --- a/wasm/python/lumbda-py.js +++ b/wasm/python/lumbda-py.js @@ -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/.portal is reachable from JS exactly as in C. diff --git a/wasm/repl/repl.js b/wasm/repl/repl.js index 5d9314b..1ab7f7f 100644 --- a/wasm/repl/repl.js +++ b/wasm/repl/repl.js @@ -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); diff --git a/www/playground/python/lumbda-py.js b/www/playground/python/lumbda-py.js index 34aff77..0e7277c 100644 --- a/www/playground/python/lumbda-py.js +++ b/www/playground/python/lumbda-py.js @@ -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/.portal is reachable from JS exactly as in C. diff --git a/www/playground/python/lumbda.py b/www/playground/python/lumbda.py index 77d5433..7ce472e 100644 --- a/www/playground/python/lumbda.py +++ b/www/playground/python/lumbda.py @@ -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)) diff --git a/www/repl/python/lumbda-py.js b/www/repl/python/lumbda-py.js index 34aff77..0e7277c 100644 --- a/www/repl/python/lumbda-py.js +++ b/www/repl/python/lumbda-py.js @@ -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/.portal is reachable from JS exactly as in C. diff --git a/www/repl/python/lumbda.py b/www/repl/python/lumbda.py index 77d5433..7ce472e 100644 --- a/www/repl/python/lumbda.py +++ b/www/repl/python/lumbda.py @@ -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)) diff --git a/www/repl/repl.js b/www/repl/repl.js index 5d9314b..1ab7f7f 100644 --- a/www/repl/repl.js +++ b/www/repl/repl.js @@ -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);