Skip to content

Commit 433bb9b

Browse files
authored
site: one shared compiler+Python runtime across pages, warmed in the background (#2)
The runnable-block worker is now a SharedWorker where supported (dedicated per-page worker as fallback, with in-flight replay if shared mode errors), so open tabs and the playground share a single live compiler and Pyodide instance; runs serialize inside the worker since pages share one interpreter. A SharedWorker still dies with its last page, so a same-tab navigation restarts it: for visitors who have used Run before (a localStorage flag), every page now warms the worker in the background at load, and the runtime is ready by the time they click. The docs widget also compiles inside the worker now, so lesson pages load no WASM on the main thread at all. Measured on lesson pages: first-ever run ~6.6s with the honest progress message; after a same-tab navigation ~1.0s (was ~6.3s); second tab ~0.6s; same-page rerun ~0.8s.
1 parent 76cb849 commit 433bb9b

5 files changed

Lines changed: 233 additions & 81 deletions

File tree

docs/theme/docs-run.js

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,24 @@
2020
}
2121
var playgroundBase = new URL(root + "playground/", location.href).href;
2222

23-
var compilerPromise = null;
23+
// Everything heavy (the compiler WASM and the Python runtime) lives in the worker
24+
// behind createRunner: a SharedWorker where supported, so open tabs share one
25+
// runtime. A same-tab navigation still restarts it (a SharedWorker dies with its
26+
// last page), so for visitors who have used Run before, plumbing() is called at
27+
// page load below and the runtime boots in the background while they read.
28+
var runnerPromise = null;
2429
var runner = null;
30+
var mod = null;
2531

2632
function plumbing() {
27-
if (!compilerPromise) {
28-
compilerPromise = import(playgroundBase + "pyfun-run.js").then(function (mod) {
29-
runner = mod.createRunner(playgroundBase);
30-
return mod.loadCompiler(playgroundBase);
33+
if (!runnerPromise) {
34+
runnerPromise = import(playgroundBase + "pyfun-run.js").then(function (m) {
35+
mod = m;
36+
runner = m.createRunner(playgroundBase);
37+
return runner;
3138
});
3239
}
33-
return compilerPromise;
40+
return runnerPromise;
3441
}
3542

3643
function attach(code) {
@@ -71,20 +78,22 @@
7178
out.className = "pyfun-run-out";
7279
out.textContent = runner && runner.loaded()
7380
? "running…"
74-
: "loading the compiler and Python runtime… (first run on a page downloads ~10 MB, then it is cached)";
81+
: "starting the compiler and Python runtime… (the first run on the site downloads ~10 MB; every page then shares the same live runtime)";
7582
btn.disabled = true;
7683
plumbing()
77-
.then(function (compile) {
78-
var result = compile(code.textContent);
79-
if (!result.ok) {
80-
var msgs = result.diagnostics.map(function (d) {
81-
return d.severity + ": " + d.message;
82-
});
83-
out.className = "pyfun-run-out pyfun-run-err";
84-
out.textContent = msgs.length ? msgs.join("\n") : "(nothing to compile)";
85-
return null;
86-
}
87-
return runner.run(result.python);
84+
.then(function (r) {
85+
mod.markRunnerUsed();
86+
return r.compile(code.textContent).then(function (result) {
87+
if (!result.ok) {
88+
var msgs = result.diagnostics.map(function (d) {
89+
return d.severity + ": " + d.message;
90+
});
91+
out.className = "pyfun-run-out pyfun-run-err";
92+
out.textContent = msgs.length ? msgs.join("\n") : "(nothing to compile)";
93+
return null;
94+
}
95+
return r.run(result.python);
96+
});
8897
})
8998
.then(function (res) {
9099
if (!res) return;
@@ -105,5 +114,22 @@
105114
});
106115
}
107116

108-
document.querySelectorAll("code.language-pyfun").forEach(attach);
117+
var blocks = document.querySelectorAll("code.language-pyfun");
118+
blocks.forEach(attach);
119+
120+
// Background warm-up for returning users: if this visitor has clicked Run before,
121+
// start the worker now so the runtime is ready by the time they click it here.
122+
if (blocks.length > 0) {
123+
import(playgroundBase + "pyfun-run.js")
124+
.then(function (m) {
125+
if (m.runnerWasUsed()) {
126+
plumbing().then(function (r) {
127+
r.warm();
128+
});
129+
}
130+
})
131+
.catch(function () {
132+
// No worker, no warm-up; the click path reports real errors.
133+
});
134+
}
109135
})();

playground/README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,13 @@ The playground is a normal web page, so record it natively on any OS — no `tty
6464
resilient `analyze`), as you type.
6565
- **Runs it.** The **Run** button executes the emitted Python in **CPython itself**,
6666
compiled to WebAssembly ([Pyodide](https://pyodide.org)), and shows stdout. Pyodide runs
67-
in a **Web Worker** (`pyodide-worker.js`), off the main thread, so loading the ~10 MB
68-
runtime (lazy, on first Run, then cached) and executing code never freeze the UI. Each run
69-
uses a fresh namespace and captures stdout/stderr via a `StringIO` redirect; a Python
70-
exception shows its traceback. Programs that only touch the stdlib (`json`, `sqlite3`,
67+
in a worker (`pyodide-worker.js`), off the main thread, so loading the ~10 MB runtime
68+
(lazy, on first Run) and executing code never freeze the UI. The worker is a
69+
**SharedWorker** where supported: one compiler + Python runtime serves this page AND
70+
every runnable code block on the docs site, so nothing reloads per page (browsers
71+
without module SharedWorkers fall back to a per-page worker; runs are serialized inside
72+
the worker since pages share one interpreter). Each run uses a fresh namespace and
73+
captures stdout/stderr via a `StringIO` redirect; a Python exception shows its traceback. Programs that only touch the stdlib (`json`, `sqlite3`,
7174
`math`, `statistics`, `dataclasses`, …) run as-is; an `extern` for a third-party package
7275
(`numpy`, `requests`) would need `micropip` (not wired up) and network calls don't work in
7376
the sandbox.

playground/web/app.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// edit, render the emitted Python + diagnostics, and — on demand — run that Python in
33
// CPython-via-WebAssembly (Pyodide). The compile/run plumbing lives in pyfun-run.js,
44
// shared with the docs site's runnable code blocks.
5-
import { loadCompiler, createRunner } from "./pyfun-run.js";
5+
import { loadCompiler, createRunner, markRunnerUsed, runnerWasUsed } from "./pyfun-run.js";
66

77
// Assigned in main() once the WASM loads: source -> { ok, python, diagnostics }.
88
let compileFn = null;
@@ -269,12 +269,13 @@ const runner = createRunner(import.meta.url);
269269

270270
runBtn.addEventListener("click", async () => {
271271
if (lastPython === null) return;
272+
markRunnerUsed();
272273
const code = lastPython;
273274
runOutput.hidden = false;
274275
runOutput.classList.remove("run-error");
275276
runOutput.textContent = runner.loaded()
276277
? "running…"
277-
: "loading Python runtime… (first run downloads ~10 MB, then it's cached)";
278+
: "starting Python runtime… (the first run on the site downloads ~10 MB; after that the docs and this page share one live runtime)";
278279
runBtn.disabled = true;
279280
try {
280281
const { out, err } = await runner.run(code);
@@ -303,6 +304,10 @@ window.addEventListener("hashchange", () => {
303304
});
304305

305306
async function main() {
307+
// Returning users get the Python runtime booted in the background so Run is warm.
308+
if (runnerWasUsed()) {
309+
runner.warm();
310+
}
306311
compileFn = await loadCompiler(import.meta.url);
307312
editor.value = sourceFromHash() ?? EXAMPLES[0].source;
308313
render();

playground/web/pyfun-run.js

Lines changed: 92 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,43 +14,103 @@ export async function loadCompiler(base) {
1414
return (source) => JSON.parse(mod.compile(source));
1515
}
1616

17-
// A lazy Pyodide runner. The worker (and Pyodide's ~10 MB runtime) loads on the
18-
// first run() and is reused after; run() resolves with { out, err }.
17+
// A lazy runner over pyodide-worker.js. Preferred channel: a SharedWorker, so ONE
18+
// compiler + Python runtime serves every page on the site (the first run anywhere
19+
// pays the load; every later page connects to the live instance). Browsers without
20+
// module SharedWorkers (e.g. Firefox) fall back to a per-page dedicated worker.
21+
//
22+
// run(code) -> Promise<{ out, err }> execute emitted Python
23+
// compile(src) -> Promise<{ ok, python, diagnostics }> compile Pyfun in the worker
1924
export function createRunner(base) {
20-
let worker = null;
2125
let seq = 0;
22-
const pending = new Map();
23-
24-
function ensureWorker() {
25-
if (!worker) {
26-
worker = new Worker(new URL("./pyodide-worker.js", base), { type: "module" });
27-
worker.onmessage = (event) => {
28-
const { id, out, err } = event.data;
29-
const resolve = pending.get(id);
30-
if (resolve) {
31-
pending.delete(id);
32-
resolve({ out, err });
33-
}
34-
};
35-
worker.onerror = (e) => {
36-
for (const resolve of pending.values()) {
37-
resolve({ out: "", err: "worker failed to start: " + (e.message || e) });
38-
}
39-
pending.clear();
40-
};
26+
let post = null;
27+
const pending = new Map(); // id -> resolve
28+
const inflight = new Map(); // id -> message (replayed if shared mode fails)
29+
30+
function onMessage(event) {
31+
const resolve = pending.get(event.data.id);
32+
if (resolve) {
33+
pending.delete(event.data.id);
34+
inflight.delete(event.data.id);
35+
resolve(event.data);
36+
}
37+
}
38+
39+
function useDedicated(url) {
40+
const w = new Worker(url, { type: "module" });
41+
w.onmessage = onMessage;
42+
w.onerror = (e) => {
43+
for (const resolve of pending.values()) {
44+
resolve({ out: "", err: "worker failed to start: " + (e.message || e) });
45+
}
46+
pending.clear();
47+
inflight.clear();
48+
};
49+
post = (m) => w.postMessage(m);
50+
}
51+
52+
function ensureChannel() {
53+
if (post) return;
54+
const url = new URL("./pyodide-worker.js", base);
55+
if (typeof SharedWorker !== "undefined") {
56+
try {
57+
const sw = new SharedWorker(url, { type: "module", name: "pyfun-runner" });
58+
sw.port.onmessage = onMessage;
59+
sw.onerror = () => {
60+
// The script failed in shared mode (typically: module SharedWorkers
61+
// unsupported). Rebuild as a per-page worker and replay what was queued.
62+
useDedicated(url);
63+
for (const msg of inflight.values()) post(msg);
64+
};
65+
sw.port.start();
66+
post = (m) => sw.port.postMessage(m);
67+
return;
68+
} catch {
69+
// Constructor refused outright; use the dedicated path below.
70+
}
4171
}
42-
return worker;
72+
useDedicated(url);
73+
}
74+
75+
function send(msg) {
76+
ensureChannel();
77+
inflight.set(msg.id, msg);
78+
return new Promise((resolve) => {
79+
pending.set(msg.id, resolve);
80+
post(msg);
81+
});
4382
}
4483

4584
return {
46-
loaded: () => worker !== null,
47-
run(code) {
48-
const w = ensureWorker();
49-
const id = ++seq;
50-
return new Promise((resolve) => {
51-
pending.set(id, resolve);
52-
w.postMessage({ id, code });
53-
});
54-
},
85+
loaded: () => post !== null,
86+
run: (code) => send({ id: ++seq, code }).then(({ out, err }) => ({ out, err })),
87+
compile: (source) =>
88+
send({ id: ++seq, source }).then(({ compiled, err }) => {
89+
if (!compiled) throw new Error(err || "compiler failed to load");
90+
return compiled;
91+
}),
92+
// Boot the compiler and Python runtime without running anything, so a page can
93+
// warm the worker in the background before the user clicks Run.
94+
warm: () => send({ id: ++seq, warm: true }),
5595
};
5696
}
97+
98+
// The localStorage flag marking that this visitor has used Run before; pages check it
99+
// to decide whether background-warming the runtime is worth the download/CPU.
100+
export const WARM_FLAG = "pyfun-runner-used";
101+
102+
export function markRunnerUsed() {
103+
try {
104+
localStorage.setItem(WARM_FLAG, "1");
105+
} catch {
106+
// Storage can be unavailable (privacy modes); warming is just an optimization.
107+
}
108+
}
109+
110+
export function runnerWasUsed() {
111+
try {
112+
return localStorage.getItem(WARM_FLAG) === "1";
113+
} catch {
114+
return false;
115+
}
116+
}

0 commit comments

Comments
 (0)