Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 71 additions & 64 deletions .github/workflows/cpython-wasm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ on:
- reopened
workflow_dispatch:
inputs:
cpython_ref:
required: true
default: "main"
description: "Ref of CPython to build"
deploy_channel:
required: true
default: "stable"
Expand All @@ -35,23 +31,13 @@ concurrency:
cancel-in-progress: false

jobs:
get-emscripten-version:
name: "Read pinned Emscripten/Node versions from CPython"
setup:
name: "Resolve deploy channel"
runs-on: ubuntu-latest
outputs:
emscripten_version: ${{ steps.versions.outputs.emscripten_version }}
node_version: ${{ steps.versions.outputs.node_version }}
cpython_ref: ${{ steps.ref.outputs.cpython_ref }}
deploy_channel: ${{ steps.channel.outputs.deploy_channel }}
deploy_destination: ${{ steps.channel.outputs.deploy_destination }}
steps:
- name: "Resolve cpython ref"
id: ref
env:
REF_INPUT: ${{ inputs.cpython_ref }}
run: |
ref="${REF_INPUT:-main}"
echo "cpython_ref=$ref" >> "$GITHUB_OUTPUT"
- name: "Resolve deploy channel"
id: channel
env:
Expand All @@ -71,37 +57,23 @@ jobs:
fi
echo "deploy_channel=$channel" >> "$GITHUB_OUTPUT"
echo "deploy_destination=$destination" >> "$GITHUB_OUTPUT"
- name: "Checkout cpython @ ${{ steps.ref.outputs.cpython_ref }}"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: "python/cpython"
ref: ${{ steps.ref.outputs.cpython_ref }}
persist-credentials: false
- name: "Install Python"
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.x"
- name: "Read config.toml"
id: versions
shell: python
run: |
import os, tomllib
from pathlib import Path
cfg = tomllib.loads(Path("Platforms/emscripten/config.toml").read_text())
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"emscripten_version={cfg['emscripten-version']}\n")
f.write(f"node_version={cfg['node-version']}\n")

build:
name: "Cross-compile CPython to wasm32-emscripten"
needs: get-emscripten-version
name: "Build CPython ${{ matrix.version }}"
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- version: "3.14"
ref: "3.14"
- version: "3.15"
ref: "main"
env:
CPYTHON_REF: ${{ needs.get-emscripten-version.outputs.cpython_ref }}
defaults:
run:
working-directory: cpython
CPYTHON_REF: ${{ matrix.ref }}
CPYTHON_VERSION: ${{ matrix.version }}
steps:
- name: "Checkout codoscope"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand All @@ -122,42 +94,77 @@ jobs:
with:
python-version: "3"

- name: "Read pinned Emscripten/Node versions from CPython"
id: versions
shell: python
run: |
import os, tomllib
from pathlib import Path
cfg = tomllib.loads(Path("cpython/Platforms/emscripten/config.toml").read_text())
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"emscripten_version={cfg['emscripten-version']}\n")
f.write(f"node_version={cfg['node-version']}\n")

- name: "Install Node"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ needs.get-emscripten-version.outputs.node_version }}
node-version: ${{ steps.versions.outputs.node_version }}

- name: "Install Emscripten SDK ${{ needs.get-emscripten-version.outputs.emscripten_version }}"
- name: "Install Emscripten SDK ${{ steps.versions.outputs.emscripten_version }}"
uses: mymindstorm/setup-emsdk@6ab9eb1bda2574c4ddb79809fc9247783eaf9021 # v14
with:
version: ${{ needs.get-emscripten-version.outputs.emscripten_version }}
actions-cache-folder: emsdk-cache
version: ${{ steps.versions.outputs.emscripten_version }}
actions-cache-folder: emsdk-${{ matrix.version }}

- name: "Build"
working-directory: cpython
run: python3 Tools/wasm/emscripten build --quiet

- name: "Stage site"
working-directory: ${{ github.workspace }}
- name: "Stage runtime for ${{ matrix.version }}"
run: |
mkdir _site
cp -a cpython/cross-build/wasm32-emscripten/build/python/web_example/. _site/
rm _site/server.py
cp codoscope/web/index.html _site/index.html
cp codoscope/web/driver.py _site/driver.py
if [ -f codoscope/web/python.worker.mjs ]; then
cp codoscope/web/python.worker.mjs _site/python.worker.mjs
fi
set -euo pipefail
dest="runtime/cpython/${CPYTHON_VERSION}"
src="cpython/cross-build/wasm32-emscripten/build/python/web_example"
mkdir -p "$dest"
cp "$src/python.mjs" "$dest/"
cp "$src/python.wasm" "$dest/"
cp "$src/python${CPYTHON_VERSION}.zip" "$dest/"

- name: "Upload runtime artifact"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: cpython-runtime-${{ matrix.version }}
path: runtime
if-no-files-found: error
retention-days: 7

assemble:
name: "Assemble site"
needs: [setup, build]
runs-on: ubuntu-latest
steps:
- name: "Checkout codoscope"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: "Download runtime artifacts"
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: cpython-runtime-*
path: runtime-artifacts
merge-multiple: true

- name: "Add Pages bits"
shell: bash
working-directory: ${{ github.workspace }}/_site
- name: "Assemble site"
run: |
set -euo pipefail
cp -a web _site
cp -a runtime-artifacts/cpython _site/cpython
# GitHub Pages cannot set COOP/COEP headers, but the Emscripten
# thingy needs SharedArrayBuffer. coi-serviceworker installs
# those headers client-side (which costs us one automatic reload).
# The <script src="coi-serviceworker.js"> tag lives in web/index.html.
curl -fsSL -o coi-serviceworker.js \
curl -fsSL -o _site/coi-serviceworker.js \
https://cdn.jsdelivr.net/gh/gzuidhof/coi-serviceworker@7b1d2a092d0d2dd2b7270b6f12f13605de26f214/coi-serviceworker.min.js

- name: "Upload site artifact"
Expand All @@ -171,15 +178,15 @@ jobs:

deploy:
name: "Publish site"
needs: [get-emscripten-version, build]
needs: [setup, assemble]
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
permissions:
contents: write
pull-requests: write
env:
DEPLOY_CHANNEL: ${{ needs.get-emscripten-version.outputs.deploy_channel }}
DEPLOY_DESTINATION: ${{ needs.get-emscripten-version.outputs.deploy_destination }}
DEPLOY_CHANNEL: ${{ needs.setup.outputs.deploy_channel }}
DEPLOY_DESTINATION: ${{ needs.setup.outputs.deploy_destination }}
steps:
- name: "Checkout"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -213,7 +220,7 @@ jobs:
publish_branch: gh-pages
keep_files: true
force_orphan: false
commit_message: "Publish site from main (cpython@${{ needs.get-emscripten-version.outputs.cpython_ref || 'main' }})"
commit_message: "Publish site from main (cpython 3.14/3.15)"

- name: "Comment PR preview URL"
if: env.DEPLOY_CHANNEL == 'preview'
Expand Down
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
PYTHON ?= python3.13
WEB_PORT ?= 8000
CPY_VERSION ?= 3.15

.PHONY: test web web-sync web-kill web-restart
test:
Expand All @@ -10,6 +11,12 @@ web-sync:
cp web/index.html _site/index.html
cp web/driver.py _site/driver.py
cp web/python.worker.mjs _site/python.worker.mjs
@mkdir -p _site/cpython/$(CPY_VERSION)
@for f in python.mjs python.wasm python$(CPY_VERSION).zip; do \
if [ -f _site/$$f ] && [ ! -e _site/cpython/$(CPY_VERSION)/$$f ]; then \
mv _site/$$f _site/cpython/$(CPY_VERSION)/$$f; \
fi; \
done

web: web-sync
@echo "Serving codoscope web UI at http://localhost:$(WEB_PORT)"
Expand Down
2 changes: 1 addition & 1 deletion web/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ def view_pseudo(code: str, *, optimize: bool = False) -> dict[str, Any]:
# On some newer WASM builds (e.g. CPython 3.15 snapshots), optimize_cfg can
# trap at runtime with low-level wasm errors. Prefer a stable pseudo view
# there instead of crashing the whole worker process.
if optimize and sys.version_info < (3, 15):
if optimize and sys.version_info < (3, 14):
insts = optimize_cfg(insts, co_consts, 0)
items = _instruction_items(insts)
adjusted_consts = _apply_annotations_const_workaround(items, co_consts)
Expand Down
53 changes: 51 additions & 2 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,22 @@
}
.toggles input { accent-color: var(--accent); }
.toggles input:checked + span { color: var(--text); }
.pyversion {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
}
.pyversion select {
background: #232a35;
color: var(--text);
border: 1px solid var(--panel-border);
padding: 4px 6px;
border-radius: 4px;
font: inherit;
cursor: pointer;
}
.pyversion select:disabled { opacity: 0.5; cursor: wait; }
main {
padding: 14px;
display: grid;
Expand Down Expand Up @@ -167,6 +183,10 @@
<h1><strong>codoscope</strong> — <span id="pyver">loading CPython…</span></h1>
<span class="status" id="status"></span>
<span class="spacer"></span>
<label class="pyversion">
<span>Python</span>
<select id="pyversion" disabled></select>
</label>
<button id="run" disabled>Run</button>
<div class="toggles" id="toggles">
<label><input type="checkbox" data-view="source" checked><span>Source</span></label>
Expand Down Expand Up @@ -213,13 +233,31 @@ <h2>Code Object</h2><pre class="content"></pre>
const runBtn = document.getElementById("run");
const status = document.getElementById("status");
const pyver = document.getElementById("pyver");
const pyversionSelect = document.getElementById("pyversion");
const panels = Array.from(document.querySelectorAll(".panel"));
const toggles = Array.from(document.querySelectorAll("#toggles input"));

const VIEWS = ["tokens", "ast", "ast-opt", "pseudo", "pseudo-opt", "compiled"];
const PYTHON_VERSIONS = ["3.14", "3.15"];
const DEFAULT_PYTHON_VERSION = "3.15";
const VERSION_STORAGE_KEY = "codoscope:python-version";

let driverSource = null;

const getPythonDir = () => `./cpython/${pyversionSelect.value}/`;

const initVersionSelect = () => {
for (const v of PYTHON_VERSIONS) {
const opt = document.createElement("option");
opt.value = v;
opt.textContent = v;
pyversionSelect.appendChild(opt);
}
const stored = localStorage.getItem(VERSION_STORAGE_KEY);
pyversionSelect.value = PYTHON_VERSIONS.includes(stored) ? stored : DEFAULT_PYTHON_VERSION;
pyversionSelect.disabled = false;
};

const applyToggles = () => {
for (const box of toggles) {
const view = box.dataset.view;
Expand Down Expand Up @@ -308,7 +346,8 @@ <h2>Code Object</h2><pre class="content"></pre>
const stdout = [];
const stderr = [];
let finished = false;
const worker = new Worker("./python.worker.mjs", { type: "module" });
const workerUrl = `./python.worker.mjs?dir=${encodeURIComponent(getPythonDir())}`;
const worker = new Worker(workerUrl, { type: "module" });
const startedAt = Date.now();
const watchdog = setInterval(() => {
if (finished) {
Expand All @@ -328,7 +367,10 @@ <h2>Code Object</h2><pre class="content"></pre>
finished = true;
clearInterval(watchdog);
worker.terminate();
showError(`Worker initialization failed: ${d.error || "unknown error"}`);
const stderrText = String.fromCharCode(...stderr).trimEnd();
const parts = [`Worker error: ${d.error || "unknown error"}`];
if (stderrText) parts.push("", "stderr:", stderrText);
showError(parts.join("\n"));
runBtn.disabled = false;
} else if (d.type === "ready") {
status.textContent = "runtime ready, running…";
Expand Down Expand Up @@ -391,6 +433,13 @@ <h2>Code Object</h2><pre class="content"></pre>

runBtn.addEventListener("click", run);

pyversionSelect.addEventListener("change", () => {
localStorage.setItem(VERSION_STORAGE_KEY, pyversionSelect.value);
run();
});

initVersionSelect();

window.addEventListener("load", async () => {
try {
driverSource = await fetch("driver.py", { cache: "no-store" }).then((r) => r.text());
Expand Down
13 changes: 7 additions & 6 deletions web/python.worker.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import createEmscriptenModule from "./python.mjs";

class StdinBuffer {
constructor() {
this.sab = new SharedArrayBuffer(128 * Int32Array.BYTES_PER_ELEMENT);
Expand Down Expand Up @@ -61,7 +59,7 @@ const stderr = (charCode) => {

const stdinBuffer = new StdinBuffer();

const emscriptenSettings = {
const buildEmscriptenSettings = (pythonDir) => ({
noInitialRun: true,
stdin: stdinBuffer.stdin,
stdout: stdout,
Expand All @@ -81,7 +79,7 @@ const emscriptenSettings = {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
const resp = await fetch(stdlibName, { signal: controller.signal });
const resp = await fetch(`${pythonDir}${stdlibName}`, { signal: controller.signal });
clearTimeout(timeoutId);
if (!resp.ok) {
throw new Error(`failed to fetch ${stdlibName}: HTTP ${resp.status}`);
Expand All @@ -96,9 +94,12 @@ const emscriptenSettings = {
Module.removeRunDependency(depName);
}
},
};
});

const modulePromise = createEmscriptenModule(emscriptenSettings);
const pythonDir = new URL(self.location.href).searchParams.get("dir");
const modulePromise = import(`${pythonDir}python.mjs`).then((mod) =>
mod.default(buildEmscriptenSettings(pythonDir)),
);

onmessage = async (event) => {
if (event.data.type === "run") {
Expand Down
Loading