Repository navigation
Let several Papyros instances share one runtime - #1168
Conversation
6d21dc6 to
b0a5436
Compare
b0a5436 to
b1ea616
Compare
bmesuere
left a comment
There was a problem hiding this comment.
🤖 The shared runtime makes the run state in Runner harder to keep right, and I think a small design change would make that much easier, before #1170 builds on it.
The problem. "Loading packages" is stored as a run state with a one-slot undo: onLoad saves the current state in previousState and restores it afterwards. But setState overwrites previousState on every change, so whatever gets restored is whatever happened to be there last. The shared runtime adds more ways to hit this, because Loading events now reach every instance and the isBusyFor gate in the Loading subscription is checked separately for the "loading" and the "loaded" of a pair. Two cases fail against the fakeClient from SharedRuntime.test.ts (tests below):
- A's editor lint starts installing numpy while nothing runs, then B clicks Run before the install finishes. A skips the "loaded" event because B is running, so A stays in
LoadingwithloadingPackages = ["numpy"]. Every playground lints right after boot, so this window is easy to hit on a page with several blocks. - An idle instance that has run before (so
previousStateisRunning) gets a "loaded" without having seen its "loading".onLoadrestorespreviousState, and the idle instance now reportsrunning.provideFileson a shared runtime does this without any race, because the worker's per-file "loaded" events go to every instance.
A related case: stop() checks isBusyFor before await this.availableBackend(), then interrupts whatever is running on the shared client. If another instance starts a run during that await, stop() interrupts the other instance's run.
Proposal. Make loading a separate property instead of a state, and let only the running instance track it:
// written only by the run lifecycle: start, stop, launch, onStart, onEnd
@stateProperty phase: RunState = RunState.Ready;
@stateProperty get state(): RunState {
return this.loadingPackages.length > 0 ? RunState.Loading : this.phase;
}onLoad then only adds and removes package names and never touches the run state, and previousState goes away. A stray or unmatched event can at worst leave a name in the list, and never puts an instance in the wrong state. On a shared runtime, only the instance that holds the runtime processes Loading events; the editors still get them through the event bus and relint as before. Nothing outside Runner compares against RunState.Loading, so the public API doesn't change.
Two things would make this hold up as the playground grows:
- Model-based tests on top of
fakeClient. Run random sequences (A or B starts, stops, lint loading/loaded/failed, end, restart, dispose) and after each step check invariants: at most one instance is running; an instance that isn't running is neverRunningorAwaitingInput; everystart()promise settles. A seeded random loop is enough, with no new dependency. This catches the interleavings that are hard to think of as single tests, which is where the risk in this PR is. - Later, give each run an object with an id.
tryAcquirewould return aRun { id, owner }and the worker would put that id on every event of the run. Run events would then be routed by id. Events without an id come from lint and change no run state. Events from runs or workers that no longer exist would be dropped.stop()would interrupt only its ownRun. This replaces theowner, late-output andisBusyForrules with one exact rule, but it needs a change in both workers, so a follow-up issue makes more sense than this PR.
I'd do the first part (separate loading property, running instance only) in this PR or right after it. The tests would be good to have before #1170, and the run id can wait.
Repro tests (drop into SharedRuntime.test.ts)
const loading = (status: string): BackendEvent => ({
type: BackendEventType.Loading,
data: { modules: ["numpy"], status },
contentType: "application/json",
});
it("does not leave an idle instance loading when a run starts mid-install", async () => {
const client = fakeClient();
const { runtime, a, b } = await sharing(() => client);
client.emit(loading("loading"));
const running = b.runner.start();
await vi.waitFor(() => expect(client.workerProxy.runCode).toHaveBeenCalled());
client.emit(loading("loaded"));
client.emit(end);
client.finishRun();
await running;
expect(a.runner.state).toBe(RunState.Ready);
runtime.dispose();
});
it("ignores a loaded event an idle instance never saw loading for", async () => {
const client = fakeClient();
const { runtime, a, b } = await sharing(() => client);
let running = b.runner.start();
await vi.waitFor(() => expect(client.workerProxy.runCode).toHaveBeenCalled());
client.emit(start);
client.emit(end);
client.finishRun();
await running;
running = a.runner.start();
await vi.waitFor(() => expect(client.workerProxy.runCode).toHaveBeenCalledTimes(2));
client.emit(loading("loading"));
client.emit(end);
client.finishRun();
await running;
client.emit(loading("loaded"));
expect(b.runner.state).toBe(RunState.Ready);
runtime.dispose();
});b1ea616 to
da5a2a0
Compare
|
🤖 @bmesuere your design review is in:
|
da5a2a0 to
41d325c
Compare
41d325c to
fa31952
Compare
|
🤖 Rebase notes for #1194 (reviewed at 910fb15). From this PR on,
|
fa31952 to
a5d61c1
Compare
|
🤖 Rebased onto main as a5d61c1; the #1194 note above is applied: |
a5d61c1 to
9966107
Compare
|
🤖 Simplify pass after the rebase (now 9966107):
|
33a13a5 to
eba4dda
Compare
|
🤖 note: Fixed a CI-only hang the simplify pass introduced in this PR (eba4dda). Since the boot became the |
|
🤖 Blocking finding. Reviewed with the code-review skill (escalated because the diff is large and this PR changes how student code is run on a shared worker), with a checkout of the PR head. Covers the full PR against its base #1169 (step 2 of the stack in dodona-edu/dodona#9402).
The skill raised 10 candidates; I verified each against the code and dropped 7:
Generated by Claude Code |
eba4dda to
10a890e
Compare
|
🤖 note: Changed since the approval (10a890e): fixes for three review-routine findings.
The seeded model test gained a |
A new PapyrosRuntime owns the backend workers and the input channel.
Instances given the same runtime share one worker per language, so a page
with many editors boots Pyodide once. Without a runtime, an instance gets a
private one, as before.
Only one instance runs code at a time. Runner.start() claims the runtime with
tryAcquire, which returns a Run with an id unique to the runtime, or null
while another run is in progress. The id travels with runCode to the worker,
and Backend puts it on every event of runCode until it settles. The events of
other worker calls, such as linting or provided files, carry no id, also when
they run while a run waits in input() under JSPI: the Python worker hands
those calls a callback of their own.
The runtime routes events by that id. An event of the most recent run goes
to the instance that started it, also after the run ended, since the worker
tags an event when it emits it and the event can arrive after runCode
settled. Events of any earlier run are dropped, as are events from a worker
that has been replaced. Loading events without an id reach every instance,
so an editor that shows an import error relints after a package install;
Python lint diagnostics carry Pylint's message id for that. A shared runtime drops the other
events without an id, such as output of code that outlived its run or the
Files snapshot of provideFiles, since it cannot tell which instance they
belong to; a private runtime passes them to its instance. Runner.stop() and
provideInput() act only on the instance's own run, which they read from
runtime.currentRun, and the debugger relies on this routing to keep frames
of a previous run out of the trace.
Package loading and the boot are properties next to the run phase instead of
run states of their own: Runner.phase is written only by the run lifecycle
and a failed launch, and Runner.state reads Loading while launching is true or
loadingPackages is not empty. On a shared
runtime only the running instance tracks the installs of its run, and it
forgets them when the run ends. On a private runtime, installs triggered by
linting still show as Loading.
Runner.state stays Loading until the worker is up, so the sandbox shows the
boot. A run started during it waits for the worker, and the sandbox offers Run
until a run of its own holds the runtime. The language and example pickers
follow the same rule, so they stay enabled during the boot. A launch whose
worker is replaced, for instance because the instance that waited for it was
disposed, carries on with the replacement worker instead of waiting forever,
without an error. Runner.ensureLaunched() launches the backend without
the alert of Papyros.launch(), for callers that report a failure themselves.
Runner.start(mode, { files }) starts a run from a workspace that holds only
the given files: the run clears the workspace and writes them during its
Loading phase, before the code runs, and skips the code when Stop was pressed
in the meantime.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
10a890e to
129272a
Compare
This pull request lets several
Papyrosinstances share one runtime:new Papyros({ runtime }). It is step 2 of 5 that turn Papyros into a component library offering both the sandbox and a small code playground for Dodona's reading activities (asked for on dodona-edu/dodona#9211). This step is state only: no UI.Stacked on #1169. It also contains the change from #1159 (approved, closed so the stack reads as one series):
runner.start(mode, { files })(next to main'seditorTaboption) lets a run start from a workspace holding only the given files, written during the run's Loading phase and skipped when Stop was pressed meanwhile. The shared runtime relies on it: every playground run declares its own files.<p-code-playground>, ported from DodonaThe Dodona side: dodona-edu/dodona#9304 (thin wrapper) and #9305 (files).
Why
A reading activity can hold many playgrounds. Each needs its own editor, output and files, so each gets its own
Papyrosinstance, and every existing component (which takes.papyros) works per playground as is. But each instance used to start its own Pyodide worker. A shared runtime keeps one worker per language for all of them.How to review
Moved without changes (from
RunnerandPapyrosintoPapyrosRuntime): the worker clients per language, the worker launch and its retry handling, the input channel and service worker registration.Runner/Papyroskeep delegating accessors, so the public API is unchanged.New, worth reading closely (
PapyrosRuntime.ts, then theRunner.tsdiff):start()claims the runtime before it touches any state, and a refusedstart()leaves the instance untouched. A claim is aRun { id, owner }, andBackendputs that id on every event the worker emits while the run's code runs, so both workers send it without worker-specific code.PapyrosRuntime.dispatchroutes by that id alone: an event of the latest run goes to its owner, also when it arrives after the run settled; an event of an earlier run, or from a replaced worker, is dropped. Loading events go to every instance (every editor relints on them), but only the running instance tracks the install. Other events without an id (output from an asyncio task or asetTimeoutthat outlived its run,provideFiles' Files snapshot) are dropped on a shared runtime and still delivered on a private one.RunnerandDebuggerdon't route themselves anymore: they read the run from the runtime. (Proposed by @bmesuere in review; closes Route worker events by run id on a shared runtime #1187.)statereadsLoadingwhilelaunchingis true (the scratchpad shows "Loading" while Python downloads, and Run stays available and queues the run), instead of reportingReadybefore the download even started. A Pyodide boot failure is remembered on the runtime, so the other instances get the sameUnsupportedBrowserErrorinstead of each booting a new worker that fails the same way.phaseis the run itself, andstatereadsLoadingwhileloadingPackagesisn't empty. A Loading event can therefore no longer leave an instance in a wrong state, and on a shared runtime only the running instance tracks installs.stop()andprovideInput()check again after awaiting the backend, so they never act on another instance's run.E0401): every editor on a shared runtime receives the install's Loading events, and re-linting all of them queued one pylint run per editor on the one worker.stop()andprovideInput()from another instance do nothing; a stuck stop replaces the worker and frees it for the others; recovery runs once per runtime and doesn't replay files (each shared run writes its own throughstart(mode, { files })); disposing one instance leaves the worker to the others.runner.ensureLaunched(): launches once and rejects on failure, without the alert/confirm thatpapyros.launch()shows per instance.Decisions worth questioning: one
Papyrosper playground instead of one shared instance with swapped state (that was Dodona's scheduler, and it breaks as soon as components bind topapyros.io); no counters or flags for unlikely races.Behaviour changes without a shared runtime:
new Papyros()gets a private runtime, so the sandbox works as before (MultiInstance.test.tspasses unchanged), except: a secondstart()while running is now ignored instead of wiping the first run's output,provideInput()outside a run does nothing, and a stuck stop now really replaces the worker (before, its relaunch was a no-op on a live worker).Manual testing instructions
yarn vitest run --exclude '**/.claude/**'test/__tests__/state/SharedRuntime.test.ts: one test per rule above.Checklist
SharedRuntime.test.ts, including @bmesuere's repro cases and a seeded model-based test that runs random sequences of starts, stops, input, lint events, restarts and disposals over two instances and checks invariants after every step; full suite green locally (398), withFiles.test.tsandRuntimeRecovery.test.tscases from Let a run start from a workspace with only the given files #1159🤖 Generated with Claude Code