Skip to content

Add JSPI as an input transport for the Python backend, and vendor the sync-message stack #1081

Description

@bmesuere

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 defexec() → sync callback), which is exactly Papyros' shape: run_asyncexecute/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-83runner_callback wraps the callback result: if hasattr(res, "then"): return run_sync(res).
  • src/frontend/state/Runner.tsprovideInput/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.typeKeyboardInterrupt) 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.typeKeyboardInterrupt 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):

  1. 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.
  2. 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);
    });
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions