The Run Cycle#
The data donation task is built on a co-routine pattern: Python and
JavaScript take turns. Python runs until it has something to show the
participant or something to send to the host, then it yields a command and
pauses. JavaScript handles the command, waits for a response (user interaction
or a host acknowledgement), and sends that response back to Python. Python
resumes with the response as the return value of the yield.
This alternation is the heartbeat of the whole system. Every UI interaction, every log message, every donation — all of it goes through this cycle.
Startup sequence#
Before the first run cycle can happen, Pyodide must be loaded and the port package installed. This takes several seconds on first load.
sequenceDiagram
participant MONO as Eyra mono
participant SHC as ScriptHostComponent
participant ASM as Assembly
participant WPE as WorkerProcessingEngine
participant WK as py_worker.js
MONO->>SHC: postMessage live-init (MessagePort, locale)
SHC->>ASM: LiveBridge.create() callback
ASM->>WPE: processingEngine.start()
WPE->>WK: postMessage initialise
WK->>WK: loadPyodide()
WK->>WK: loadPackage([micropip, numpy, pandas])
WK->>WK: micropip.install(port-*.whl)
WK->>WPE: postMessage initialiseDone
WPE->>MONO: sendSystemEvent("initialized")
WPE->>WK: postMessage firstRunCycle (sessionId, platform)
WK->>WK: pyScript = port.start(sessionId, platform)
WK->>WK: runCycle(null)
WK->>WPE: postMessage runCycleDone (first command)
port.start(sessionId, platform) calls main.start(), which creates a
ScriptWrapper around script.process(). The ScriptWrapper is a Python
generator. runCycle(null) calls pyScript.send(null) — the first send to a
generator must pass None, which advances it to its first yield.
The run cycle loop#
After startup, every cycle follows the same pattern:
sequenceDiagram
participant WPE as WorkerProcessingEngine
participant CR as CommandRouter
participant RE as ReactEngine
participant BR as Bridge
participant WK as py_worker.js
participant PY as Python (ScriptWrapper)
WPE->>CR: onCommand(command)
alt CommandUIRender
CR->>RE: visualizationEngine.render(page)
RE-->>CR: response (PayloadTrue/False/JSON/File)
CR-->>WPE: response
else CommandSystemDonate / CommandSystemLog / CommandSystemExit
CR->>BR: bridge.send(command)
BR-->>CR: response (PayloadVoid or PayloadResponse)
CR-->>WPE: response
end
WPE->>WK: postMessage nextRunCycle (response)
WK->>PY: pyScript.send(response.payload)
PY-->>WK: next command (via yield)
WK->>WPE: postMessage runCycleDone (command)
WPE->>WPE: logger.flush()
The cycle ends when Python raises StopIteration (the generator is
exhausted), at which point ScriptWrapper.send() returns
CommandSystemExit(0, "End of script").
File handling in the worker#
When the user selects a file, the response payload is PayloadFile, which
contains a browser File object. Browser File objects cannot be passed
directly into Python — they live in the JS thread and Python runs in the
worker thread.
The worker handles this in unwrap():
JS sends
nextRunCyclewithresponse.payload.__type__ == "PayloadFile"py_worker.jswraps theFilein aFileReaderSync-backed reader objectThis reader is sent to Python as a
PayloadFile.valuePython’s
AsyncFileAdapterwraps it, allowing synchronous.read()inside the workeruploads.materialize_file()writes the bytes to/tmpin Emscripten’s in-memory filesystem
The /tmp filesystem is in-memory and scoped to the worker — no data is
written to the participant’s disk, and it is freed when the worker terminates.
Key files#
File |
Role |
|---|---|
|
Worker entry point, |
|
|
|
|
|
Wires all JS components |
|
|
|
|
|
|
→ Command protocol — what Python can yield and what comes back