Skip to content

Commit db5994b

Browse files
feat(playground): engine radio (minipy / real Python), Pyodide warmed on open and cached
An "Engine" radio above the editor swaps between the step-through interpreter (minipy, default) and real Python (Pyodide in a Web Worker); the choice is remembered. In real-Python mode edits re-run automatically and the output and figures take the right column. The runtime starts downloading shortly after a playground opens; the worker keeps everything fetched from the Pyodide base in Cache Storage, so later visits start from the cache. Runs are serialized (newest wins), stopped after 15 s, and the worker restarts from the cache. The service worker no longer deletes the Pyodide cache. Replaces the separate card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ed84703 commit db5994b

14 files changed

Lines changed: 542 additions & 243 deletions

File tree

‎docs/log.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77

88
## 进展时间线
99

10+
### 2026-10-08 — 实验台引擎切换:逐步演示(minipy)/ 真实 Python(Pyodide)(按用户要求)
11+
- **feat**:NumPy、matplotlib、pandas 实验台的编辑器上方新增「运行引擎」单选:逐步演示(minipy,默认)或真实 Python(Pyodide,Web Worker)。选真实 Python 后,代码改动后自动在真实库中运行,输出(含版本与耗时)和 matplotlib 图显示在右栏,逐步可视化暂停;选择记在 `knowcs-py-engine`,全站共享。PyTorch、Keras、TensorFlow 中该选项置灰并说明没有浏览器版本。
12+
- **feat**:打开实验台约 1.2 秒后在后台下载并启动 Python 运行时(省流量模式下不预载);各包仍按导入按需下载。worker 把从 Pyodide 地址取到的文件(wasm、标准库、lock、各 wheel)存入 Cache Storage(`knowcs-pyodide:<base>`,换版本时清掉旧缓存),再次打开约 1.3 秒就绪。站点 Service Worker 激活时不再删除该缓存。
13+
- **feat**:同一时间只运行一段代码,新的请求替换排队中的旧请求;单次运行超过 15 秒自动停止(提示是否死循环),worker 随即从缓存重启;运行中可手动停止。
14+
- **change**:替换上一版的「用真实 Python 运行」卡片。浏览器检查:预载、缓存条目、自动运行、超时与重启、刷新后从缓存启动、matplotlib 出图、PyTorch 置灰,无控制台错误;390 宽无横向滚动,对比度通过。
15+
1016
### 2026-10-08 — 实验台加「用真实 Python 运行」(Pyodide,按用户要求)
1117
- **feat**:NumPy、matplotlib、pandas 实验台(实验场页与笔记旁)在输出下方新增「用真实 Python 运行」卡片:点按钮后才在 Web Worker 里加载 Pyodide 314.0.7(CPython 3.14.2),用 `loadPackagesFromImports` 只下载代码导入的包(NumPy 2.4.6、pandas 3.0.2、matplotlib 3.10.8、scikit-learn 1.8.0、SciPy、OpenCV),seaborn 不在内置包里,导入时用 micropip 安装。每次运行用新的命名空间;显示 print、stderr、最后一个表达式的 repr、Python 3.14 的 traceback 和 matplotlib 图(PNG);可随时停止(直接终止 worker)。minipy 仍是默认引擎,逐步可视化不变;PyTorch、Keras、TensorFlow 没有 Pyodide 版本,不显示该卡片。
1218
- **config**:资源地址默认 `https://cdn.jsdelivr.net/pyodide/v314.0.7/full/`,构建时可用 `VITE_PYODIDE_BASE` 改为自托管或 COS + CDN(jsDelivr 在内地不稳定,目前未自托管)。首屏不加载任何 Pyodide 资源;首次运行约 7 MB(仅 Python)到 37 MB(含 scikit-learn),浏览器缓存后再次运行约 60 ms。下载失败时提示所用域名。

‎src/lib/pyodide/client.ts‎

Lines changed: 100 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,82 @@
1-
// The page's side of real Python: one shared worker, started on the first run and terminated by Stop.
1+
// The page's side of real Python: one shared worker. It boots in the background once a playground opens (warmReal),
2+
// runs one piece of code at a time (a newer request replaces one still waiting), and Stop or a run that takes too
3+
// long terminates it; a fresh worker then starts again from the cache.
24
import { pyodideBase } from './config'
35
import type { FromWorker, RealPhase, RealResult } from './protocol'
46

57
export type { RealPhase, RealResult } from './protocol'
68

79
/** Python or a package could not be downloaded (offline, CDN blocked …) */
810
export class RealLoadError extends Error {}
9-
/** the run was stopped (Stop, or the worker was replaced) */
11+
/** the run was stopped (Stop) */
1012
export class RealStopped extends Error {}
13+
/** the run took longer than RUN_LIMIT_MS (an endless loop?) */
14+
export class RealTimeout extends Error {}
15+
/** a newer run replaced this one before it started */
16+
export class RealSuperseded extends Error {}
17+
18+
/** the most a run may take once its packages are loaded */
19+
export const RUN_LIMIT_MS = 15_000
20+
21+
export type EngineState = 'cold' | 'loading' | 'ready' | 'failed'
22+
23+
interface Job {
24+
code: string
25+
onStatus: (s: RealPhase) => void
26+
resolve: (r: RealResult) => void
27+
reject: (e: Error) => void
28+
}
1129

1230
let worker: Worker | null = null
13-
let warm = false
31+
let engine: EngineState = 'cold'
32+
let seenReady = false
33+
const listeners = new Set<(s: EngineState) => void>()
1434
let nextId = 0
15-
const pending = new Map<number, { onStatus: (s: RealPhase) => void; resolve: (r: RealResult) => void; reject: (e: Error) => void }>()
35+
let current: (Job & { id: number }) | null = null
36+
let queued: Job | null = null
37+
let timer: ReturnType<typeof setTimeout> | undefined
38+
39+
const setEngine = (s: EngineState) => {
40+
engine = s
41+
if (s === 'ready') seenReady = true
42+
listeners.forEach((f) => f(s))
43+
}
44+
45+
export const engineState = () => engine
46+
/** has Python started in this tab before (a restart after Stop then comes from the cache: no size warning) */
47+
export const engineSeenReady = () => seenReady
48+
export const onEngineState = (f: (s: EngineState) => void) => {
49+
listeners.add(f)
50+
return () => void listeners.delete(f)
51+
}
52+
53+
const finish = () => {
54+
clearTimeout(timer)
55+
current = null
56+
const next = queued
57+
queued = null
58+
if (next) start(next)
59+
}
1660

1761
const spawn = (): Worker => {
1862
const w = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
1963
w.onmessage = (e: MessageEvent<FromWorker>) => {
2064
const m = e.data
21-
const p = pending.get(m.id)
22-
if (!p) return
23-
if (m.type === 'status') return p.onStatus(m.status)
24-
pending.delete(m.id)
25-
if (m.type === 'done') {
26-
warm = true
27-
p.resolve(m.result)
28-
} else {
65+
if (m.type === 'engine') {
66+
if (m.state === 'failed') console.warn('[real python]', m.message)
67+
return setEngine(m.state)
68+
}
69+
if (!current || m.id !== current.id) return
70+
const job = current
71+
if (m.type === 'status') {
72+
if (m.status.phase === 'running') timer = setTimeout(() => stopReal(new RealTimeout('timeout')), RUN_LIMIT_MS)
73+
return job.onStatus(m.status)
74+
}
75+
finish()
76+
if (m.type === 'done') job.resolve(m.result)
77+
else {
2978
console.warn('[real python]', m.message)
30-
p.reject(new RealLoadError(m.message))
79+
job.reject(new RealLoadError(m.message))
3180
}
3281
}
3382
w.onerror = (e) => {
@@ -37,24 +86,51 @@ const spawn = (): Worker => {
3786
return w
3887
}
3988

40-
/** has Python already been downloaded and started in this tab? */
41-
export const isRealWarm = () => warm
89+
const ensureWorker = (): Worker => {
90+
if (!worker) {
91+
worker = spawn()
92+
setEngine('loading')
93+
}
94+
return worker
95+
}
96+
97+
function start(job: Job) {
98+
const w = ensureWorker()
99+
if (engine === 'failed') setEngine('loading') // the worker tries the download again
100+
current = { ...job, id: ++nextId }
101+
w.postMessage({ id: current.id, base: pyodideBase(), type: 'run', code: job.code })
102+
}
103+
104+
/** download and start Python in the background (the packages still wait for an import) */
105+
export function warmReal(): void {
106+
if (worker && engine !== 'failed') return
107+
if (engine === 'failed') stopWorker()
108+
ensureWorker().postMessage({ id: 0, base: pyodideBase(), type: 'boot' })
109+
}
42110

43111
/** run code with real CPython and the real libraries; onStatus reports downloading and running */
44112
export function runReal(code: string, onStatus: (s: RealPhase) => void): Promise<RealResult> {
45-
worker ??= spawn()
46-
const id = ++nextId
47113
return new Promise<RealResult>((resolve, reject) => {
48-
pending.set(id, { onStatus, resolve, reject })
49-
worker!.postMessage({ id, base: pyodideBase(), code })
114+
const job = { code, onStatus, resolve, reject }
115+
if (!current) return start(job)
116+
queued?.reject(new RealSuperseded('superseded'))
117+
queued = job
50118
})
51119
}
52120

53-
/** stop whatever is running: the worker is thrown away, so the next run starts Python again (from the browser cache) */
54-
export function stopReal(reason: Error = new RealStopped('stopped')): void {
121+
function stopWorker() {
55122
worker?.terminate()
56123
worker = null
57-
warm = false
58-
for (const p of pending.values()) p.reject(reason)
59-
pending.clear()
124+
setEngine('cold')
125+
}
126+
127+
/** stop whatever is running; a new worker boots again at once (from the cache), so the next run is quick */
128+
export function stopReal(reason: Error = new RealStopped('stopped')): void {
129+
clearTimeout(timer)
130+
const jobs = [current, queued]
131+
current = null
132+
queued = null
133+
stopWorker()
134+
jobs.forEach((j) => j?.reject(reason))
135+
if (!(reason instanceof RealLoadError)) warmReal()
60136
}

‎src/lib/pyodide/protocol.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ export interface RealResult {
2323

2424
export type RealPhase = { phase: 'runtime' } | { phase: 'packages'; names: string[] } | { phase: 'running' }
2525

26-
export type ToWorker = { id: number; base: string; code: string }
26+
/** run code, or (type 'boot') only download and start Python so a later run is instant */
27+
export type ToWorker = { id: number; base: string; type: 'run'; code: string } | { id: number; base: string; type: 'boot' }
2728

2829
export type FromWorker =
2930
| { id: number; type: 'status'; status: RealPhase }
3031
| { id: number; type: 'done'; result: RealResult }
3132
/** Python or its packages could not be downloaded */
3233
| { id: number; type: 'failed'; message: string }
34+
/** the runtime itself: started (ready) or could not be downloaded (failed) */
35+
| { type: 'engine'; state: 'ready' | 'failed'; message?: string }

‎src/lib/pyodide/worker.ts‎

Lines changed: 85 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
/// <reference lib="webworker" />
22
// The Pyodide worker: downloads CPython (once) and the packages each run imports, then runs the code with harness.py.
33
// It lives off the main thread so a long run never freezes the page, and Stop can simply terminate it.
4+
// Everything fetched from the Pyodide base (the wasm runtime, the standard library, package wheels) is kept in
5+
// Cache Storage, so the next visit starts Python without downloading it again.
46
import HARNESS from './harness.py?raw'
57
import { MICROPIP_IMPORTS, packageOf } from './config'
68
import type { FromWorker, ToWorker } from './protocol'
79

810
interface PyProxy {
911
(...args: unknown[]): unknown
10-
toJs(): unknown
1112
destroy(): void
1213
}
1314
interface Pyodide {
@@ -21,15 +22,55 @@ interface Pyodide {
2122
const post = (m: FromWorker) => self.postMessage(m)
2223
const quiet = () => {}
2324

25+
const CACHE_PREFIX = 'knowcs-pyodide:'
26+
27+
/** fetch from the Pyodide base through Cache Storage: cache first, store what was downloaded; old versions are dropped */
28+
const cacheFetches = (base: string) => {
29+
if (typeof caches === 'undefined') return // not a secure context: the browser's HTTP cache still helps
30+
const name = CACHE_PREFIX + base
31+
void caches.keys().then((keys) => Promise.all(keys.filter((k) => k.startsWith(CACHE_PREFIX) && k !== name).map((k) => caches.delete(k)))).catch(quiet)
32+
const plain = self.fetch.bind(self)
33+
self.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
34+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
35+
if (!url.startsWith(base) || (init?.method ?? 'GET') !== 'GET') return plain(input, init)
36+
let cache: Cache
37+
try {
38+
cache = await caches.open(name)
39+
const hit = await cache.match(url)
40+
if (hit) return hit
41+
} catch {
42+
return plain(input, init)
43+
}
44+
const res = await plain(input, init)
45+
if (res.ok) cache.put(url, res.clone()).catch(quiet) // a full disk only costs the cache
46+
return res
47+
}
48+
}
49+
2450
let booting: Promise<Pyodide> | null = null
2551

2652
const boot = async (base: string): Promise<Pyodide> => {
53+
cacheFetches(base)
2754
const mod = (await import(/* @vite-ignore */ `${base}pyodide.mjs`)) as { loadPyodide(o: object): Promise<Pyodide> }
2855
const py = await mod.loadPyodide({ indexURL: base, stdout: quiet, stderr: quiet })
2956
py.runPython(HARNESS)
3057
return py
3158
}
3259

60+
const start = (base: string): Promise<Pyodide> => {
61+
if (!booting) {
62+
booting = boot(base)
63+
booting.then(
64+
() => post({ type: 'engine', state: 'ready' }),
65+
(err) => {
66+
booting = null
67+
post({ type: 'engine', state: 'failed', message: String(err) })
68+
},
69+
)
70+
}
71+
return booting
72+
}
73+
3374
/** the imports that still need downloading (not built in, not loaded yet) */
3475
const missing = (py: Pyodide, code: string): string[] => {
3576
const find = py.globals.get('knowcs_missing')
@@ -40,50 +81,49 @@ const missing = (py: Pyodide, code: string): string[] => {
4081
}
4182
}
4283

43-
let queue: Promise<unknown> = Promise.resolve()
44-
45-
self.onmessage = (e: MessageEvent<ToWorker>) => {
46-
const { id, base, code } = e.data
47-
// one run at a time: Python has one global interpreter
48-
queue = queue.then(async () => {
49-
const started = performance.now()
50-
let py: Pyodide
51-
try {
52-
if (!booting) post({ id, type: 'status', status: { phase: 'runtime' } })
53-
booting ??= boot(base)
54-
py = await booting
55-
} catch (err) {
56-
booting = null
57-
post({ id, type: 'failed', message: String(err) })
58-
return
59-
}
60-
try {
61-
const need = missing(py, code)
62-
if (need.length) {
63-
post({ id, type: 'status', status: { phase: 'packages', names: need.map(packageOf) } })
64-
await py.loadPackagesFromImports(code, { messageCallback: quiet })
65-
const extra = need.filter((m) => m in MICROPIP_IMPORTS)
66-
if (extra.length) {
67-
await py.loadPackage('micropip', { messageCallback: quiet })
68-
const micropip = py.pyimport('micropip')
69-
for (const m of extra) await micropip.install(MICROPIP_IMPORTS[m])
70-
}
84+
const run = async (id: number, base: string, code: string) => {
85+
const started = performance.now()
86+
let py: Pyodide
87+
try {
88+
if (!booting) post({ id, type: 'status', status: { phase: 'runtime' } })
89+
py = await start(base)
90+
} catch (err) {
91+
post({ id, type: 'failed', message: String(err) })
92+
return
93+
}
94+
try {
95+
const need = missing(py, code)
96+
if (need.length) {
97+
post({ id, type: 'status', status: { phase: 'packages', names: need.map(packageOf) } })
98+
await py.loadPackagesFromImports(code, { messageCallback: quiet })
99+
const extra = need.filter((m) => m in MICROPIP_IMPORTS)
100+
if (extra.length) {
101+
await py.loadPackage('micropip', { messageCallback: quiet })
102+
const micropip = py.pyimport('micropip')
103+
for (const m of extra) await micropip.install(MICROPIP_IMPORTS[m])
71104
}
72-
} catch (err) {
73-
post({ id, type: 'failed', message: String(err) })
74-
return
75105
}
76-
post({ id, type: 'status', status: { phase: 'running' } })
77-
const run = py.globals.get('knowcs_run')
78-
const versions = py.globals.get('knowcs_versions')
79-
try {
80-
const out = JSON.parse(run(code, /\bmatplotlib\b|\bseaborn\b/.test(code)) as string)
81-
post({ id, type: 'done', result: { ...out, versions: JSON.parse(versions() as string), ms: Math.round(performance.now() - started) } })
82-
} catch (err) {
83-
post({ id, type: 'failed', message: String(err) })
84-
} finally {
85-
run.destroy()
86-
versions.destroy()
87-
}
88-
})
106+
} catch (err) {
107+
post({ id, type: 'failed', message: String(err) })
108+
return
109+
}
110+
post({ id, type: 'status', status: { phase: 'running' } })
111+
const exec = py.globals.get('knowcs_run')
112+
const versions = py.globals.get('knowcs_versions')
113+
try {
114+
const out = JSON.parse(exec(code, /\bmatplotlib\b|\bseaborn\b/.test(code)) as string)
115+
post({ id, type: 'done', result: { ...out, versions: JSON.parse(versions() as string), ms: Math.round(performance.now() - started) } })
116+
} catch (err) {
117+
post({ id, type: 'failed', message: String(err) })
118+
} finally {
119+
exec.destroy()
120+
versions.destroy()
121+
}
122+
}
123+
124+
// the page sends one run at a time (client.ts queues them), so runs never overlap here
125+
self.onmessage = (e: MessageEvent<ToWorker>) => {
126+
const m = e.data
127+
if (m.type === 'boot') void start(m.base).catch(quiet)
128+
else void run(m.id, m.base, m.code)
89129
}

‎src/locales/en.json‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3936,21 +3936,30 @@
39363936
"prev": "Previous step",
39373937
"next": "Next step",
39383938
"real": {
3939-
"title": "Run in real Python",
3940-
"engine": "Pyodide · CPython running in your browser",
3941-
"what": "The step-by-step views come from KnowCS's own interpreter. This runs the same code with the real NumPy, pandas and matplotlib (scikit-learn, SciPy and OpenCV work too), so you can check the output.",
39423939
"size": "The first run downloads Python and the libraries your code imports: about 7 MB for Python alone, 10 MB with NumPy, 20 MB with pandas and matplotlib, 37 MB with scikit-learn. Your browser keeps them for next time.",
3943-
"run": "Run in real Python",
3944-
"rerun": "Run again",
39453940
"stop": "Stop",
39463941
"phase_runtime": "Downloading Python…",
39473942
"phase_packages": "Downloading {{names}}…",
39483943
"phase_running": "Running…",
39493944
"took": "{{ms}} ms",
3950-
"stale": "The code has changed since this run.",
39513945
"failed": "Couldn't download Python from {{host}}. Check your connection, then try again.",
39523946
"stopped": "Stopped.",
3953-
"figure": "Figure {{n}}, drawn by real matplotlib"
3947+
"figure": "Figure {{n}}, drawn by real matplotlib",
3948+
"output": "Output · real Python",
3949+
"timeout": "Stopped after {{s}} s. Is there an endless loop?",
3950+
"see_figures": "No printed output; the figures are below."
3951+
},
3952+
"engine": {
3953+
"label": "Engine",
3954+
"mini": "Step-through",
3955+
"mini_sub": "minipy · draws every step",
3956+
"real": "Real Python",
3957+
"real_cold": "Pyodide · loads when chosen",
3958+
"real_loading": "Pyodide · loading…",
3959+
"real_ready": "Pyodide · ready",
3960+
"real_failed": "Pyodide · couldn't download",
3961+
"unavailable": "No browser build of {{name}}",
3962+
"steps_off": "Step-by-step views need the Step-through engine. Real Python runs the code as is and shows its output and figures."
39543963
}
39553964
},
39563965
"view": {

0 commit comments

Comments
 (0)