From f22e3a23de63a7b95fcfb213e7dc04831cdcbc5f Mon Sep 17 00:00:00 2001 From: Michael Allwright Date: Fri, 25 Sep 2026 20:26:23 +0200 Subject: [PATCH 1/4] Add client-side runner --- .github/CODEOWNERS | 3 + .github/clientside/run-in-kernel.mjs | 154 ++++++++++++++ .github/workflows/ci.yml | 4 + .github/workflows/clientside-test.yml | 117 +++++++++++ .github/workflows/publish-clientside.yml | 248 +++++++++++++++++++++++ .gitignore | 1 + README.md | 32 +++ bin/build-clientside-parser.sh | 95 +++++++++ bin/build-clientside-tarball.sh | 131 ++++++++++++ clientside.json | 16 ++ 10 files changed, 801 insertions(+) create mode 100644 .github/clientside/run-in-kernel.mjs create mode 100644 .github/workflows/clientside-test.yml create mode 100644 .github/workflows/publish-clientside.yml create mode 100755 bin/build-clientside-parser.sh create mode 100755 bin/build-clientside-tarball.sh create mode 100644 clientside.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 691de80..c1bb586 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,4 @@ * @exercism/guardians + +# Writes to the test-runners bucket, so changes to it need sign-off. +/.github/workflows/publish-clientside.yml @iHiD @exercism/maintainers-admin diff --git a/.github/clientside/run-in-kernel.mjs b/.github/clientside/run-in-kernel.mjs new file mode 100644 index 0000000..4a23b15 --- /dev/null +++ b/.github/clientside/run-in-kernel.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +// Boot a kernel in headless Chromium, untar a runner into it, run one command. +// +// run-in-kernel.mjs [--extract ]... \ +// [args...] +// +// Exits with the command's status. stdout/stderr are relayed as they arrive. +// +// --extract copies a file out of the kernel once the command has succeeded: +// the browser equivalent of `docker cp`. It is how a binary built by the +// sysroot's own compiler gets back out to be shipped. +// +// This is the browser equivalent of `docker run --entrypoint `. +// The kernel is threaded wasm and only runs in a cross-origin isolated page, +// so there is no way to drive it from Node directly: everything is served +// to a real headless Chromium, which is also the runtime students get. +// +// Needs `playwright` on the module path and a Chromium it can launch +// (`npx playwright install --with-deps chromium`, or CHROMIUM_PATH). + +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { chromium } from "playwright"; + +const args = process.argv.slice(2); +const extracts = []; +while (args[0] === "--extract") { + const [, from, to] = args.splice(0, 3); + if (!from || !to) break; + extracts.push([from, to]); +} +const [kernelDir, sysrootDir, bootJson, tarball, ...argv] = args; +if (!argv.length) { + console.error( + "usage: run-in-kernel.mjs [--extract ]... " + + " [args...]", + ); + process.exit(64); +} + +const DEBUG = !!process.env.KERNEL_DEBUG; + +const files = { + "/kernel/kernel.js": [path.join(kernelDir, "kernel.js"), "text/javascript"], + "/kernel/kernel_bg.wasm": [path.join(kernelDir, "kernel_bg.wasm"), "application/wasm"], + "/kernel/kernel_client.mjs": [path.join(kernelDir, "kernel_client.mjs"), "text/javascript"], + "/sysroot.tar": [path.join(sysrootDir, "sysroot.tar"), "application/x-tar"], + "/boot.json": [bootJson, "application/json"], + "/runner.tar": [tarball, "application/x-tar"], +}; +for (const [p] of Object.values(files)) { + if (!fs.existsSync(p)) { console.error(`missing: ${p}`); process.exit(66); } +} + +// The kernel is threaded wasm: the page must be cross-origin isolated, which +// means every response carries COOP/COEP. Same pair the website sends. +const ISOLATION = { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "credentialless", +}; + +const PAGE = `kernel +`; + +const server = http.createServer((req, res) => { + const p = new URL(req.url, "http://localhost").pathname; + if (p === "/") { res.writeHead(200, { ...ISOLATION, "Content-Type": "text/html" }); return res.end(PAGE); } + const entry = files[p]; + if (!entry) { res.writeHead(404, ISOLATION); return res.end(); } + res.writeHead(200, { ...ISOLATION, "Content-Type": entry[1] }); + fs.createReadStream(entry[0]).pipe(res); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +const origin = `http://127.0.0.1:${server.address().port}`; + +const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH, args: ["--no-sandbox"] }); +const page = await browser.newPage(); +await page.exposeFunction("__out", (s) => process.stdout.write(s)); +await page.exposeFunction("__err", (s) => process.stderr.write(s)); +await page.exposeFunction("__event", (s) => { if (DEBUG) console.error("[event]", s); }); +page.on("pageerror", (e) => console.error("[page]", e.message)); +page.on("console", (m) => { if (m.type() === "error") console.error("[console]", m.text()); }); + +let status = 1; +try { + await page.goto(origin, { waitUntil: "load" }); + if (!(await page.evaluate(() => crossOriginIsolated))) throw new Error("page is not cross-origin isolated"); + await page.waitForFunction(() => window.__ready, null, { timeout: 30_000 }); + const raw = await page.evaluate((a) => window.__run(a), argv).then( + (s) => s, + (e) => { console.error("[kernel]", e.message); return 1; }, + ); + if (DEBUG) console.error("[run returned]", JSON.stringify(raw)); + status = raw ?? 0; + if (status === 0) { + for (const [from, to] of extracts) { + const bytes = Buffer.from(await page.evaluate((p) => window.__read(p), from), "base64"); + fs.mkdirSync(path.dirname(path.resolve(to)), { recursive: true }); + fs.writeFileSync(to, bytes); + console.error(`[extract] ${from} -> ${to} (${bytes.length} bytes)`); + } + } +} finally { + await browser.close().catch(() => {}); + server.close(); +} +process.exit(status); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24d8a45..779641e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,7 @@ jobs: - name: Run Tests in Docker run: bin/run-tests-in-docker.sh + + # The same suite, run the way a student's browser runs it. + clientside: + uses: ./.github/workflows/clientside-test.yml diff --git a/.github/workflows/clientside-test.yml b/.github/workflows/clientside-test.yml new file mode 100644 index 0000000..7a970d9 --- /dev/null +++ b/.github/workflows/clientside-test.yml @@ -0,0 +1,117 @@ +# Runs a track's test-runner suite inside the wasm kernel, on the kernel and +# sysroot its clientside.json names: the track's bin/run.sh, driven by its own +# bin/run-tests.sh, in headless Chromium. It is the browser equivalent of +# `bin/run-tests-in-docker.sh`, and what's under test is the tarball +# publish-clientside.yml would ship, down to the parser being built the same +# way. publish-clientside.yml runs this suite again against the exact bytes it +# publishes. +# +# Copied from jq-test-runner, where it is written as the reusable workflow it +# will become in exercism/github-actions. The one C++-specific addition is +# "Build exercism_parser": the parser is compiled, so it has to be built for +# the kernel before there is a tarball to test. +name: Client-side tests + +on: + workflow_call: + +jobs: + test: + name: Tests (client-side) + runs-on: ubuntu-26.04 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + # Once this workflow lives in exercism/github-actions, this becomes a + # sparse checkout of that repo's clientside/ into harness/. Today the + # harness is beside this file. + - name: Get the harness + run: cp -r .github/clientside harness + + - name: Read clientside.json + id: config + run: | + track="${GITHUB_REPOSITORY##*/}" + track="${track%-test-runner}" + kernel="$(jq -er '.kernel' clientside.json)" + version="$(jq -er '.sysroot.version' clientside.json)" + id="$(jq -er '.sysroot.id' clientside.json)" + { + echo "kernel=kernel/${kernel}" + echo "sysroot=sysroot/${track}/${version}/${id}" + } >> "$GITHUB_OUTPUT" + + # From where the website serves them, so what's tested is exactly what + # students get. Nothing here is published until the track points at it, + # so a PR naming an unpublished kernel or sysroot fails right here. + - name: Fetch the kernel and sysroot + run: | + base="https://exercism.org/test-runners" + fetch() { + mkdir -p "$(dirname "$1")" + curl --fail --silent --show-error --location --retry 3 "${base}/$1" --output "$1" + } + for f in kernel.js kernel_bg.wasm kernel_client.mjs; do + fetch "${{ steps.config.outputs.kernel }}/${f}" + done + for f in sysroot.tar boot.json; do + fetch "${{ steps.config.outputs.sysroot }}/${f}" + done + ls -l "${{ steps.config.outputs.kernel }}" "${{ steps.config.outputs.sysroot }}" + + # Same merge as publish-clientside.yml: the sysroot describes itself, + # the track adds its env and precompile list. + - name: Build boot.json + run: | + jq -s '.[0] as $base | .[1] as $track | + $base + { + env: ($base.env + ($track.env // {})), + precompile: ($track.precompile // $base.precompile) + }' "${{ steps.config.outputs.sysroot }}/boot.json" clientside.json > boot.json + + - name: Install Playwright + working-directory: harness + run: | + npm install --no-save --no-package-lock playwright@1.63.0 + npx playwright install --with-deps chromium + + # For bin/build-clientside-parser.sh. Pinned, and checked, because its + # output is part of what the published tarball's hash covers. + - name: Install wasm-opt + run: | + version=130 + sha256=0a18362361ad05465118cd8eeb72edaeec89de6894bc283576ef4e07aa3babcc + curl --fail --silent --show-error --location --retry 3 --output binaryen.tar.gz \ + "https://github.com/WebAssembly/binaryen/releases/download/version_${version}/binaryen-version_${version}-x86_64-linux.tar.gz" + echo "${sha256} binaryen.tar.gz" | sha256sum --check --quiet + mkdir -p "${RUNNER_TEMP}/binaryen" + tar -xzf binaryen.tar.gz --directory "${RUNNER_TEMP}/binaryen" --strip-components=2 "binaryen-version_${version}/bin/wasm-opt" + rm binaryen.tar.gz + echo "${RUNNER_TEMP}/binaryen" >> "$GITHUB_PATH" + + # By the sysroot's own clang, inside the kernel: the Docker image's + # parser is native and cannot run there. + - name: Build exercism_parser + env: + HARNESS: harness + run: | + bin/build-clientside-parser.sh \ + "${{ steps.config.outputs.kernel }}" \ + "${{ steps.config.outputs.sysroot }}" \ + boot.json + + # The exact tarball publish-clientside.yml would ship, with the suite + # laid on top. + - name: Build the runner tarball + run: | + bin/build-clientside-tarball.sh test-runner.tar + tar --append --file test-runner.tar --transform 's|^|opt/test-runner/|' bin/run-tests.sh tests + + - name: Run the suite in the kernel + run: | + node harness/run-in-kernel.mjs \ + "${{ steps.config.outputs.kernel }}" \ + "${{ steps.config.outputs.sysroot }}" \ + boot.json test-runner.tar \ + /opt/test-runner/bin/run-tests.sh diff --git a/.github/workflows/publish-clientside.yml b/.github/workflows/publish-clientside.yml new file mode 100644 index 0000000..1d0fab1 --- /dev/null +++ b/.github/workflows/publish-clientside.yml @@ -0,0 +1,248 @@ +# Publishes the browser test runner: a tarball of this repo's bin/run.sh and a +# kernel build of bin/exercism_parser that the wasm kernel unpacks at boot, the +# boot.json that goes with it, and the latest.json that points the website at +# both. +# +# Copied from jq-test-runner. The C++ difference is the parser: it is compiled +# inside the kernel by the sysroot's own clang, and the suite is run against +# the resulting tarball before anything is published, because this build of +# the parser is the one students get. +# +# This is deliberately NOT part of deploy.yml. That file is synced into every +# tooling repo from exercism/org-wide-files, so edits to it here are reverted +# on the next sync. It should move there once a second track has one of these +# and it is clear what is actually common between them. +name: Publish client-side runner + +on: + # Chained off Deploy rather than run on push, so the Docker image and the + # browser tarball always ship from the same commit. They are the same + # bin/run.sh, and a student running tests in the browser should be running + # exactly what the server would have run. + workflow_run: + workflows: [Deploy] + types: [completed] + workflow_dispatch: + +permissions: + contents: read + id-token: write # for the OIDC token exchanged for the AWS role + +env: + BUCKET: exercism-test-runners + +jobs: + publish: + name: Publish + # Forks have no role to assume. A failed Deploy means the image never + # shipped, and publishing the tarball then would put the browser ahead of + # the server. And only main publishes, however the run was started: + # latest.json is production, and a branch must not be able to repoint it. + if: >- + github.repository_owner == 'exercism' && + ( + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main') + ) + runs-on: ubuntu-26.04 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + # workflow_run starts from the default branch, so check out the + # commit Deploy actually built. + ref: ${{ github.event.workflow_run.head_sha || github.ref }} + + - name: Read clientside.json + id: config + run: | + # The track is the repo name, so this file is identical in every + # test runner repo. + track="${GITHUB_REPOSITORY##*/}" + track="${track%-test-runner}" + echo "TRACK=${track}" >> "$GITHUB_ENV" + + # These end up in S3 keys and in later steps' shell, so they are held + # to a strict shape even though the file is on main and trusted. + segment() { + local key="$1" value="$2" + if ! [[ "${value}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "::error::clientside.json: ${key} must be a single path segment, got '${value}'" + exit 1 + fi + } + + version="$(jq -er '.sysroot.version' clientside.json)" + segment sysroot.version "${version}" + id="$(jq -er '.sysroot.id | select(type == "number" and . == floor and . >= 1)' clientside.json)" + + kernel="$(jq -er '.kernel' clientside.json)" + segment kernel "${kernel}" + + timeout="$(jq -er '.timeout | select(type == "number")' clientside.json)" + + { + echo "sysroot_version=${version}" + echo "sysroot_id=${id}" + # Where this track's sysroot lives, relative to the bucket. One + # place, because it is needed to read the sysroot here and to + # point the browser at it later. + echo "sysroot_prefix=test-runners/sysroot/${track}/${version}/${id}" + echo "kernel=${kernel}" + echo "timeout=${timeout}" + } >> "$GITHUB_OUTPUT" + + # The kernel is needed here, unlike for jq, to build the parser. Both come + # from where the website serves them, as in clientside-test.yml. + - name: Fetch the kernel and sysroot + run: | + fetch() { + mkdir -p "$(dirname "$1")" + curl --fail --silent --show-error --location --retry 3 "https://exercism.org/$1" --output "$1" + } + for f in kernel.js kernel_bg.wasm kernel_client.mjs; do + fetch "test-runners/kernel/${{ steps.config.outputs.kernel }}/${f}" + done + for f in sysroot.tar boot.json; do + fetch "${{ steps.config.outputs.sysroot_prefix }}/${f}" + done + + - name: Check the sysroot has what we precompile + run: | + # precompile names paths inside the sysroot. A typo, or a version bump that + # moves a binary, otherwise fails at boot in a student's browser with + # nothing useful in the console. + tar -tf "${{ steps.config.outputs.sysroot_prefix }}/sysroot.tar" | sed 's|^|/|' | sort > sysroot-paths.txt + + status=0 + while read -r path; do + grep -qxF "${path}" sysroot-paths.txt || { echo "::error::${path} is in precompile but not in the sysroot"; status=1; } + done < <(jq -r '.precompile[]' clientside.json) + exit "${status}" + + - name: Build boot.json + run: | + # The sysroot publishes the half that describes itself - PATH, HOME, + # the licence - and this repo owns the half that describes the track. + jq -s '.[0] as $base | .[1] as $track | + $base + { + env: ($base.env + ($track.env // {})), + precompile: ($track.precompile // $base.precompile) + }' "${{ steps.config.outputs.sysroot_prefix }}/boot.json" clientside.json > boot.json + + - name: Install Playwright + working-directory: .github/clientside + run: | + npm install --no-save --no-package-lock playwright@1.63.0 + npx playwright install --with-deps chromium + + # For bin/build-clientside-parser.sh. Pinned, and checked, because its + # output is part of what the published tarball's hash covers. + - name: Install wasm-opt + run: | + version=130 + sha256=0a18362361ad05465118cd8eeb72edaeec89de6894bc283576ef4e07aa3babcc + curl --fail --silent --show-error --location --retry 3 --output binaryen.tar.gz \ + "https://github.com/WebAssembly/binaryen/releases/download/version_${version}/binaryen-version_${version}-x86_64-linux.tar.gz" + echo "${sha256} binaryen.tar.gz" | sha256sum --check --quiet + mkdir -p "${RUNNER_TEMP}/binaryen" + tar -xzf binaryen.tar.gz --directory "${RUNNER_TEMP}/binaryen" --strip-components=2 "binaryen-version_${version}/bin/wasm-opt" + rm binaryen.tar.gz + echo "${RUNNER_TEMP}/binaryen" >> "$GITHUB_PATH" + + - name: Build exercism_parser + run: | + bin/build-clientside-parser.sh \ + "test-runners/kernel/${{ steps.config.outputs.kernel }}" \ + "${{ steps.config.outputs.sysroot_prefix }}" \ + boot.json + + - name: Build the tarball + run: bin/build-clientside-tarball.sh test-runner.tar + + # Against a copy with the suite laid on top, so what is tested is the + # published tarball plus the tests, as in clientside-test.yml. + - name: Run the suite against the tarball + run: | + cp test-runner.tar test-runner-with-tests.tar + tar --append --file test-runner-with-tests.tar --transform 's|^|opt/test-runner/|' bin/run-tests.sh tests + node .github/clientside/run-in-kernel.mjs \ + "test-runners/kernel/${{ steps.config.outputs.kernel }}" \ + "${{ steps.config.outputs.sysroot_prefix }}" \ + boot.json test-runner-with-tests.tar \ + /opt/test-runner/bin/run-tests.sh + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c + with: + role-to-assume: ${{ secrets.AWS_CLIENTSIDE_PUBLISH_ROLE_ARN }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Derive the build id + id: build + run: | + # Content-addressed over everything that lands at the prefix - the + # tarball AND boot.json. The tarball holds a freshly compiled parser, + # so this is only stable across deploys if clang's output is. Re-running a deploy that changed nothing then + # republishes to the same prefix rather than minting a new one and + # busting every student's cache. And a change to only one of them + # (an env var in clientside.json, say) still gets a new prefix, which + # matters because the old one is cached as immutable for a year. + hash="$(cat test-runner.tar boot.json | sha256sum | cut -c1-12)" + { + echo "hash=${hash}" + # Under the sysroot it was built and checked against, so a listing + # of runner//// is every runner made for that sysroot. + echo "prefix=test-runners/runner/${TRACK}/${{ steps.config.outputs.sysroot_version }}/${{ steps.config.outputs.sysroot_id }}/${hash}" + # latest.json wants a single string for the version. + echo "version=${{ steps.config.outputs.sysroot_version }}-${{ steps.config.outputs.sysroot_id }}-${hash}" + } >> "$GITHUB_OUTPUT" + + - name: Publish the build + id: publish + run: | + prefix="${{ steps.build.outputs.prefix }}" + # Immutable: nothing at this prefix is ever rewritten, because the + # prefix ends in a hash of the contents. + cache="public, max-age=31536000, immutable" + + aws s3 cp test-runner.tar "s3://${BUCKET}/${prefix}/test-runner.tar" \ + --content-type application/x-tar --cache-control "${cache}" + aws s3 cp boot.json "s3://${BUCKET}/${prefix}/boot.json" \ + --content-type application/json --cache-control "${cache}" + + echo "prefix=/${prefix}" >> "$GITHUB_OUTPUT" + + - name: Point the track at it + run: | + # Written last. latest.json is the only mutable object in the tree, + # so until this lands the new build is invisible and students keep + # getting the old one - rather than briefly getting a half-published + # one. + jq -n \ + --arg version "${{ steps.build.outputs.version }}" \ + --argjson timeout "${{ steps.config.outputs.timeout }}" \ + --arg kernel "/test-runners/kernel/${{ steps.config.outputs.kernel }}/" \ + --arg boot "${{ steps.publish.outputs.prefix }}/boot.json" \ + --arg sysroot "/${{ steps.config.outputs.sysroot_prefix }}/sysroot.tar" \ + --arg testRunner "${{ steps.publish.outputs.prefix }}/test-runner.tar" \ + '{type: "kernel", version: $version, timeout: $timeout, + kernel: $kernel, boot: $boot, sysroot: $sysroot, testRunner: $testRunner}' \ + > latest.json + + cat latest.json + + aws s3 cp latest.json "s3://${BUCKET}/test-runners/${TRACK}/latest.json" \ + --content-type application/json --cache-control "public, max-age=60" + + - name: Summarise + run: | + { + echo "### Published \`${{ steps.build.outputs.version }}\`" + echo + echo '```json' + cat latest.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index cceb64b..b6197ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .vscode/* tests/*/results.json +bin/exercism_parser diff --git a/README.md b/README.md index 64ba49e..5bae859 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,38 @@ Catch can report the tests results in [JUnit][junit] formatted xml when enabled, This file is parsed with Python and the [junitparser][junitparser-lib] library in the `process.py` script that outputs a `results.json` file that respects the test runners specifications. +# The client-side runner + +The same `bin/run.sh` also runs in the browser, on a wasm kernel that provides a real Linux userland. +There is no second implementation: the kernel's sysroot carries bash, clang, cmake, make, boost and Catch2, this repo's `bin/run.sh` and `bin/exercism_parser` are untarred into `/opt/test-runner`, and `run.sh` is executed exactly as the Docker image runs it. + +`clientside.json` is everything this track says about that: + +| Key | Meaning | +| --- | --- | +| `sysroot` | which published sysroot to run on: its version, and an id to tell rebuilds of the same version apart | +| `kernel` | which kernel build to run on | +| `timeout` | seconds a single run may take before the worker is killed | +| `env` | environment variables this track needs on top of the sysroot's own | +| `precompile` | binaries to compile at boot rather than on first use | + +Kernels and sysroots are published from [exercism/clientside-tooling][tooling]. + +Unlike the rest of `bin/`, `exercism_parser` is compiled, and the kernel's binaries are wasm modules linked against the sysroot's own libc. +So it is built inside the kernel, by the sysroot's clang, and copied back out: + +```bash +./bin/build-clientside-parser.sh +./bin/build-clientside-tarball.sh test-runner.tar +``` + +The first needs node, `playwright` (see `.github/clientside/run-in-kernel.mjs`) and binaryen's `wasm-opt`. +The second needs GNU tar, because the archive has to be byte-identical between runs: the published path is derived from its hash. + +Publishing happens in `.github/workflows/publish-clientside.yml`, which runs after a successful Deploy so that the Docker image and the browser tarball always come from the same commit. +It builds the parser, runs the test suite in the kernel against the tarball, and only then publishes that tarball. + +[tooling]: https://github.com/exercism/clientside-tooling [test-runner-interface]: https://exercism.org/docs/building/tooling/test-runners/interface [test-runner-docker]: https://exercism.org/docs/building/tooling/test-runners/docker [cmake]: https://cmake.org/ diff --git a/bin/build-clientside-parser.sh b/bin/build-clientside-parser.sh new file mode 100755 index 0000000..ec4c4d9 --- /dev/null +++ b/bin/build-clientside-parser.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -e + +# Synopsis: +# Build bin/exercism_parser for the browser test runner, by compiling it inside +# the wasm kernel with the sysroot's own clang and boost. +# +# This is the browser's equivalent of the Dockerfile's +# +# RUN g++ -I ./include ./src/*.cpp -o ./bin/exercism_parser ... +# +# The kernel's binaries are wasm modules dynamically linked against the +# sysroot's libc, so the parser has to be built by the toolchain it will run +# on. The sysroot's compiler targets the kernel by default, so no cross +# toolchain is needed on the host: the sources go in, the binary comes back out. +# +# The result is then shrunk with binaryen's wasm-opt on the host. +# +# Needs node, `playwright` resolvable from the harness directory (see +# run-in-kernel.mjs), and wasm-opt. +# +# Arguments: +# $1: directory holding kernel.js, kernel_bg.wasm and kernel_client.mjs +# $2: directory holding sysroot.tar +# $3: the merged boot.json +# $4: path to write the parser to (default: bin/exercism_parser) +# +# Environment: +# HARNESS: directory holding run-in-kernel.mjs (default: .github/clientside) +# +# Example: +# ./bin/build-clientside-parser.sh kernel/ sysroot/cpp// boot.json + +# Runs inside the kernel, in /opt/test-runner. +# +# The sysroot's clang links every executable with --export-all, which keeps +# every function alive as an export: the parser comes out at 1.6MB. So the +# link is done by hand: clang is asked for its own wasm-ld command (-###), +# --export-all is dropped, and only main is exported. main has to be, because +# the start-up code finds it through the module's exports; nothing else does. +# With wasm-opt afterwards, that is about 55KB. +# +# No -lboost_system, unlike the Dockerfile: it has been header-only since +# Boost 1.69 and the stub library is gone from recent releases. +# shellcheck disable=SC2016 +IN_KERNEL=' +set -e +mkdir -p bin obj +for source in src/*.cpp; do + object="obj/$(basename "${source}" .cpp).o" + clang++ -Oz -I ./include -c "${source}" -o "${object}" +done +link="$(clang++ -### obj/*.o -o bin/exercism_parser -lboost_json 2>&1 | tail -n 1)" +link="\"${link#*\"}" # drop the leading " (in-process) " +link="${link//\"--export-all\" /}" +eval "${link} --export=__main_argc_argv" +' + +main() { + if [[ $# -lt 3 ]]; then + echo "usage: ./bin/build-clientside-parser.sh [output]" >&2 + exit 1 + fi + + local kernel="$1" sysroot="$2" boot="$3" output="${4:-bin/exercism_parser}" + local harness="${HARNESS:-.github/clientside}" + + if ! command -v wasm-opt > /dev/null; then + echo "wasm-opt is required (binaryen)" >&2 + exit 1 + fi + + local staging + staging="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '${staging}'" EXIT + + # The sources, laid out where the Dockerfile compiles them. + tar --create --file "${staging}/src.tar" --transform 's|^|opt/test-runner/|' include src + + node "${harness}/run-in-kernel.mjs" \ + --extract /opt/test-runner/bin/exercism_parser "${output}" \ + "${kernel}" "${sysroot}" "${boot}" "${staging}/src.tar" \ + /usr/bin/bash -c "${IN_KERNEL}" + + # Deterministic, like the compile, so the published tarball's hash only + # changes when the parser does. + wasm-opt -Oz "${output}" -o "${output}" + + # run.sh calls it directly, and the kernel's file mode does not come out + # with the bytes. + chmod +x "${output}" +} + +main "$@" diff --git a/bin/build-clientside-tarball.sh b/bin/build-clientside-tarball.sh new file mode 100755 index 0000000..04966ec --- /dev/null +++ b/bin/build-clientside-tarball.sh @@ -0,0 +1,131 @@ +#! /bin/bash -e + +# Synopsis: +# Build the tarball that the browser test runner untars into its kernel. +# +# This is the browser's equivalent of the Dockerfile's +# +# WORKDIR /opt/test-runner +# COPY . . +# +# The kernel's sysroot carries clang, cmake, boost and Catch2 but not this +# runner, so the runner ships as a tarball that is unpacked at boot. Staging it here rather +# than tarring the repo directly means the archive's layout is exactly what the +# kernel should end up with, and nothing that only makes sense on a developer's +# machine goes along for the ride. +# +# bin/exercism_parser must already have been built for the kernel, by +# bin/build-clientside-parser.sh: the one the Dockerfile builds is a native +# binary and cannot run there. +# +# Arguments: +# $1: path to write the tarball to (default: ./test-runner.tar) +# +# Example: +# ./bin/build-clientside-tarball.sh test-runner.tar + +# Only what runs when a student runs their tests. The rest of bin/ is drivers +# for Docker and for this runner's own golden tests in tests/. +CONTENTS=(bin/run.sh bin/exercism_parser) + +# Everything lands under here, matching the Docker image's WORKDIR. +# Explicitly not an absolute path: see the `stage` function. +PREFIX="opt/test-runner" + +main() { + # Made absolute now, because tar's --directory would otherwise resolve a + # relative path inside the staging tree. Not via realpath, which on BSD + # refuses a path that does not exist yet. + local output="${1:-test-runner.tar}" + [[ "${output}" == /* ]] || output="${PWD}/${output}" + mkdir -p "$(dirname "${output}")" + + local tar + tar="$(find_gnu_tar)" + + check_parser + + local staging + staging="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '${staging}'" EXIT + + stage "${staging}" + archive "${tar}" "${staging}" "${output}" + + echo "Wrote ${output} ($(wc -c < "${output}" | tr -d ' ') bytes)" + echo "sha256: $(sha256 "${output}")" +} + +# The reproducibility flags below are GNU-only. BSD tar takes different ones and +# writes different headers, so rather than silently produce a tarball that +# hashes differently on a maintainer's Mac than in CI, insist on GNU tar. +find_gnu_tar() { + local candidate + for candidate in tar gtar; do + if command -v "${candidate}" > /dev/null && "${candidate}" --version 2> /dev/null | grep -q "GNU tar"; then + echo "${candidate}" + return + fi + done + + echo "GNU tar is required (on macOS: brew install gnu-tar)" >&2 + exit 1 +} + +# A native parser from a local build would otherwise ship silently, and only +# fail in a student's browser. +check_parser() { + local parser=bin/exercism_parser + if [[ ! -f "${parser}" ]]; then + echo "${parser} is missing: build it with bin/build-clientside-parser.sh" >&2 + exit 1 + fi + if [[ "$(head -c 4 "${parser}" | od -An -tx1 | tr -d ' \n')" != "0061736d" ]]; then + echo "${parser} is not a wasm module: build it with bin/build-clientside-parser.sh" >&2 + exit 1 + fi +} + +stage() { + local staging="$1" + + local entry + for entry in "${CONTENTS[@]}"; do + mkdir -p "${staging}/${PREFIX}/$(dirname "${entry}")" + # -a preserves the executable bits, which bin/run.sh needs. + cp -a "${entry}" "${staging}/${PREFIX}/${entry}" + done +} + +archive() { + local tar="$1" staging="$2" output="$3" + + # The archive has to be byte-identical between runs: the published S3 path + # is derived from its hash, so an unstable tarball would mint a new + # immutable prefix on every deploy and re-publish the same bytes under a + # new URL. --sort fixes the entry order, --mtime the timestamps, the + # ownership flags strip whoever happened to run the build, and ustar has + # no extension headers that could carry anything else. + "${tar}" \ + --create \ + --file "${output}" \ + --directory "${staging}" \ + --sort=name \ + --mtime="@0" \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + --format=ustar \ + "${PREFIX%%/*}" +} + +sha256() { + if command -v sha256sum > /dev/null; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +main "$@" diff --git a/clientside.json b/clientside.json new file mode 100644 index 0000000..a2fa160 --- /dev/null +++ b/clientside.json @@ -0,0 +1,16 @@ +{ + "sysroot": { + "version": "19.0.0", + "id": 1 + }, + "kernel": "979c9dec-976e-4b68-a54e-e48f79eb2635", + "timeout": 30, + "env": {}, + "precompile": [ + "/usr/bin/bash", + "/usr/bin/coreutils", + "/usr/bin/make", + "/usr/bin/cmake", + "/usr/bin/clang" + ] +} From f05f800e43f835440ffafcaba4109cdd9cadc0b8 Mon Sep 17 00:00:00 2001 From: Michael Allwright Date: Mon, 5 Oct 2026 18:04:24 +0200 Subject: [PATCH 2/4] Fix comments in client-side scripts --- bin/build-clientside-parser.sh | 2 +- bin/build-clientside-tarball.sh | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/build-clientside-parser.sh b/bin/build-clientside-parser.sh index ec4c4d9..4edf166 100755 --- a/bin/build-clientside-parser.sh +++ b/bin/build-clientside-parser.sh @@ -41,7 +41,7 @@ set -e # With wasm-opt afterwards, that is about 55KB. # # No -lboost_system, unlike the Dockerfile: it has been header-only since -# Boost 1.69 and the stub library is gone from recent releases. +# Boost 1.69, and the sysroot does not carry the stub library. # shellcheck disable=SC2016 IN_KERNEL=' set -e diff --git a/bin/build-clientside-tarball.sh b/bin/build-clientside-tarball.sh index 04966ec..7e6f56d 100755 --- a/bin/build-clientside-tarball.sh +++ b/bin/build-clientside-tarball.sh @@ -9,10 +9,10 @@ # COPY . . # # The kernel's sysroot carries clang, cmake, boost and Catch2 but not this -# runner, so the runner ships as a tarball that is unpacked at boot. Staging it here rather -# than tarring the repo directly means the archive's layout is exactly what the -# kernel should end up with, and nothing that only makes sense on a developer's -# machine goes along for the ride. +# runner, so the runner ships as a tarball that is unpacked at boot. Staging it +# here rather than tarring the repo directly means the archive's layout is +# exactly what the kernel should end up with, and nothing that only makes sense +# on a developer's machine goes along for the ride. # # bin/exercism_parser must already have been built for the kernel, by # bin/build-clientside-parser.sh: the one the Dockerfile builds is a native From 657876eca3fea036a5a798e5e7f528430b62232d Mon Sep 17 00:00:00 2001 From: Michael Allwright Date: Mon, 5 Oct 2026 18:04:24 +0200 Subject: [PATCH 3/4] Rewrap comment, bump configure-aws-credentials to v6.3.0 --- .github/workflows/publish-clientside.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-clientside.yml b/.github/workflows/publish-clientside.yml index 1d0fab1..90f38db 100644 --- a/.github/workflows/publish-clientside.yml +++ b/.github/workflows/publish-clientside.yml @@ -175,7 +175,7 @@ jobs: /opt/test-runner/bin/run-tests.sh - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd with: role-to-assume: ${{ secrets.AWS_CLIENTSIDE_PUBLISH_ROLE_ARN }} aws-region: ${{ secrets.AWS_REGION }} @@ -185,11 +185,12 @@ jobs: run: | # Content-addressed over everything that lands at the prefix - the # tarball AND boot.json. The tarball holds a freshly compiled parser, - # so this is only stable across deploys if clang's output is. Re-running a deploy that changed nothing then - # republishes to the same prefix rather than minting a new one and - # busting every student's cache. And a change to only one of them - # (an env var in clientside.json, say) still gets a new prefix, which - # matters because the old one is cached as immutable for a year. + # so this is only stable across deploys if clang's output is. + # Re-running a deploy that changed nothing then republishes to the + # same prefix rather than minting a new one and busting every + # student's cache. And a change to only one of them (an env var in + # clientside.json, say) still gets a new prefix, which matters because + # the old one is cached as immutable for a year. hash="$(cat test-runner.tar boot.json | sha256sum | cut -c1-12)" { echo "hash=${hash}" From 54b3cf0ea26d9a1b4d582bf4d019478e7846f06b Mon Sep 17 00:00:00 2001 From: Michael Allwright Date: Mon, 5 Oct 2026 18:58:33 +0200 Subject: [PATCH 4/4] Handle staged kernels and sysroots --- .github/clientside/run-in-kernel.mjs | 26 ++++++++++++++++++++++++ .github/workflows/clientside-test.yml | 4 +++- .github/workflows/publish-clientside.yml | 8 +++++--- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/clientside/run-in-kernel.mjs b/.github/clientside/run-in-kernel.mjs index 4a23b15..db366d0 100644 --- a/.github/clientside/run-in-kernel.mjs +++ b/.github/clientside/run-in-kernel.mjs @@ -17,6 +17,12 @@ // // Needs `playwright` on the module path and a Chromium it can launch // (`npx playwright install --with-deps chromium`, or CHROMIUM_PATH). +// +// A published sysroot carries its wasm binaries as stubs that name the real +// bytes by absolute path under /test-runners/, which the kernel fetches from +// this origin on first use. Those requests are passed through to where the +// website serves them (TEST_RUNNERS_BASE overrides), so what runs here is +// what students get. A raw, unstubbed sysroot never asks. import fs from "node:fs"; import http from "node:http"; @@ -40,6 +46,7 @@ if (!argv.length) { } const DEBUG = !!process.env.KERNEL_DEBUG; +const TEST_RUNNERS_BASE = process.env.TEST_RUNNERS_BASE ?? "https://exercism.org/test-runners"; const files = { "/kernel/kernel.js": [path.join(kernelDir, "kernel.js"), "text/javascript"], @@ -109,9 +116,28 @@ const PAGE = `kernel window.__ready = true; `; +// fetch() has already undone any Content-Encoding, so the bytes go out plain. +// Immutable, as upstream: the kernel fetches a stubbed file every time it is +// opened, and a test suite's links open the same libraries over and over. +async function passThrough(p, res) { + const upstream = await fetch(TEST_RUNNERS_BASE + p.slice("/test-runners".length)); + if (!upstream.ok) { res.writeHead(upstream.status, ISOLATION); return res.end(); } + const bytes = Buffer.from(await upstream.arrayBuffer()); + res.writeHead(200, { + ...ISOLATION, + "Content-Type": upstream.headers.get("content-type") ?? "application/octet-stream", + "Content-Length": bytes.length, + "Cache-Control": "public, max-age=31536000, immutable", + }); + res.end(bytes); +} + const server = http.createServer((req, res) => { const p = new URL(req.url, "http://localhost").pathname; if (p === "/") { res.writeHead(200, { ...ISOLATION, "Content-Type": "text/html" }); return res.end(PAGE); } + if (p.startsWith("/test-runners/")) { + return passThrough(p, res).catch((e) => { console.error("[proxy]", p, e.message); res.writeHead(502, ISOLATION); res.end(); }); + } const entry = files[p]; if (!entry) { res.writeHead(404, ISOLATION); return res.end(); } res.writeHead(200, { ...ISOLATION, "Content-Type": entry[1] }); diff --git a/.github/workflows/clientside-test.yml b/.github/workflows/clientside-test.yml index 7a970d9..01e6db9 100644 --- a/.github/workflows/clientside-test.yml +++ b/.github/workflows/clientside-test.yml @@ -45,12 +45,14 @@ jobs: # From where the website serves them, so what's tested is exactly what # students get. Nothing here is published until the track points at it, # so a PR naming an unpublished kernel or sysroot fails right here. + # --compressed: they are stored brotli-encoded, and what lands on disk has + # to be the decoded bytes, as in a browser. - name: Fetch the kernel and sysroot run: | base="https://exercism.org/test-runners" fetch() { mkdir -p "$(dirname "$1")" - curl --fail --silent --show-error --location --retry 3 "${base}/$1" --output "$1" + curl --fail --silent --show-error --location --retry 3 --compressed "${base}/$1" --output "$1" } for f in kernel.js kernel_bg.wasm kernel_client.mjs; do fetch "${{ steps.config.outputs.kernel }}/${f}" diff --git a/.github/workflows/publish-clientside.yml b/.github/workflows/publish-clientside.yml index 90f38db..c884e14 100644 --- a/.github/workflows/publish-clientside.yml +++ b/.github/workflows/publish-clientside.yml @@ -95,12 +95,13 @@ jobs: } >> "$GITHUB_OUTPUT" # The kernel is needed here, unlike for jq, to build the parser. Both come - # from where the website serves them, as in clientside-test.yml. + # from where the website serves them, as in clientside-test.yml, and are + # stored brotli-encoded, hence --compressed. - name: Fetch the kernel and sysroot run: | fetch() { mkdir -p "$(dirname "$1")" - curl --fail --silent --show-error --location --retry 3 "https://exercism.org/$1" --output "$1" + curl --fail --silent --show-error --location --retry 3 --compressed "https://exercism.org/$1" --output "$1" } for f in kernel.js kernel_bg.wasm kernel_client.mjs; do fetch "test-runners/kernel/${{ steps.config.outputs.kernel }}/${f}" @@ -114,7 +115,8 @@ jobs: # precompile names paths inside the sysroot. A typo, or a version bump that # moves a binary, otherwise fails at boot in a student's browser with # nothing useful in the console. - tar -tf "${{ steps.config.outputs.sysroot_prefix }}/sysroot.tar" | sed 's|^|/|' | sort > sysroot-paths.txt + # The stubs for the sysroot's binaries carry PAX records tar does not know. + tar --warning=no-unknown-keyword -tf "${{ steps.config.outputs.sysroot_prefix }}/sysroot.tar" | sed 's|^|/|' | sort > sysroot-paths.txt status=0 while read -r path; do