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