Problem
input() and time.sleep() must block a synchronous Python stack in the worker until the main thread supplies a value. Today this goes through three unmaintained packages: sync-message (0.0.12), comsync (0.0.9) and pyodide-worker-runner (1.4.0). sync-message picks one of two mechanisms:
- Atomics:
SharedArrayBuffer + Atomics.wait. Requires COOP/COEP. Dodona embeds third-party iframes, so COOP/COEP is permanently off the table and this path never runs there.
- Service worker: the worker issues a synchronous XMLHttpRequest that a service worker intercepts and holds until the main thread POSTs the input. This is what runs in production on Dodona, for every browser.
The service-worker path is a live reliability liability:
- When the SW is not controlling the page (eviction, browser update, multiple tabs racing on registration, private mode quirks), the student gets a traceback telling them to "Try closing all this site's tabs, then reopening. If that doesn't work, try using a different browser." (
python_runner's PyodideRunner.ServiceWorkerError). Papyros string-matches tracebacks to detect this and report it to Sentry (src/frontend/state/InputOutput.ts:163-176).
- Dodona has to serve the SW bundle from a dedicated Rails route (
config/routes.rb:315) and hard-fails scratchpad startup with a visible alert when registration fails (coding_scratchpad.ts:66-79).
- Sentry (dodona org, last 90 days) shows the whole family, ~13 events across ~11 users:
Received status 404 from /__SyncMessageServiceWorkerInput__/write (DODONA-FRONTEND-FQ, 3 users), Service worker for reading input was not available (DODONA-FRONTEND-298), Error launching papyros after registering service worker (DODONA-FRONTEND-297), and SW-registration failures (DODONA-FRONTEND-27R, 5 users; DODONA-FRONTEND-2AM). Low absolute counts, but each one is a student who could not run code, and the failure is unrecoverable without closing every tab.
- Sync XHR is deprecated platform-wide; the whole mechanism is a hack living on borrowed time.
WebAssembly JSPI (WebAssembly.Suspending / WebAssembly.promising) removes the need for any of this on supporting browsers: Pyodide's pyodide.ffi.run_sync(awaitable) suspends the wasm stack until a JS promise resolves. Shipped in Chrome 137+, Firefox from 153 (flagged 139–152), and Safari 27 (announced for the beta at WWDC26, confirmed working on a real Safari 27 on 2026-08-04). Every current engine now supports it, so the channels below are for older browsers and for the JavaScript backend.
This issue proposes adding JSPI as a third input transport and vendoring the external packages into Papyros while doing it.
Findings
All experiments ran in the repo's own vitest browser mode: headless Chromium 151.0.7922.34 (Playwright 1.61.1), Pyodide 314.0.2 (the pinned ^314.0.0), no COOP/COEP (so no SharedArrayBuffer), service worker registered as in production. Baseline before any change: 8 files / 77 tests green.
Legend: [ran] = observed in a test run, [read] = concluded from reading source, [assume] = not verified.
1. The crux: run_sync works inside run_async with no change to the JS call site — [ran]
Papyros.run_async is an async def, called from JS via this.papyros?.run_async.callKwargs(...) (src/backend/workers/python/PythonWorker.ts:93). The question was whether that call already runs on a suspender stack, as hinted by pyodide/ffi.d.ts:747 ("Not needed if the callee is an async Python function").
It does. Observed in isolated tests (JspiExperiment.test.ts, 11/11 green) and end-to-end (below):
can_run_sync() is True inside an async def entered via a plain PyProxy call; False inside a plain sync call; True again when the sync function is entered via callPromising().
run_sync(js_promise) returns the resolved value inside an async def called normally — including when the run_sync call sits deep in a synchronous stack (async def → exec() → sync callback), which is exactly Papyros' shape: run_async → execute/JSONTracer → user code → input() → callback.
- No
callPromisingKwargs fallback is needed. [ran] Pyodide 314 auto-detects JSPI at load (jspiSupported = "Suspending" in WebAssembly || "Suspender" in WebAssembly in pyodide.asm.mjs); no loader flag required. [read]
So the port collapses to: let the JS input callback return a Promise, and run_sync it in papyros.py.
2. End-to-end prototype in the real worker stack — [ran]
An ~85-line prototype (diff attached below) was validated with 8 end-to-end tests through the real Comlink/worker/python_runner stack, and the full suite (96 tests incl. the experiments) is green with it applied:
| Scenario |
Result |
| Python worker detects JSPI (probe below) |
true in Chromium 151 |
| JS worker reports no JSPI |
false, keeps SW path |
input() via promise + run_sync |
works, no channel involved |
3 × input() in a loop |
works |
time.sleep(2) via promise |
works, correct duration |
Debug mode: JSONTracer frames across an input() suspend |
works — 5 frames, correct line numbers, entered value visible in later frames' globals |
Interrupt while blocked on input() |
works without terminating the worker: rejecting the pending promise surfaces as JsException with .js_error.type == "InterruptError", which python_runner.PyodideRunner.pyodide_error already maps to KeyboardInterrupt; no stderr output, same worker immediately runs the next program |
| Interrupt of a busy loop |
still requires terminate + relaunch (unchanged; JSPI does not help here, setInterruptBuffer still needs SAB) |
The debug-mode result matters: bdb/sys.settrace tracing survives a JSPI suspend point mid-script. This was the sharpest risk and it did not materialize.
The prototype changed 4 files:
src/backend/Backend.ts:80-88 — the seam: onEvent returns a Promise for Input/Sleep when in JSPI mode, plus Comlink-exposed receiveInput/interruptInput resolving/rejecting the pending promise.
src/backend/workers/python/PythonWorker.ts — detection probe after launch.
src/backend/workers/python/papyros/papyros.py:78-83 — runner_callback wraps the callback result: if hasattr(res, "then"): return run_sync(res).
src/frontend/state/Runner.ts — provideInput/stop route to the Comlink methods when the backend reported JSPI.
No change to python_runner was needed: PatchedStdinRunner.readline requires the callback to return str synchronously, and the run_sync wrap in papyros.py satisfies that. Its error mapping (e.js_error.type → KeyboardInterrupt) works unchanged for rejected promises. [ran] python_runner stays an external dependency.
3. Feature detection — [ran]
The authoritative check is Pyodide's own can_run_sync(), observed from inside an async def entered via a plain PyProxy call — i.e. the exact calling convention of run_async:
async def __papyros_jspi_check():
from pyodide.ffi import can_run_sync
return can_run_sync()
Run once in the worker after Pyodide loads; the awaited result decides the transport. This is end-to-end (covers browser support and Pyodide build support and the calling convention) and is not UA sniffing, so flagged-Firefox 139–152 and future Safari resolve correctly by construction. typeof WebAssembly.Suspending === "function" is the right main-thread pre-check (it is exactly what Pyodide tests), useful before a worker exists — e.g. to decide whether SW registration failure is fatal.
4. Things learned the hard way
- The interrupt decision must be made from main-thread state. First prototype asked the worker "do you have a pending input?" over Comlink before choosing interrupt strategy — deadlock on a busy-looping worker, which never services messages. [ran] The client must track "awaiting input" itself (comsync already has this state machine, driven by a status callback the worker fires before blocking; the JSPI path must keep firing it). Conversely, delivering input to a JSPI-suspended worker over Comlink is fine: the worker's event loop runs while the stack is suspended (verified: JS→Python reentry during suspension works [ran]).
- Interrupt during traced (debug-mode) input is swallowed by
JSONTracer — the bare except: in _runscript records it as uncaught_exception and completes normally. [ran] This is identical to the current sync path's behaviour (same bare except: catches the KeyboardInterrupt) [read], so no regression; noted for completeness.
- Sleep interruption needs a rejectable timer. The prototype's
new Promise(r => setTimeout(r, ms)) for Sleep cannot be interrupted, so stopping during a JSPI time.sleep falls back to terminate+relaunch — a regression vs. the sync path, where "sleeping" is cheaply interruptible. [read + reasoned; not separately run] The real implementation must keep a pending-message registry covering both input and sleep, and signal reading/sleeping status like syncExpose does.
- Latent bug, confirmed by reading:
BackendManager's static block creates a throwaway channel at import time (src/communication/BackendManager.ts:120) that Papyros.configureInput (src/frontend/state/Papyros.ts:71-78) later replaces. Any backend created before launch() would capture the wrong channel. Not live today (backends are created lazily after configureInput), but it should die in the refactor.
Proposed architecture
One concept: a message transport with a sync worker side (from Python's perspective) and an async main-thread side. Three implementations:
| Transport |
Worker-side readMessage |
Main-thread delivery |
Interrupt while waiting |
Who uses it |
atomics |
Atomics.wait on SAB (sync) |
write into SAB |
channel write |
COOP/COEP embedders (not Dodona) |
serviceWorker |
sync XHR held by SW |
POST via SW |
channel write |
Python on browsers without stack switching; JavaScript backend everywhere |
jspi |
return Promise; Python wraps in run_sync |
Comlink call resolving the pending promise |
Comlink call rejecting it (error .type = "InterruptError") |
Python on Chrome/Firefox |
Key structural points:
- The seam stays where it is:
Backend.onEvent (src/backend/Backend.ts:80-88) already funnels Input/Sleep to extras.readMessage()/extras.syncSleep(). The extras object becomes transport-backed; in JSPI mode those calls return Promises and register a pending resolver keyed by the message sequence, and the status callback (reading/sleeping) keeps firing so the client state machine works identically across all three transports.
- Channel selection is unchanged (SAB → atomics, else service worker): the channel must still exist for the JavaScript backend, which can never use JSPI (
prompt() at src/backend/workers/javascript/JavaScriptWorker.ts:45 is called from synchronous user JS with no wasm on the stack). JSPI is a per-backend overlay, decided in the worker by the can_run_sync probe and reported to the client once at launch. All three configurations are permanent, not transitional.
SyncClient.writeMessage / interrupt route by transport: same public API, delivery switches between channel write and Comlink call. Interrupt policy from client state: awaitingMessage/sleeping → cheap transport interrupt; running → interrupt buffer if SAB, else terminate+relaunch (unchanged).
- Python side: the only change is the
run_sync unwrap in papyros.py's runner_callback (a value in the sync transports, a thenable in JSPI). python_runner is untouched.
- Registration becomes lazy (revised, see phase 4): the service worker is only registered when something actually needs the channel.
SyncClient.channel is a public property read at call time by _writeMessage, so it can be assigned after the client is constructed. That means Python can launch with a null channel, and registration happens only if the JSPI probe comes back false or the user selects JavaScript. On Chrome and Firefox, Dodona registers no service worker at all. Today a failed registration is fatal for everything (Papyros.ts:31-32 skips runner.launch() entirely, plus the Dodona alert), even though Python on those browsers never touches it.
Vendoring recommendation: yes — sync-message, comsync, pyodide-worker-runner; not python_runner
The JSPI transport cannot be added inside the current stack without forking it anyway: comsync.syncExpose hardcodes sync readMessage, PyodideClient hardcodes channel writes, and all three packages are effectively unmaintained (0.0.x versions, years dormant). ~975 lines total, all read in full; nothing exotic. Papyros' public API (src/Library.ts) re-exports none of their types, so vendoring is not a breaking change — a minor release suffices.
| Package |
Fate |
Detail |
sync-message (418 lines) |
vendor ≈ verbatim as src/sync/channel.ts |
makeChannel/makeAtomicsChannel/makeServiceWorkerChannel, readMessage/writeMessage/syncSleep, serviceWorkerFetchListener, uuidv4. Solid code; keep the wire protocol and the __sync-message-v2__ version string byte-identical — a freshly deployed Papyros must interop with a stale, already-registered service worker (/version handshake handles skew only if the constant is preserved). |
comsync (214 lines) |
vendor + refactor as src/sync/SyncClient.ts + src/sync/expose.ts |
Keep the state machine (idle/running/awaitingMessage/sleeping), writeMessage queueing, InterruptError. Merge PyodideClient (the SAB interrupt-buffer 12 lines) into SyncClient; add transport-aware delivery in _writeMessage/interrupt. |
pyodide-worker-runner (344 lines TS + 106 lines Python) |
vendor the used half |
Keep: pyodideExpose→ merged into the unified expose, PyodideExtras, loadPyodideAndPackage, initPyodide. Move pyodide_worker_runner.py (install_imports, imported by papyros.py:17) into the papyros Python package. Drop deliberately: makeRunnerCallback (Papyros has its own callback in papyros.py), PyodideFatalErrorReloader (unused), pre-0.19 Pyodide compat in unpackArchive, the p-retry and raw-loader dependencies (retry is a 10-line loop; the .py file becomes a real vite ?raw import). |
python_runner (Python, upstream, maintained) |
keep external |
Its PatchedStdinRunner/PyodideRunner input path is satisfied by the run_sync wrap; its js_error.type → KeyboardInterrupt mapping works for rejected promises as-is (verified). No reason to own it. |
Net effect: three dead dependencies removed, one coherent src/sync/ module (~600–700 lines after merging) that Papyros owns, with the JSPI transport as a first-class citizen instead of a bolt-on.
Phased implementation
Each phase leaves the suite green and is independently shippable.
| Phase |
Content |
Files |
Size |
| 1. Vendor verbatim |
Copy the three packages into src/sync/ (channel.ts, SyncClient.ts, expose.ts, pyodide.ts), move install_imports into the papyros Python package, switch imports (src/backend/Backend.ts:2, src/communication/BackendManager.ts:5-7, src/communication/InputWorker.ts:1, src/backend/workers/python/PythonWorker.ts:5, src/frontend/state/Papyros.ts:8, papyros.py:17), drop the npm deps. Behaviour-identical; wire protocol untouched. Fold in the BackendManager.ts:120 lazy-channel fix. |
~10 files |
~900 lines moved, ~50 changed |
| 2. Merge & refactor |
Collapse syncExpose+pyodideExpose into one expose; merge PyodideClient into SyncClient; delete the dropped surface (makeRunnerCallback, reloader, compat shims). |
src/sync/* |
−300 lines |
| 3. JSPI transport |
Worker: can_run_sync probe in PythonWorker.launch; pending-message registry (input and rejectable sleep) in the extras; status callback kept firing; Comlink-exposed receiveMessage/interruptMessage on Backend. Python: run_sync unwrap in papyros.py runner_callback. Client: transport-aware writeMessage/interrupt (decision from client-tracked state, never a worker round-trip). Tests: port the prototype's 8 E2E tests + a forced-sync-transport test to keep the Safari path covered in CI. |
Backend.ts, PythonWorker.ts, papyros.py, src/sync/*, Runner.ts |
~250–350 lines |
| 4. Lazy registration + docs |
Papyros.configureInput stops registering eagerly. With SharedArrayBuffer it builds the atomics channel as today. Otherwise, if WebAssembly.Suspending exists it defers: Python launches with a null channel, and registration happens only when the probe returns false, or when the JavaScript backend is selected, assigning client.channel before the first run. Registration failure stops being fatal for a backend that does not need the channel. README gains a mermaid diagram of launch order and transport selection. CHANGELOG, release. Dodona follow-up (separate repo): the registration-failure alert becomes Safari-and-JavaScript only. |
Papyros.ts, Runner.ts, BackendManager.ts, README.md |
~150 lines |
Phases 1+2 are pure refactoring and are worth doing even if phase 3 were rejected. Phase 3 is where the prototype diff (attached) already proves the mechanics.
Non-goals / what this does not fix
- Stop on a busy loop still terminates the worker and reloads Pyodide. JSPI does not change interrupt semantics for running code;
setInterruptBuffer requires a SharedArrayBuffer regardless. Only "waiting on input" and "sleeping" interrupt cheaply — same as today, minus the channel.
- The JavaScript backend keeps the service-worker path forever, as do browsers without stack switching, so the SW and its Rails route remain (along with the atomics path for COOP/COEP embedders). Phase 4 only stops registering it where nothing will use it; it does not delete it.
- No COOP/COEP anywhere. Not proposed, not needed.
- No change to
python_runner or to the event/callback protocol between papyros.py and the frontend.
Risks and open questions
- Pyodide labels
run_sync/can_run_sync/JSPI "experimental". The behaviour relied on ("async def entered via PyProxy call runs on a suspender stack") is documented in ffi.d.ts and verified on 314.0.2, but a future Pyodide bump could change the calling convention. Mitigation: the can_run_sync probe fails closed — if a future version stops suspending plain async calls, the probe returns False and Papyros silently stays on the sync transport. A CI test pinning the probe result on Chromium guards against silent loss of the JSPI path.
- Browser regressions in JSPI (it is new in Firefox). Same mitigation: probe-gated, sync path always present.
- Event-loop reentrancy during suspension. While Python is suspended on
run_sync, the worker services Comlink messages — verified to work, but this means e.g. lintCode can now run concurrently with a suspended runCode in the same interpreter. The sync transports never allowed that. Pyodide's webloop reentrant-task machinery handles it (enterTask in the stack-switching module), but Papyros should still refuse/queue new work while a run is active if it doesn't already.
- Stale service workers during rollout: old SW + new page must keep working (protocol frozen, phase 1) and vice versa.
- What would kill the plan: none of the above; the crux experiments passed. The only fatal discovery would have been
run_sync not working under the existing call convention or breaking the tracer — both disproven empirically.
Impact on Dodona
- No API change; bump
@dodona/papyros when released (minor version).
- Chrome/Firefox students stop depending on the service worker for Python input — the dominant browser share stops seeing the
__SyncMessageServiceWorkerInput__ failure family entirely.
- Students on a browser without stack switching see no change (SW path, unchanged bytes on the wire). Since Safari 27 that is a shrinking group.
- After phase 4, every current browser registers no service worker at all for a Python scratchpad, so
/inputServiceWorker.js (config/routes.rb:315) stops being requested by nearly all traffic and the coding_scratchpad.ts:66-79 alert becomes a JavaScript-and-old-browser case. The route stays for those.
- Dodona passes the activity's own language (
_handin.html.erb:138), and javascript maps to the JS backend, so JavaScript exercises still register and still use the channel. Lazy registration handles that by registering on language selection rather than assuming Python.
- Interrupting a student program stuck on
input() no longer reloads Pyodide on JSPI browsers (faster, keeps installed packages warm). Stopping a busy loop behaves as today.
Reproducing the experiments
On a branch, with yarn build:sw && yarn setup done first (baseline must be green before drawing conclusions):
- Isolated Pyodide behaviour:
JspiExperiment.test.ts (11 tests) — loads Pyodide 314.0.2 from CDN in the test page, exercises can_run_sync/run_sync/rejections/JSONTracer-across-suspend.
- Prototype (~85-line diff across
Backend.ts, PythonWorker.ts, papyros.py, Runner.ts) + JspiE2E.test.ts (8 tests) — full worker-stack validation, yarn vitest --run: 96/96 green.
The prototype diff and both test files are inlined below.
Attachments
jspi-prototype.diff
diff --git a/src/backend/Backend.ts b/src/backend/Backend.ts
index 24781a0..fbb0089 100644
--- a/src/backend/Backend.ts
+++ b/src/backend/Backend.ts
@@ -42,6 +42,12 @@ export abstract class Backend<Extras extends SyncExtras = SyncExtras> {
* for synchronous operations
*/
protected extras: Extras;
+ /**
+ * EXPERIMENT: whether input/sleep are handled by returning Promises
+ * (JSPI stack switching) instead of blocking on the sync-message channel
+ */
+ protected useJspi = false;
+ private pendingInput?: { resolve: (value: string) => void; reject: (reason: unknown) => void };
/**
* Callback to handle events published by this Backend
*/
@@ -82,8 +88,16 @@ export abstract class Backend<Extras extends SyncExtras = SyncExtras> {
this.onEvent = (e: BackendEvent) => {
onEvent(e);
if (e.type === BackendEventType.Sleep) {
+ if (this.useJspi) {
+ return new Promise((resolve) => setTimeout(resolve, e.data));
+ }
return this.extras.syncSleep(e.data);
} else if (e.type === BackendEventType.Input) {
+ if (this.useJspi) {
+ return new Promise<string>((resolve, reject) => {
+ this.pendingInput = { resolve, reject };
+ });
+ }
return this.extras.readMessage();
}
};
@@ -91,6 +105,39 @@ export abstract class Backend<Extras extends SyncExtras = SyncExtras> {
return Promise.resolve();
}
+ /**
+ * EXPERIMENT: called via Comlink from the main thread after launch
+ * to decide how input should be delivered.
+ */
+ public usesJspiInput(): boolean {
+ return this.useJspi;
+ }
+
+ /**
+ * EXPERIMENT: resolve a pending JSPI input promise.
+ * @return {boolean} whether a pending input was resolved
+ */
+ public receiveInput(text: string): boolean {
+ if (this.pendingInput) {
+ this.pendingInput.resolve(text);
+ this.pendingInput = undefined;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * EXPERIMENT: reject a pending JSPI input promise, mimicking comsync's InterruptError.
+ */
+ public interruptInput(): boolean {
+ if (this.pendingInput) {
+ this.pendingInput.reject(Object.assign(new Error("interrupted"), { type: "InterruptError" }));
+ this.pendingInput = undefined;
+ return true;
+ }
+ return false;
+ }
+
/**
* Determine whether the modes supported by this Backend are active
* @param {string} code The current code in the editor
diff --git a/src/backend/workers/python/PythonWorker.ts b/src/backend/workers/python/PythonWorker.ts
index 91f35fc..b190a5f 100644
--- a/src/backend/workers/python/PythonWorker.ts
+++ b/src/backend/workers/python/PythonWorker.ts
@@ -58,6 +58,18 @@ export class PythonWorker extends Backend<PyodideExtras> {
});
// preload micropip to allow installing packages
await (this.pyodide as any).loadPackage("micropip");
+ // EXPERIMENT: authoritative JSPI detection: can_run_sync() observed from
+ // inside an async def entered via a plain PyProxy call, i.e. the exact
+ // calling convention used for run_async.
+ const check = this.pyodide.runPython(
+ "async def __papyros_jspi_check():\n" +
+ " from pyodide.ffi import can_run_sync\n" +
+ " return can_run_sync()\n" +
+ "__papyros_jspi_check",
+ );
+ this.useJspi = await check();
+ check.destroy();
+ console.log("Papyros JSPI input transport:", this.useJspi);
}
/**
diff --git a/src/backend/workers/python/papyros/papyros.py b/src/backend/workers/python/papyros/papyros.py
index f80f0dc..00db9b7 100644
--- a/src/backend/workers/python/papyros/papyros.py
+++ b/src/backend/workers/python/papyros/papyros.py
@@ -57,6 +57,14 @@ class Papyros(python_runner.PyodideRunner):
self.set_event_callback(callback)
def set_event_callback(self, event_callback):
+ # EXPERIMENT: if the JS callback returned a Promise (JSPI input transport),
+ # block on it with stack switching; otherwise pass the value through.
+ def unwrap(res):
+ if hasattr(res, "then"):
+ from pyodide.ffi import run_sync
+ return run_sync(res)
+ return res
+
def runner_callback(event_type, data):
def cb(typ, dat, contentType=None, **kwargs):
return event_callback(dict(type=typ, data=dat, contentType=contentType or "text/plain", **kwargs))
@@ -77,10 +85,10 @@ class Papyros(python_runner.PyodideRunner):
cb("output", data, contentType=part.get("contentType"))
elif event_type == "input":
self._emit_turtle_snapshot()
- return cb("input", data["prompt"])
+ return unwrap(cb("input", data["prompt"]))
elif event_type == "sleep":
self._emit_turtle_snapshot()
- return cb("sleep", data["seconds"]*1000, contentType="application/number")
+ return unwrap(cb("sleep", data["seconds"]*1000, contentType="application/number"))
else:
return cb(event_type, data.get("data", ""), contentType=data.get("contentType"))
diff --git a/src/frontend/state/Runner.ts b/src/frontend/state/Runner.ts
index 1971085..f86835d 100644
--- a/src/frontend/state/Runner.ts
+++ b/src/frontend/state/Runner.ts
@@ -72,6 +72,10 @@ export class Runner extends State {
* Identifies the most recent launch, so a superseded one cannot report ready
*/
private launchId: number = 0;
+ /**
+ * EXPERIMENT: whether the current backend delivers input via JSPI promises
+ */
+ private jspiInput = false;
/**
* Current state of the program
*/
@@ -203,6 +207,7 @@ export class Runner extends State {
proxy((e: BackendEvent) => BackendManager.publish(e)),
this.pyodideAssetURL,
);
+ this.jspiInput = await workerProxy.usesJspiInput();
if (launchId === this.launchId) {
this.updateRunModes();
this.backendReady = true;
@@ -272,6 +277,9 @@ export class Runner extends State {
* @return {Promise<void>} Returns when the code has been interrupted
*/
public async stop(): Promise<void> {
+ // EXPERIMENT: must be decided from main-thread state; a busy-looping
+ // worker never services Comlink messages, so asking the worker deadlocks
+ const wasAwaitingInput = this.state === RunState.AwaitingInput;
this.setState(RunState.Stopping);
BackendManager.publish({
type: BackendEventType.End,
@@ -279,7 +287,11 @@ export class Runner extends State {
contentType: "text/plain",
});
const backend = await this.backend;
- await backend.interrupt();
+ if (this.jspiInput && wasAwaitingInput && (await backend.workerProxy.interruptInput())) {
+ // Pending input promise rejected; KeyboardInterrupt propagates in Python
+ } else {
+ await backend.interrupt();
+ }
const startTime = new Date().getTime();
while (this.state === RunState.Stopping && new Date().getTime() - startTime < 5000) {
@@ -298,6 +310,9 @@ export class Runner extends State {
public async provideInput(input: string): Promise<void> {
const backend = await this.backend;
this.setState(RunState.Running);
+ if (this.jspiInput && (await backend.workerProxy.receiveInput(input))) {
+ return;
+ }
await backend.writeMessage(input);
}
JspiExperiment.test.ts (11 tests, isolated Pyodide behaviour)
/**
* Throwaway experiments for JSPI (WebAssembly stack switching) support.
* Loads Pyodide in the main test thread (JSPI behaviour is identical to a worker).
* DO NOT COMMIT.
*/
import { describe, expect, it, beforeAll } from "vitest";
import { loadPyodide } from "pyodide";
import type { PyodideInterface } from "pyodide";
let pyodide: PyodideInterface;
declare global {
var __getInput: () => Promise<string>;
var __getRejected: () => Promise<string>;
var __sleep: (ms: number) => Promise<void>;
var __duringSuspend: string[];
}
describe("JSPI experiments", () => {
beforeAll(async () => {
pyodide = await loadPyodide({
indexURL: "https://cdn.jsdelivr.net/pyodide/v314.0.2/full/",
});
globalThis.__getInput = () => new Promise((resolve) => setTimeout(() => resolve("hello"), 100));
globalThis.__getRejected = () =>
Promise.reject(Object.assign(new Error("interrupted"), { type: "InterruptError" }));
globalThis.__sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
globalThis.__duringSuspend = [];
pyodide.runPython(`
import js
import time
from pyodide.ffi import run_sync, can_run_sync
async def check_async():
return can_run_sync()
def check_sync():
return can_run_sync()
async def get_via_run_sync():
return run_sync(js.__getInput())
async def deep_sync_stack():
def cb():
return run_sync(js.__getInput())
ns = {"cb": cb}
exec("x = cb() + '!'", ns)
return ns["x"]
async def get_rejected():
from pyodide.ffi import JsException
try:
run_sync(js.__getRejected())
return "no-error"
except JsException as e:
js_error = getattr(e, "js_error", None)
return "JsException:" + str(getattr(js_error, "type", None))
except BaseException as e:
return "other:" + type(e).__name__
async def jspi_sleep(ms):
t0 = time.time()
run_sync(js.__sleep(ms))
return (time.time() - t0) * 1000
`);
}, 240000);
it("0. environment supports WebAssembly.Suspending", () => {
expect(typeof (WebAssembly as any).Suspending).toBe("function");
expect(typeof (WebAssembly as any).promising).toBe("function");
});
it("1. can_run_sync is True inside async def called via plain PyProxy call", async () => {
const check = pyodide.globals.get("check_async");
const result = await check();
check.destroy();
expect(result).toBe(true);
});
it("2. can_run_sync is False inside sync def called via plain PyProxy call", () => {
const check = pyodide.globals.get("check_sync");
const result = check();
check.destroy();
expect(result).toBe(false);
});
it("3. sync def via callPromising has can_run_sync True", async () => {
const check = pyodide.globals.get("check_sync");
const result = await check.callPromising();
check.destroy();
expect(result).toBe(true);
});
it("4. run_sync resolves a JS promise inside async def called normally", async () => {
const fn = pyodide.globals.get("get_via_run_sync");
const result = await fn();
fn.destroy();
expect(result).toBe("hello");
});
it("5. run_sync works deep in a synchronous exec() stack under an async entry", async () => {
const fn = pyodide.globals.get("deep_sync_stack");
const result = await fn();
fn.destroy();
expect(result).toBe("hello!");
});
it("6. run_sync on a rejected promise raises JsException with reachable .type", async () => {
const fn = pyodide.globals.get("get_rejected");
const result = await fn();
fn.destroy();
console.warn("rejection result:", result);
expect(result).toBe("JsException:InterruptError");
});
it("7. run_sync-based sleep blocks for roughly the requested time", async () => {
const fn = pyodide.globals.get("jspi_sleep");
const elapsed = await fn(300);
fn.destroy();
expect(elapsed).toBeGreaterThanOrEqual(250);
expect(elapsed).toBeLessThan(1000);
});
it("8. JS can call back into Python while the stack is suspended", async () => {
// While Python is suspended on run_sync, invoke another (sync) Python
// function from the JS side. This mimics comlink messages arriving
// (e.g. lintCode) while user code waits on input().
pyodide.runPython(`
side_effects = []
def side_call(x):
side_effects.append(x)
return len(side_effects)
async def wait_long():
return run_sync(js.__sleep(500))
`);
const wait = pyodide.globals.get("wait_long");
const side = pyodide.globals.get("side_call");
const waiting = wait();
await new Promise((r) => setTimeout(r, 100));
const n = side("during-suspend");
await waiting;
wait.destroy();
side.destroy();
expect(n).toBe(1);
});
it("9. JSONTracer (bdb settrace) survives a run_sync suspend point", async () => {
await pyodide.loadPackage("micropip");
await pyodide.runPythonAsync(`
import micropip
await micropip.install("json-tracer")
`);
const result = await pyodide.runPythonAsync(`
import builtins
import json
from tracer import JSONTracer
frames = []
def frame_cb(f):
frames.append(json.loads(f))
def fake_input(prompt=""):
return run_sync(js.__getInput())
old_input = builtins.input
builtins.input = fake_input
try:
trace = JSONTracer(frame_callback=frame_cb, module_name="sandbox").runscript(
"x = input()\\ny = x + '!'\\nz = y * 2\\n"
)
finally:
builtins.input = old_input
final_globals = json.loads(trace)[-1].get("globals", {})
json.dumps({"frame_count": len(frames), "final_globals": final_globals})
`);
const parsed = JSON.parse(result);
console.log("tracer result:", result);
expect(parsed.frame_count).toBeGreaterThanOrEqual(3);
expect(JSON.stringify(parsed.final_globals)).toContain("hello!");
});
it("10. interrupting run_sync while traced: KeyboardInterrupt-style unwind", async () => {
const result = await pyodide.runPythonAsync(`
from pyodide.ffi import JsException
def fake_input_rejected(prompt=""):
return run_sync(js.__getRejected())
import builtins
old_input = builtins.input
builtins.input = fake_input_rejected
try:
outcome = None
try:
from tracer import JSONTracer
JSONTracer(module_name="sandbox").runscript("x = input()\\n")
outcome = "completed"
except JsException as e:
outcome = "JsException:" + str(getattr(getattr(e, "js_error", None), "type", None))
except BaseException as e:
outcome = "other:" + type(e).__name__
finally:
builtins.input = old_input
outcome
`);
console.warn("interrupt-under-trace result:", result);
expect(typeof result).toBe("string");
});
});
JspiE2E.test.ts (8 tests, full worker stack)
/**
* Throwaway end-to-end experiments: JSPI input transport through the real
* worker / Comlink / python_runner stack. DO NOT COMMIT.
*/
import { describe, expect, it } from "vitest";
import { Papyros } from "../../src/frontend/state/Papyros";
import { ProgrammingLanguage } from "../../src/ProgrammingLanguage";
import { RunMode } from "../../src/backend/Backend";
import { RunState } from "../../src/frontend/state/Runner";
import { waitForAwaitingInput, waitForInputReady, waitForOutput, waitForPapyrosReady } from "../helpers";
import { NonExceptionFrame } from "@dodona/trace-component/dist/trace_types";
describe.sequential("JSPI end-to-end", () => {
it("python backend detects and reports JSPI", async () => {
const papyros = new Papyros();
await papyros.launch();
papyros.runner.programmingLanguage = ProgrammingLanguage.Python;
const backend = await papyros.runner.backend;
expect(await backend.workerProxy.usesJspiInput()).toBe(true);
});
it("javascript backend does not use JSPI", async () => {
const papyros = new Papyros();
await papyros.launch();
papyros.runner.programmingLanguage = ProgrammingLanguage.JavaScript;
const backend = await papyros.runner.backend;
expect(await backend.workerProxy.usesJspiInput()).toBe(false);
});
it("reads input via JSPI promises", async () => {
const papyros = new Papyros();
await papyros.launch();
papyros.runner.programmingLanguage = ProgrammingLanguage.Python;
papyros.runner.code = "print('hello ' + input('name?'))";
await waitForInputReady();
const unsubscribe = papyros.io.subscribe(
() => (papyros.io.awaitingInput ? papyros.io.provideInput("jspi") : ""),
"awaitingInput",
);
await papyros.runner.start();
await waitForOutput(papyros);
await waitForPapyrosReady(papyros);
expect(papyros.io.output[0].content).toBe("hello jspi");
unsubscribe();
});
it("reads multiple inputs in a loop via JSPI", async () => {
const papyros = new Papyros();
await papyros.launch();
papyros.runner.programmingLanguage = ProgrammingLanguage.Python;
papyros.runner.code = "total = 0\nfor _ in range(3):\n total += int(input())\nprint(total)";
await waitForInputReady();
const unsubscribe = papyros.io.subscribe(
() => (papyros.io.awaitingInput ? papyros.io.provideInput("7") : ""),
"awaitingInput",
);
await papyros.runner.start();
await waitForOutput(papyros);
await waitForPapyrosReady(papyros);
expect(papyros.io.output[0].content).toBe("21");
unsubscribe();
});
it("handles time.sleep via JSPI", async () => {
const papyros = new Papyros();
await papyros.launch();
papyros.runner.programmingLanguage = ProgrammingLanguage.Python;
papyros.runner.code = "import time\ntime.sleep(2)";
await papyros.runner.start();
await waitForPapyrosReady(papyros);
expect(papyros.runner.state).toBe(RunState.Ready);
expect(papyros.runner.stateMessage).toMatch(/^Code executed in 2/);
});
it("debug mode traces across a JSPI input suspend", async () => {
const papyros = new Papyros();
await papyros.launch();
papyros.runner.programmingLanguage = ProgrammingLanguage.Python;
papyros.runner.code = `print("hello")
x = input("input: ")
print("world " + x)
z = 1 + 2`;
const unsubscribe = papyros.io.subscribe(
() => (papyros.io.awaitingInput ? papyros.io.provideInput("foo") : ""),
"awaitingInput",
);
await waitForInputReady();
await papyros.runner.start(RunMode.Debug);
await waitForOutput(papyros);
await waitForPapyrosReady(papyros);
expect(papyros.debugger.trace.length).toBe(5);
expect((papyros.debugger.trace[4] as NonExceptionFrame).globals.z).toBe(3);
expect((papyros.debugger.trace[4] as NonExceptionFrame).globals.x).toBe("foo");
unsubscribe();
});
it("interrupts a JSPI input wait without terminating the worker", async () => {
const papyros = new Papyros();
await papyros.launch();
papyros.runner.programmingLanguage = ProgrammingLanguage.Python;
papyros.runner.code = "x = input('never answered')\nprint(x)";
await waitForInputReady();
const backendBefore = papyros.runner.backend;
const runPromise = papyros.runner.start();
await waitForAwaitingInput(papyros);
await papyros.runner.stop();
await runPromise;
await waitForPapyrosReady(papyros, 10000);
expect(papyros.runner.state).toBe(RunState.Ready);
expect(papyros.runner.stateMessage).toMatch(/^Code interrupted after/);
// The worker must not have been terminated and relaunched
expect(papyros.runner.backend).toBe(backendBefore);
// The rejection must surface as a clean KeyboardInterrupt, not an error
expect(papyros.io.output.every((o) => o.type !== "stderr")).toBe(true);
// The backend must still be usable without relaunch
papyros.runner.code = "print('alive')";
await papyros.runner.start();
await waitForOutput(papyros);
expect(papyros.io.output[0].content).toBe("alive\n");
});
it("still terminates the worker to interrupt a busy loop", async () => {
const papyros = new Papyros();
await papyros.launch();
papyros.runner.programmingLanguage = ProgrammingLanguage.Python;
papyros.runner.code = "while True:\n pass";
const backendBefore = papyros.runner.backend;
const runPromise = papyros.runner.start();
await new Promise((r) => setTimeout(r, 3000));
expect(papyros.runner.state).toBe(RunState.Running);
await papyros.runner.stop();
await runPromise;
await waitForPapyrosReady(papyros, 10000);
expect(papyros.runner.stateMessage).toMatch(/^Code interrupted after/);
// Busy loop: no cheap interrupt available, so the backend is relaunched
expect(papyros.runner.backend).not.toBe(backendBefore);
});
});
Problem
input()andtime.sleep()must block a synchronous Python stack in the worker until the main thread supplies a value. Today this goes through three unmaintained packages:sync-message(0.0.12),comsync(0.0.9) andpyodide-worker-runner(1.4.0).sync-messagepicks one of two mechanisms:SharedArrayBuffer+Atomics.wait. Requires COOP/COEP. Dodona embeds third-party iframes, so COOP/COEP is permanently off the table and this path never runs there.The service-worker path is a live reliability liability:
python_runner'sPyodideRunner.ServiceWorkerError). Papyros string-matches tracebacks to detect this and report it to Sentry (src/frontend/state/InputOutput.ts:163-176).config/routes.rb:315) and hard-fails scratchpad startup with a visible alert when registration fails (coding_scratchpad.ts:66-79).Received status 404 from /__SyncMessageServiceWorkerInput__/write(DODONA-FRONTEND-FQ, 3 users),Service worker for reading input was not available(DODONA-FRONTEND-298),Error launching papyros after registering service worker(DODONA-FRONTEND-297), and SW-registration failures (DODONA-FRONTEND-27R, 5 users; DODONA-FRONTEND-2AM). Low absolute counts, but each one is a student who could not run code, and the failure is unrecoverable without closing every tab.WebAssembly JSPI (
WebAssembly.Suspending/WebAssembly.promising) removes the need for any of this on supporting browsers: Pyodide'spyodide.ffi.run_sync(awaitable)suspends the wasm stack until a JS promise resolves. Shipped in Chrome 137+, Firefox from 153 (flagged 139–152), and Safari 27 (announced for the beta at WWDC26, confirmed working on a real Safari 27 on 2026-08-04). Every current engine now supports it, so the channels below are for older browsers and for the JavaScript backend.This issue proposes adding JSPI as a third input transport and vendoring the external packages into Papyros while doing it.
Findings
All experiments ran in the repo's own vitest browser mode: headless Chromium 151.0.7922.34 (Playwright 1.61.1), Pyodide 314.0.2 (the pinned
^314.0.0), no COOP/COEP (so noSharedArrayBuffer), service worker registered as in production. Baseline before any change: 8 files / 77 tests green.Legend: [ran] = observed in a test run, [read] = concluded from reading source, [assume] = not verified.
1. The crux:
run_syncworks insiderun_asyncwith no change to the JS call site — [ran]Papyros.run_asyncis anasync def, called from JS viathis.papyros?.run_async.callKwargs(...)(src/backend/workers/python/PythonWorker.ts:93). The question was whether that call already runs on a suspender stack, as hinted bypyodide/ffi.d.ts:747("Not needed if the callee is an async Python function").It does. Observed in isolated tests (
JspiExperiment.test.ts, 11/11 green) and end-to-end (below):can_run_sync()isTrueinside anasync defentered via a plain PyProxy call;Falseinside a plain sync call;Trueagain when the sync function is entered viacallPromising().run_sync(js_promise)returns the resolved value inside anasync defcalled normally — including when therun_synccall sits deep in a synchronous stack (async def→exec()→ sync callback), which is exactly Papyros' shape:run_async→execute/JSONTracer→ user code →input()→ callback.callPromisingKwargsfallback is needed. [ran] Pyodide 314 auto-detects JSPI at load (jspiSupported = "Suspending" in WebAssembly || "Suspender" in WebAssemblyinpyodide.asm.mjs); no loader flag required. [read]So the port collapses to: let the JS input callback return a Promise, and
run_syncit inpapyros.py.2. End-to-end prototype in the real worker stack — [ran]
An ~85-line prototype (diff attached below) was validated with 8 end-to-end tests through the real Comlink/worker/
python_runnerstack, and the full suite (96 tests incl. the experiments) is green with it applied:truein Chromium 151false, keeps SW pathinput()via promise +run_syncinput()in a looptime.sleep(2)via promiseJSONTracerframes across aninput()suspendinput()JsExceptionwith.js_error.type == "InterruptError", whichpython_runner.PyodideRunner.pyodide_erroralready maps toKeyboardInterrupt; no stderr output, same worker immediately runs the next programsetInterruptBufferstill needs SAB)The debug-mode result matters: bdb/
sys.settracetracing survives a JSPI suspend point mid-script. This was the sharpest risk and it did not materialize.The prototype changed 4 files:
src/backend/Backend.ts:80-88— the seam:onEventreturns a Promise forInput/Sleepwhen in JSPI mode, plus Comlink-exposedreceiveInput/interruptInputresolving/rejecting the pending promise.src/backend/workers/python/PythonWorker.ts— detection probe after launch.src/backend/workers/python/papyros/papyros.py:78-83—runner_callbackwraps the callback result:if hasattr(res, "then"): return run_sync(res).src/frontend/state/Runner.ts—provideInput/stoproute to the Comlink methods when the backend reported JSPI.No change to
python_runnerwas needed:PatchedStdinRunner.readlinerequires the callback to returnstrsynchronously, and therun_syncwrap inpapyros.pysatisfies that. Its error mapping (e.js_error.type→KeyboardInterrupt) works unchanged for rejected promises. [ran]python_runnerstays an external dependency.3. Feature detection — [ran]
The authoritative check is Pyodide's own
can_run_sync(), observed from inside anasync defentered via a plain PyProxy call — i.e. the exact calling convention ofrun_async:Run once in the worker after Pyodide loads; the awaited result decides the transport. This is end-to-end (covers browser support and Pyodide build support and the calling convention) and is not UA sniffing, so flagged-Firefox 139–152 and future Safari resolve correctly by construction.
typeof WebAssembly.Suspending === "function"is the right main-thread pre-check (it is exactly what Pyodide tests), useful before a worker exists — e.g. to decide whether SW registration failure is fatal.4. Things learned the hard way
JSONTracer— the bareexcept:in_runscriptrecords it asuncaught_exceptionand completes normally. [ran] This is identical to the current sync path's behaviour (same bareexcept:catches theKeyboardInterrupt) [read], so no regression; noted for completeness.new Promise(r => setTimeout(r, ms))for Sleep cannot be interrupted, so stopping during a JSPItime.sleepfalls back to terminate+relaunch — a regression vs. the sync path, where "sleeping" is cheaply interruptible. [read + reasoned; not separately run] The real implementation must keep a pending-message registry covering both input and sleep, and signalreading/sleepingstatus likesyncExposedoes.BackendManager's static block creates a throwaway channel at import time (src/communication/BackendManager.ts:120) thatPapyros.configureInput(src/frontend/state/Papyros.ts:71-78) later replaces. Any backend created beforelaunch()would capture the wrong channel. Not live today (backends are created lazily afterconfigureInput), but it should die in the refactor.Proposed architecture
One concept: a message transport with a sync worker side (from Python's perspective) and an async main-thread side. Three implementations:
readMessageatomicsAtomics.waiton SAB (sync)serviceWorkerjspirun_sync.type = "InterruptError")Key structural points:
Backend.onEvent(src/backend/Backend.ts:80-88) already funnelsInput/Sleeptoextras.readMessage()/extras.syncSleep(). The extras object becomes transport-backed; in JSPI mode those calls return Promises and register a pending resolver keyed by the message sequence, and the status callback (reading/sleeping) keeps firing so the client state machine works identically across all three transports.prompt()atsrc/backend/workers/javascript/JavaScriptWorker.ts:45is called from synchronous user JS with no wasm on the stack). JSPI is a per-backend overlay, decided in the worker by thecan_run_syncprobe and reported to the client once at launch. All three configurations are permanent, not transitional.SyncClient.writeMessage/interruptroute by transport: same public API, delivery switches between channel write and Comlink call. Interrupt policy from client state:awaitingMessage/sleeping→ cheap transport interrupt;running→ interrupt buffer if SAB, else terminate+relaunch (unchanged).run_syncunwrap inpapyros.py'srunner_callback(a value in the sync transports, a thenable in JSPI).python_runneris untouched.SyncClient.channelis a public property read at call time by_writeMessage, so it can be assigned after the client is constructed. That means Python can launch with a null channel, and registration happens only if the JSPI probe comes back false or the user selects JavaScript. On Chrome and Firefox, Dodona registers no service worker at all. Today a failed registration is fatal for everything (Papyros.ts:31-32skipsrunner.launch()entirely, plus the Dodona alert), even though Python on those browsers never touches it.Vendoring recommendation: yes —
sync-message,comsync,pyodide-worker-runner; notpython_runnerThe JSPI transport cannot be added inside the current stack without forking it anyway:
comsync.syncExposehardcodes syncreadMessage,PyodideClienthardcodes channel writes, and all three packages are effectively unmaintained (0.0.x versions, years dormant). ~975 lines total, all read in full; nothing exotic. Papyros' public API (src/Library.ts) re-exports none of their types, so vendoring is not a breaking change — a minor release suffices.sync-message(418 lines)src/sync/channel.tsmakeChannel/makeAtomicsChannel/makeServiceWorkerChannel,readMessage/writeMessage/syncSleep,serviceWorkerFetchListener,uuidv4. Solid code; keep the wire protocol and the__sync-message-v2__version string byte-identical — a freshly deployed Papyros must interop with a stale, already-registered service worker (/versionhandshake handles skew only if the constant is preserved).comsync(214 lines)src/sync/SyncClient.ts+src/sync/expose.tsidle/running/awaitingMessage/sleeping),writeMessagequeueing,InterruptError. MergePyodideClient(the SAB interrupt-buffer 12 lines) intoSyncClient; add transport-aware delivery in_writeMessage/interrupt.pyodide-worker-runner(344 lines TS + 106 lines Python)pyodideExpose→ merged into the unifiedexpose,PyodideExtras,loadPyodideAndPackage,initPyodide. Movepyodide_worker_runner.py(install_imports, imported bypapyros.py:17) into thepapyrosPython package. Drop deliberately:makeRunnerCallback(Papyros has its own callback inpapyros.py),PyodideFatalErrorReloader(unused), pre-0.19 Pyodide compat inunpackArchive, thep-retryandraw-loaderdependencies (retry is a 10-line loop; the .py file becomes a real vite?rawimport).python_runner(Python, upstream, maintained)PatchedStdinRunner/PyodideRunnerinput path is satisfied by therun_syncwrap; itsjs_error.type→KeyboardInterruptmapping works for rejected promises as-is (verified). No reason to own it.Net effect: three dead dependencies removed, one coherent
src/sync/module (~600–700 lines after merging) that Papyros owns, with the JSPI transport as a first-class citizen instead of a bolt-on.Phased implementation
Each phase leaves the suite green and is independently shippable.
src/sync/(channel.ts, SyncClient.ts, expose.ts, pyodide.ts), moveinstall_importsinto thepapyrosPython package, switch imports (src/backend/Backend.ts:2,src/communication/BackendManager.ts:5-7,src/communication/InputWorker.ts:1,src/backend/workers/python/PythonWorker.ts:5,src/frontend/state/Papyros.ts:8,papyros.py:17), drop the npm deps. Behaviour-identical; wire protocol untouched. Fold in theBackendManager.ts:120lazy-channel fix.syncExpose+pyodideExposeinto oneexpose; mergePyodideClientintoSyncClient; delete the dropped surface (makeRunnerCallback, reloader, compat shims).src/sync/*can_run_syncprobe inPythonWorker.launch; pending-message registry (input and rejectable sleep) in the extras; status callback kept firing; Comlink-exposedreceiveMessage/interruptMessageonBackend. Python:run_syncunwrap inpapyros.pyrunner_callback. Client: transport-awarewriteMessage/interrupt(decision from client-tracked state, never a worker round-trip). Tests: port the prototype's 8 E2E tests + a forced-sync-transport test to keep the Safari path covered in CI.Backend.ts,PythonWorker.ts,papyros.py,src/sync/*,Runner.tsPapyros.configureInputstops registering eagerly. WithSharedArrayBufferit builds the atomics channel as today. Otherwise, ifWebAssembly.Suspendingexists it defers: Python launches with a null channel, and registration happens only when the probe returns false, or when the JavaScript backend is selected, assigningclient.channelbefore the first run. Registration failure stops being fatal for a backend that does not need the channel. README gains a mermaid diagram of launch order and transport selection. CHANGELOG, release. Dodona follow-up (separate repo): the registration-failure alert becomes Safari-and-JavaScript only.Papyros.ts,Runner.ts,BackendManager.ts,README.mdPhases 1+2 are pure refactoring and are worth doing even if phase 3 were rejected. Phase 3 is where the prototype diff (attached) already proves the mechanics.
Non-goals / what this does not fix
setInterruptBufferrequires aSharedArrayBufferregardless. Only "waiting on input" and "sleeping" interrupt cheaply — same as today, minus the channel.python_runneror to the event/callback protocol betweenpapyros.pyand the frontend.Risks and open questions
run_sync/can_run_sync/JSPI "experimental". The behaviour relied on ("async def entered via PyProxy call runs on a suspender stack") is documented inffi.d.tsand verified on 314.0.2, but a future Pyodide bump could change the calling convention. Mitigation: thecan_run_syncprobe fails closed — if a future version stops suspending plain async calls, the probe returnsFalseand Papyros silently stays on the sync transport. A CI test pinning the probe result on Chromium guards against silent loss of the JSPI path.run_sync, the worker services Comlink messages — verified to work, but this means e.g.lintCodecan now run concurrently with a suspendedrunCodein the same interpreter. The sync transports never allowed that. Pyodide's webloop reentrant-task machinery handles it (enterTaskin the stack-switching module), but Papyros should still refuse/queue new work while a run is active if it doesn't already.run_syncnot working under the existing call convention or breaking the tracer — both disproven empirically.Impact on Dodona
@dodona/papyroswhen released (minor version).__SyncMessageServiceWorkerInput__failure family entirely./inputServiceWorker.js(config/routes.rb:315) stops being requested by nearly all traffic and thecoding_scratchpad.ts:66-79alert becomes a JavaScript-and-old-browser case. The route stays for those._handin.html.erb:138), andjavascriptmaps to the JS backend, so JavaScript exercises still register and still use the channel. Lazy registration handles that by registering on language selection rather than assuming Python.input()no longer reloads Pyodide on JSPI browsers (faster, keeps installed packages warm). Stopping a busy loop behaves as today.Reproducing the experiments
On a branch, with
yarn build:sw && yarn setupdone first (baseline must be green before drawing conclusions):JspiExperiment.test.ts(11 tests) — loads Pyodide 314.0.2 from CDN in the test page, exercisescan_run_sync/run_sync/rejections/JSONTracer-across-suspend.Backend.ts,PythonWorker.ts,papyros.py,Runner.ts) +JspiE2E.test.ts(8 tests) — full worker-stack validation,yarn vitest --run: 96/96 green.The prototype diff and both test files are inlined below.
Attachments
jspi-prototype.diff
JspiExperiment.test.ts (11 tests, isolated Pyodide behaviour)
JspiE2E.test.ts (8 tests, full worker stack)