Skip to content
Open
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
105 changes: 69 additions & 36 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,62 +5,61 @@ on:
branches: [main, oss-release-prep]
pull_request:
branches: [main]
schedule:
# Nightly 07:00 UTC: the full toolchain matrix + the full manifest + deep generative fuzzing
# with a rotating seed. This is what catches OTP/Elixir-version codegen drift over time — every
# bug in the 1.20/OTP29 port (map-literal miscompile, gen:call timers, fnegate, …) was a toolchain
# delta, and the fuzzer with a fresh seed each night explores 100 new programs across every cell.
- cron: "0 7 * * *"

permissions:
contents: read

jobs:
# Fast gate: formatting, linting, and the package's own ExUnit suite. Pure-Elixir,
# no exotic toolchain — this is the gate that must always be green.
# ── Fast gate: format, lint, ExUnit, dialyzer on the primary toolchain. Must always be green. ──
lint-test:
name: format · credo · mix test
name: format · credo · test · dialyzer
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Erlang/Elixir
uses: erlef/setup-beam@v1
- uses: erlef/setup-beam@v1
with:
otp-version: "27.0"
elixir-version: "1.17.1-otp-27"

- name: Cache deps & build
uses: actions/cache@v4
otp-version: "29.0.2"
elixir-version: "1.20.2-otp-29"
- uses: actions/cache@v4
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-mix-

key: ${{ runner.os }}-otp29-mix-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-otp29-mix-
- run: mix deps.get
- run: mix format --check-formatted
- run: mix credo
- run: mix test
# Dialyzer: PLT is built into _build (cached above); first cold run ~1 min.
- run: mix dialyzer

# Differential gate: the real proof — every suite run bit-exact against the Elixir VM.
# `verify.exs fast` runs the 6 suites that need only Node 24 + Binaryen (conformance,
# fuzz, gaps, genfuzz, regexdiff, effects); the workerd-backed suites (scoreboard,
# markdown) are gated separately. See verify.exs and BUILD.md.
# ── PR/push gate: the differential manifest (fast subset) on the two toolchain corners. The
# compiler consumes BEAM bytecode, which changes most across OTP majors — so we prove the
# oldest supported (1.17/OTP27) and the newest (1.20/OTP29) on every change. ~2 min each. ──
verify:
name: verify.exs (differential vs the VM)
name: verify (fast) · ${{ matrix.elixir }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { elixir: "1.17.1-otp-27", otp: "27.0" }
- { elixir: "1.20.2-otp-29", otp: "29.0.2" }
steps:
- uses: actions/checkout@v4

- name: Set up Erlang/Elixir
uses: erlef/setup-beam@v1
- uses: erlef/setup-beam@v1
with:
otp-version: "27.0"
elixir-version: "1.17.1-otp-27"

- name: Set up Node.js 24 (JSPI + WasmGC)
uses: actions/setup-node@v4
otp-version: ${{ matrix.otp }}
elixir-version: ${{ matrix.elixir }}
- uses: actions/setup-node@v4
with:
node-version: "24.16.0"

- name: Install Binaryen (wasm-as) version_130
run: |
set -euo pipefail
Expand All @@ -69,12 +68,46 @@ jobs:
tar xzf binaryen.tar.gz
echo "WASM_AS=$GITHUB_WORKSPACE/binaryen-version_130/bin/wasm-as" >> "$GITHUB_ENV"
echo "NODE=$(command -v node)" >> "$GITHUB_ENV"
- run: mix deps.get
- name: Differential manifest (fast) vs the Elixir VM
run: elixir verify.exs fast

- name: Verify toolchain
# ── Nightly only: the FULL supported {elixir, otp} diagonal × the FULL manifest (adds the
# workerd-scale suites: scoreboard, markdown, calc-parser) + deep generative fuzzing
# (PROGS=100, seed rotates per run so each night explores fresh programs; failures print the
# GENSEED for a one-line repro). fail-fast:false so one cell's break doesn't hide the others. ──
verify-matrix:
name: verify (full) · ${{ matrix.elixir }}
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { elixir: "1.17.1-otp-27", otp: "27.0" }
- { elixir: "1.18.3-otp-27", otp: "27.3" }
- { elixir: "1.19.5-otp-28", otp: "28.1" }
- { elixir: "1.20.2-otp-29", otp: "29.0.2" }
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
otp-version: ${{ matrix.otp }}
elixir-version: ${{ matrix.elixir }}
- uses: actions/setup-node@v4
with:
node-version: "24.16.0"
- name: Install Binaryen (wasm-as) version_130
run: |
node --version
"$WASM_AS" --version
elixir --version

- name: Run the differential manifest
run: elixir verify.exs fast
set -euo pipefail
curl -fsSL -o binaryen.tar.gz \
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz
tar xzf binaryen.tar.gz
echo "WASM_AS=$GITHUB_WORKSPACE/binaryen-version_130/bin/wasm-as" >> "$GITHUB_ENV"
echo "NODE=$(command -v node)" >> "$GITHUB_ENV"
- run: mix deps.get
- name: Full differential manifest + deep fuzz (PROGS=100, rotating seed)
env:
PROGS: "100"
GENSEED: ${{ github.run_number }}
run: elixir verify.exs
4 changes: 2 additions & 2 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
erlang 27.0
elixir 1.17.1-otp-27
erlang 29.0.2
elixir 1.20.2-otp-29
nodejs 24.16.0
5 changes: 3 additions & 2 deletions bench/gaps/GAPS_FOUND.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
Elixir.Protocol.UndefinedError.exception/1 20
Elixir.ArgumentError.exception/1 19
Elixir.Kernel.inspect/1 19
elixir_erl_pass.no_parens_remote/2 18
erlang.phash/2 18
Elixir.ArgumentError.exception/1 17
Elixir.Kernel.inspect/1 17
Elixir.Enum.EmptyError.exception/1 14
Dict.update/4 6
Elixir.IO.warn_once/3 6
Elixir.List.-inlined-last/2-/2 3
Elixir.UnicodeConversionError.exception/1 2
Elixir.KeyError.exception/1 1
Expand Down
16 changes: 8 additions & 8 deletions bench/scoreboard/SCOREBOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,24 @@ concrete call is generated from a typed input pool (VM-validated); the SAME call
on WasmGC and on the Elixir VM, results folded through an identical checksum and diffed.
`nogen` = no candidate input matched yet (a harness gap, not a runtime failure).

**TOTAL: 487/487 bit-exact (100.0%) · 79 not yet generated · 566 public functions**
**TOTAL: 496/496 bit-exact (100.0%) · 82 not yet generated · 578 public functions**

| Module | bit-exact | nogen | public fns | failing |
|--------|-----------|-------|------------|---------|
| Enum | 109/109 | 3 | 112 | |
| List | 42/42 | 0 | 42 | |
| Enum | 112/112 | 4 | 116 | |
| List | 45/45 | 0 | 45 | |
| Map | 42/42 | 0 | 42 | |
| Keyword | 50/50 | 0 | 50 | |
| Keyword | 49/49 | 1 | 50 | |
| Tuple | 7/7 | 0 | 7 | |
| Integer | 17/17 | 0 | 17 | |
| String | 76/76 | 0 | 76 | |
| Integer | 19/19 | 0 | 19 | |
| String | 77/77 | 0 | 77 | |
| Range | 10/10 | 0 | 10 | |
| MapSet | 20/20 | 0 | 20 | |
| Float | 16/16 | 0 | 16 | |
| Atom | 3/3 | 0 | 3 | |
| Bitwise | 12/12 | 0 | 12 | |
| Access | 13/13 | 3 | 16 | |
| Access | 14/14 | 3 | 17 | |
| Function | 1/1 | 3 | 4 | |
| Date | 30/30 | 20 | 50 | |
| Time | 19/19 | 21 | 40 | |
| Time | 19/19 | 22 | 41 | |
| NaiveDateTime | 20/20 | 29 | 49 | |
71 changes: 71 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# `pyex` — run general Python on WasmGC, like a real `python` binary

```
cli/pyex script.py # run a file
cli/pyex -c "print(1+1)" # run a snippet
echo "print('hi')" | cli/pyex # run stdin
REBUILD=1 cli/pyex ... # force recompiling the interpreter
```

Runs the real [ivarvong/pyex](https://github.com/ivarvong/pyex) (a Python-3 interpreter in Elixir,
~146 modules) compiled to WasmGC and prints what `print()` emitted — byte-identical to CPython on the
programs tested (functions, classes/dunders, comprehensions, generators, slicing, f-strings, dicts,
exceptions, exact bignums).

The interpreter is built by **`mix wasm.build`** run inside pyex — configured declaratively in pyex's
`mix.exs` (`:wasm` key: entry module, exports, and the dependency sandbox boundary). `cli/pyex` just
ensures `pyex/wasm/pyex.wasm` exists (building it once) and runs your program through it. Both projects
use Elixir 1.20/OTP29. Point `PYEX_DIR` at your pyex checkout.

For agent loops, use the **`PyexSandbox`** class in `cli/sandbox.mjs`: compile once, `box.run(code)` per
snippet (~0.3 ms), isolated + sandboxed + step-bounded. See its header for the API.

---

# `elw` — compile & run pure Elixir on WasmGC as a CLI

```
cli/elw <file.ex> [entry] [int-arg ...]
```

Runs the whole pipeline for you: `elixirc` → `beam2wasm.exs` (EXPORTS/STUB/BIGNUM) →
`wasm-as` → a Node runner that instantiates the WasmGC module, calls `entry`, and prints
its return value as JSON (walked out of the heap by `termToJs`). Host effects (IO, File,
`:math`, `:crypto`, exact bignums) are wired from `runtime/imports.mjs`.

- `entry` defaults to `main`; integer args cross the boundary as f64 (exact to 2^53).
- The return value is printed as JSON. Exact integers larger than 2^53 come back as strings.
- `IO.puts`/`IO.write` reach the real stdout before the return value is printed.

### Pulling in stdlib / deps

The user file is compiled alone by default. To include stdlib or dependency modules,
list them in `DEPS` (resolved to their `.beam` via `:code.which`):

```
DEPS="Enum,String" cli/elw myprog.ex main
```

### Examples

```
cli/elw prog.ex add 40 2 #=> 42
cli/elw prog.ex fact 20 #=> "2432902008176640000" (exact)
DEPS="Enum" cli/elw prog.ex main #=> {":list":[1,4,9], ...}
```

### Env knobs

- `NODE` / `WASM_AS` — override toolchain binaries (defaults: pinned nvm 24.x, `wasm-as` on PATH).
- `KEEP=1` — keep `cli/_work/out.{wat,wasm}` build artifacts for inspection.
- `DEPS="Mod,Mod"` — extra modules to compile alongside the entry file.

### Notes / limits

- `STUB=1` is on: constructs the compiler can't lower yet become traps so the module still
builds; if the entry hits one you'll see a Wasm trap. Fix at the root per the project rule.
- Entry return type is `term`, so any value walks back. Args are integers only for now
(extend the runner's marshalling for lists/binaries if needed).
- The CLI sets `ATOMNAMES=1` so atom keys print by name (`:greeting`, not `:idx12`); this is
an env-gated compiler flag, off by default, so it doesn't affect the verify suites.
```
60 changes: 60 additions & 0 deletions cli/elw
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# elw — compile a pure-Elixir file to WasmGC and run one of its functions, end to end.
#
# cli/elw <file.ex> [entry] [int-arg ...]
#
# Defaults: entry = "main", no args. Integer args are passed across the boundary as f64
# (exact to 2^53). The entry's return value is printed as JSON (walked out of the WasmGC
# heap). Pure programs with no external deps just work; to pull in stdlib/deps modules,
# set DEPS to a comma/space list of module names, resolved via :code.which:
#
# DEPS="Enum,String" cli/elw myprog.ex main
#
# Env: NODE / WASM_AS override the toolchain binaries. KEEP=1 keeps the build artifacts.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"

file="${1:?usage: elw <file.ex> [entry] [int-arg ...]}"; shift
entry="main"; if [[ $# -gt 0 && "$1" != -* && ! "$1" =~ ^-?[0-9]+$ ]]; then entry="$1"; shift; fi
args=("$@")

# toolchain: prefer $NODE, else the project-pinned 24.x line, else PATH.
node="${NODE:-}"
[[ -z "$node" ]] && node="$(ls "$HOME"/.nvm/versions/node/v24.*/bin/node 2>/dev/null | tail -1 || true)"
[[ -z "$node" ]] && node="$(command -v node)"
wasmas="${WASM_AS:-$(command -v wasm-as)}"

build="$root/cli/_work"; mkdir -p "$build"

# 1. Elixir source -> BEAM bytecode.
elixirc -o "$build" "$file"
primary="$(basename "$(ls -t "$build"/Elixir.*.beam | head -1)")"
beams=("$build/$primary")

# extra dependency modules, resolved to their .beam on disk via the running VM.
if [[ -n "${DEPS:-}" ]]; then
for m in ${DEPS//,/ }; do
p="$(elixir -e "IO.write(:code.which(Elixir.$m))")"
beams+=("$p")
done
fi

# 2. build the EXPORTS signature: numeric args are `int`, everything else `bin` (a string).
# Return type is `term`, so any value walks back out of the heap.
sig=""
for a in ${args[@]+"${args[@]}"}; do
if [[ "$a" =~ ^-?[0-9]+$ ]]; then sig+="int,"; else sig+="bin,"; fi
done
sig="${sig%,}"
exports="$entry:$sig->term"

# 3. BEAM -> WAT -> WASM. STUB=1 lets unlowered paths become traps so the module still builds;
# BIGNUM=1 gives exact integers (and the int_val bridge termToJs decodes).
wat="$build/out.wat"; wasm="$build/out.wasm"
STUB=1 BIGNUM=1 ATOMNAMES=1 EXPORTS="$exports" elixir "$root/beam2wasm.exs" "${beams[@]}" > "$wat"
"$wasmas" "$wat" -o "$wasm" -all

# 4. instantiate + call + print.
"$node" "$root/cli/runcli.mjs" "$wasm" "$wat" "$entry" ${args[@]+"${args[@]}"}

[[ -n "${KEEP:-}" ]] || rm -f "$wat" "$wasm" "$build"/Elixir.*.beam
31 changes: 31 additions & 0 deletions cli/pyex
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# pyex — run general Python on WasmGC, like a real `python` binary.
#
# cli/pyex script.py # run a file
# cli/pyex -c "print(1+1)" # run a snippet
# echo "print('hi')" | cli/pyex # run stdin
# REBUILD=1 cli/pyex ... # force recompiling the interpreter (mix wasm.build)
#
# The interpreter is the real ivarvong/pyex compiled to WasmGC by `mix wasm.build` — configured
# declaratively in pyex's mix.exs (module, exports, dependency sandbox boundary). No hand-rolled
# build here: this just ensures the wasm exists, then runs your program through it.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
pyex="${PYEX_DIR:-/Users/ivar/code/pyex}"
wasm="$pyex/wasm/pyex.wasm"

# ---- resolve the Python source: -c <code> | <file.py> | stdin ----
if [[ "${1:-}" == "-c" ]]; then src="${2:?-c needs a code string}"
elif [[ -n "${1:-}" && "$1" != "-" ]]; then src="$(cat "$1")"
else src="$(cat)"; fi

node="${NODE:-$(ls "$HOME"/.nvm/versions/node/v24.*/bin/node 2>/dev/null | tail -1)}"
[[ -z "$node" ]] && node="$(command -v node)"

# ---- build the interpreter once (cached), via the declarative mix task ----
if [[ -n "${REBUILD:-}" || ! -f "$wasm" ]]; then
echo "pyex: compiling the interpreter to WasmGC via 'mix wasm.build' (first run)…" >&2
( cd "$pyex" && mix wasm.build --out wasm >/dev/null )
fi

exec "$node" "$root/cli/pyex-run.mjs" "$wasm" "$src"
18 changes: 18 additions & 0 deletions cli/pyex-run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// pyex-run.mjs <wasm> <source|-> — run a Python program through the compiled pyex interpreter and
// print what print() emitted, like `python`. Thin CLI over PyexSandbox; a runtime error goes to
// stderr with exit 1. Reads the program from argv[3], or from stdin when it is "-".
import fs from "node:fs";
import { PyexSandbox } from "./sandbox.mjs";

const [wasmPath, srcArg] = process.argv.slice(2);
const source = srcArg === "-" || srcArg === undefined ? fs.readFileSync(0, "utf8") : srcArg;

// 0 = pyex's default step budget (a CLI run isn't a hostile agent snippet).
const box = new PyexSandbox({ wasmPath, maxSteps: 0 });
const { ok, stdout, error } = box.run(source);

if (ok) process.stdout.write(stdout);
else {
process.stderr.write("Traceback (pyex):\n" + error + "\n");
process.exit(1);
}
Loading
Loading