In-Browser Runtime

How it works

The full path from a click on Run to a program executing on the user's own machine — and back to the terminal.

The idea#

A programming language is just a way to hand instructions to a computer. The computer that runs student code does not have to be a server we own — it can be the user's own machine, reached through the one runtime every device already ships with: the web browser.

Browsers run native-speed code through WebAssembly (WASM). So AutoLabDocs ships the toolchains themselves — a Python interpreter, a C/C++ compiler — compiled to WASM, plus a thin JavaScript host, and runs everything client-side. The old model (a Node.js node-pty service spawning Docker containers over WebSocket) has been removed entirely.

Architecture#

The IDE asks a factory for a runner, hands it code and a callback, and forgets about the language. Each run gets its own dedicated Web Worker.

data flow
components/IdeWorkspace.tsx              (IDE / terminal UI)
   │  getRunnerForFile(filename).run({ code, onEvent })
   ▼
lib/runtime/index.ts                     (engine selector + Runner)
   │  spawns one Web Worker per run
   ▼
 js/worker  |  python/worker  |  cpp/worker
 new Function|  Pyodide (WASM) |  clang+lld+libc++ → program.wasm → WASI
   │  postMessage(RunEvent)        ▲  Atomics + SharedArrayBuffer
   ▼                               │  (blocking stdin)
 terminal output               user keystrokes

Every engine speaks one message protocol, so the UI is identical regardless of language. The protocol is the contract; the engines are interchangeable implementations behind it.

The event protocol#

A Runner is the entire interface the IDE talks to:

lib/runtime/index.ts
interface Runner {
  run(opts: { code: string; filename: string;
              onEvent: (e: RunEvent) => void }): Promise<void>;
  sendInput(data: string): void;  // user typed a line
  stop(): void;                   // kill the program
}

type RunEvent =
  | { type: "output";        data: string }    // stdout / stderr
  | { type: "input_request"; prompt?: string } // blocked on stdin
  | { type: "exit";          code: number }     // finished
  | { type: "error";         data: string };    // exception / compile error

getRunnerForFile(name) selects the engine by extension (.js → JS, .py → Python, .c/.cpp/… → C/C++) and returns a worker-backed runner. Because this protocol matches what the terminal already consumed from the old WebSocket, swapping the server for the browser was a near drop-in change.

Blocking stdin — the hard part#

Interactive programs (input(), std::cin >>, prompt()) must block until the user types something. You can't block the main thread — the page would freeze — and postMessage is asynchronous, yet the runtimes call stdin synchronously. The fix is SharedArrayBuffer + Atomics:

  • The program runs inside a Web Worker (a real second thread).
  • Worker and main thread share one SharedArrayBuffer (lib/runtime/stdinChannel.ts).
  • On a stdin read, the worker posts input_request (UI shows a prompt) then calls Atomics.wait() — blocking the worker thread, not the UI.
  • The user submits; the main thread writes the bytes into the buffer and calls Atomics.notify().
  • The worker wakes, decodes the bytes, and returns them to the program.
shared buffer layout
Int32[0]   = control flag (0 = waiting, 1 = ready)
Int32[1]   = payload byte length
bytes[8..] = UTF-8 input

Cross-origin isolation#

SharedArrayBuffer is only available on cross-origin-isolated pages, so next.config.ts sends:

next.config.ts
Cross-Origin-Opener-Policy:   same-origin
Cross-Origin-Embedder-Policy: credentialless
Note.credentialless is chosen over require-corp so cross-origin assets — the Pyodide CDN, the banner image, analytics — keep loading without each needing CORP headers.

The language engines#

JavaScript

Zero downloads, instant. The worker redirects console.* to output, exposes a synchronous prompt()/readline() backed by the stdin channel, and runs user code wrapped in an async IIFE so top-level await works.

Python

Real CPython via Pyodide (CPython compiled to WASM), loaded from its CDN on first run. setStdout/setStderr stream to the terminal; setStdin is wired to the blocking channel; loadPackagesFromImports() auto-installs numpy, pandas, matplotlib and friends on demand.

C / C++

A real clang + lld + libc++ toolchain compiled to WASM compiles and links the program on the client; the resulting program.wasm runs through a WASI runtime wired to stdout/stderr/stdin. The toolchain (~tens of MB) is not bundled — a setup step installs it under public/runtime/clang/ behind a small adapter with a stable build()/run() contract.

Heads up.The JS and Python engines are production-ready. The C/C++ engine's wiring and contract are in place; it requires the WASM toolchain assets to be hosted (via scripts/fetch-runtimes.sh) and a real-browser validation pass.

Run lifecycle#

  • handleRun resolves the file's language; an unsupported extension shows a friendly message and stops.
  • A fresh worker is spawned for the run.
  • Output chunks append to the terminal and buffer into the file's lastOutput so the exported lab document captures the run.
  • input_request focuses the terminal input; the submitted line returns via sendInput → the stdin channel.
  • On exit, buffers flush onto the file (lastOutput/lastInput/lastError).
  • Stop calls worker.terminate() — killing even infinite loops, which the old container model couldn't do cleanly.

Security model#

  • Code runs in the browser's Web Worker sandbox: no DOM, no parent-page access, no filesystem, no path into our infrastructure.
  • There is no execution server to attack.
  • Stopping a run terminates the worker and frees all its memory immediately.

Extending it — add a language#

Implement one worker that:

  • listens for { type: 'run', code, filename, sab },
  • emits output / input_request / exit / error,
  • (optionally) uses readStdinBlocking(sab, …) for input.

Then register the extension in languageForFile() and add a new Worker(new URL("./<lang>/worker.ts", import.meta.url)) branch in createWorker(). The UI needs no changes.