Skip to content

Instantly share code, notes, and snippets.

@moznion
Last active August 5, 2026 05:45
Show Gist options
  • Select an option

  • Save moznion/501dd99be95f266f1dd2eda3fb446967 to your computer and use it in GitHub Desktop.

Select an option

Save moznion/501dd99be95f266f1dd2eda3fb446967 to your computer and use it in GitHub Desktop.
Crash reproduction code for PGlite WASI
#!/usr/bin/env bash
# Reproducible build of the *stock* PGLite WASI module: branch
# REL_17_5_WASM-pglite at its current head (2194acf — the only branch
# with the WASI flavor; the default REL_18_3-pglite branch has none),
# compiled by the branch's own wasm-build.sh inside the portable-sdk
# toolchain it pins. None of the wasipg patches are applied.
#
# The single local change is dropping -Wl,--no-stack-first from
# pglite-wasm/build.sh: the SDK's lld 19 does not know that flag, so
# the branch does not link at all without this (build-level only, no
# behavioral effect).
#
# ./build-stock.sh # artifacts land in ./stock-build/out/
#
# Self-contained: docker (buildkit), curl and git are the only
# requirements — no other file from this repository is needed. The
# toolchain image is ~4 GB.
set -euo pipefail
cd "$(dirname "$0")"
REPO=https://github.com/electric-sql/postgres-pglite
REF=2194acf356e9b7bf7cff29c4ed4bd6b82b93895d # head of REL_17_5_WASM-pglite
SDK_VERSION=3.1.74.12.0
SDK_SHA256_AARCH64=ae5be6198935ca67bba69eb80a1ad6e9fcca4f32bc1c993bdf41026700c49a50
case "$(uname -m)" in
arm64|aarch64) UARCH=aarch64 ;;
x86_64) UARCH=x86_64 ;;
*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;
esac
ROOT=$PWD/stock-build
mkdir -p "$ROOT/cache" "$ROOT/out"
# --- 1. portable-sdk: the self-contained toolchain the branch pins ----
SDK_TARBALL=python3.13-wasm-sdk-debian12-${UARCH}.tar.lz4
if [ ! -f "$ROOT/cache/$SDK_TARBALL" ]; then
echo "fetching portable-sdk ${SDK_VERSION} (${UARCH})"
curl -fL --retry 3 -o "$ROOT/cache/$SDK_TARBALL.part" \
"https://github.com/electric-sql/portable-sdk/releases/download/${SDK_VERSION}/${SDK_TARBALL}"
mv "$ROOT/cache/$SDK_TARBALL.part" "$ROOT/cache/$SDK_TARBALL"
fi
if [ "$UARCH" = aarch64 ]; then
echo "$SDK_SHA256_AARCH64 $ROOT/cache/$SDK_TARBALL" | shasum -a 256 -c -
else
echo "WARNING: no pinned sha256 for the $UARCH portable-sdk tarball" >&2
fi
# --- 2. builder image: debian:12 + the SDK at /tmp/sdk, nothing else --
IMG=pglite-wasi-stock-builder:${SDK_VERSION}-${UARCH}
docker build -t "$IMG" --build-arg SDK_TARBALL="$SDK_TARBALL" -f - "$ROOT/cache" <<'EOF'
# syntax=docker/dockerfile:1
FROM debian:12
RUN apt-get update && apt-get install -y --no-install-recommends \
git wget curl ca-certificates lz4 xz-utils bison flex pkg-config \
autoconf automake libtool make patch file python3 \
&& rm -rf /var/lib/apt/lists/*
ARG SDK_TARBALL
RUN --mount=type=bind,source=.,target=/cache \
tar xf /cache/${SDK_TARBALL} --use-compress-program=lz4 -C /
WORKDIR /workspace
EOF
# --- 3. pristine source checkout at the pinned head -------------------
SRC=$ROOT/postgres-pglite
if [ ! -d "$SRC/.git" ]; then
mkdir -p "$SRC"
git -C "$SRC" init -q
git -C "$SRC" remote add origin "$REPO"
fi
git -C "$SRC" fetch -q --depth 1 origin "$REF"
git -C "$SRC" checkout -qf "$REF"
git -C "$SRC" clean -qfdx
# --- 4. the one unavoidable fixup: lld 19 has no --no-stack-first -----
perl -pi -e 's/ -Wl,--no-stack-first//' "$SRC/pglite-wasm/build.sh"
if grep -q 'no-stack-first' "$SRC/pglite-wasm/build.sh"; then
echo "failed to drop --no-stack-first" >&2
exit 1
fi
# --- 5. the runtime-filesystem packer, run inside the container after
# wasm-build.sh. The branch references a wasmfs.txt manifest that
# does not exist in-tree, so this packs the whole share/ + lib/
# trees the module expects at /tmp/pglite instead. ---------------
PACK_SH=$ROOT/pack.sh
cat > "$PACK_SH" <<'EOF'
#!/bin/bash
set -euo pipefail
PGROOT=/tmp/pglite
OUT=/tmp/sdk/dist
# wasm-build.sh's tail steps fail on harmless missing optional pieces,
# so we run unconditionally after it; the real success signal is the
# linked module.
if [ ! -f "$PGROOT/bin/pglite.wasi" ]; then
echo "pack: $PGROOT/bin/pglite.wasi missing — build failed" >&2
exit 1
fi
# Placeholders the boot path expects to exist (argv[0] is
# $PREFIX/bin/postgres; initdb is probed by pgl_initdb).
touch "$PGROOT/bin/initdb" "$PGROOT/bin/postgres"
# The module and the share/ tree are PostgreSQL binaries, and the
# PostgreSQL License requires its copyright notice to travel with every
# copy. Fail loudly rather than silently packing a notice-less bundle.
if [ ! -f /workspace/COPYRIGHT ]; then
echo "pack: /workspace/COPYRIGHT missing — refusing to pack PostgreSQL binaries without their copyright notice" >&2
exit 1
fi
cp /workspace/COPYRIGHT "$PGROOT/COPYRIGHT"
cp /workspace/COPYRIGHT "$OUT/COPYRIGHT"
cd /
tar -cJf "$OUT/pglite-wasi.tar.xz" \
tmp/pglite/COPYRIGHT \
tmp/pglite/bin/pglite.wasi \
tmp/pglite/bin/initdb \
tmp/pglite/bin/postgres \
tmp/pglite/password \
tmp/pglite/etc/postgresql/locale \
tmp/pglite/lib/postgresql \
tmp/pglite/share/postgresql
du -h "$OUT/pglite-wasi.tar.xz"
EOF
chmod +x "$PACK_SH"
# --- 6. run the branch's own build, then pack the runtime FS ----------
docker run --rm \
-e WASI=true -e CI=true -e DEBUG=false \
-e PG_VERSION=17.5 -e PG_BRANCH=REL_17_5_WASM \
-e SDKROOT=/tmp/sdk -e GETZIC=false -e ZIC=/usr/sbin/zic \
-v "$SRC:/workspace:rw" \
-v "$ROOT/out:/tmp/sdk/dist:rw" \
-v "$PACK_SH:/pack.sh:ro" \
-w /workspace \
"$IMG" \
bash -c './wasm-build.sh; /pack.sh'
echo
echo "stock artifacts:"
(cd "$ROOT/out" && shasum -a 256 pglite.wasi pglite-wasi.tar.xz)
#!/usr/bin/env node
// Minimal node:wasi repro for broken ERROR handling in the PGLite WASI build.
// node pglite-node-wasi.mjs <workdir> [sql]... # workdir = extracted pglite-wasi.tar.xz
// Boot recipe as in pglite-bindings (_start → pgl_initdb → use_wire), wire pump over
// PGDATA/.s.PGSQL.5432.{in,out}. Default scenario: good query, ERROR, good query —
// the stock WASI build traps on the first ERROR; a working-sjlj build keeps serving.
import { WASI } from 'node:wasi';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
const workdir = process.argv[2] ?? (console.error('usage: node pglite-node-wasi.mjs <workdir> [sql]...'), process.exit(2));
const sqls = process.argv.slice(3);
if (!fs.existsSync(path.join(workdir, 'tmp/pglite/bin/pglite.wasi'))) {
console.error(`${workdir}: not a workdir — extract the bundle first: tar xf pglite-wasi.tar.xz -C ${workdir}`);
process.exit(2);
}
const dev = path.join(workdir, 'dev');
fs.mkdirSync(dev, { recursive: true });
fs.writeFileSync(path.join(dev, 'urandom'), crypto.randomBytes(128)); // guest opens /dev/urandom by path
const pgLog = fs.openSync(path.join(workdir, 'pg.log'), 'a');
const wasi = new WASI({
version: 'preview1',
args: ['/tmp/pglite/bin/postgres', '--single', 'postgres'],
env: { PREFIX: '/tmp/pglite', PGDATA: '/tmp/pglite/base', PGSYSCONFDIR: '/tmp/pglite',
PGUSER: 'postgres', PGDATABASE: 'template1', REPL: 'N', TZ: 'UTC', PGTZ: 'UTC', PATH: '/tmp/pglite/bin' },
preopens: { '/tmp': path.join(workdir, 'tmp'), '/dev': dev },
stdout: pgLog, stderr: pgLog, returnOnExit: true,
});
const inst = await WebAssembly.instantiate(
await WebAssembly.compile(fs.readFileSync(path.join(workdir, 'tmp/pglite/bin/pglite.wasi'))),
wasi.getImportObject());
wasi.start(inst); // runs main(); with REPL=N it returns
const ex = inst.exports;
const io = (s) => path.join(workdir, 'tmp/pglite/base/.s.PGSQL.5432' + s);
const frames = (buf) => {
const out = [];
for (let n; buf.length >= 5 && (n = buf.readUInt32BE(1)) >= 4 && 1 + n <= buf.length; buf = buf.subarray(1 + n))
out.push({ type: String.fromCharCode(buf[0]), body: buf.subarray(5, 1 + n) });
return out;
};
// Send one client message, tick the guest until ReadyForQuery — or until output
// goes quiet (the auth handshake legitimately ends without Z).
function exchange(payload) {
ex.use_wire(1);
fs.writeFileSync(io('.in'), payload);
let buf = Buffer.alloc(0);
for (let idle = 0; idle < 10 && !frames(buf).some((f) => f.type === 'Z'); ) {
ex.interactive_one();
ex.wasipg_flush?.(); // patched builds: side-effect-free reply flush
try { buf = Buffer.concat([buf, fs.readFileSync(io('.out'))]); fs.rmSync(io('.out')); idle = 0; }
catch { if (!fs.existsSync(io('.in'))) idle++; }
}
return frames(buf);
}
// ErrorResponse/NoticeResponse body: repeated <field-type byte><value\0>, then a final \0.
const fields = (body) => Object.fromEntries(
body.toString('utf8').split('\0').filter(Boolean).map((s) => [s[0], s.slice(1)]));
const report = (f) => {
const e = fields(f.body);
console.log(`<! ${e.S ?? 'ERROR'}: ${e.M ?? ''}${e.C ? ` (SQLSTATE ${e.C})` : ''}`);
for (const [label, v] of [['DETAIL', e.D], ['HINT', e.H], ['WHERE', e.W]]) if (v) console.log(` ${label}: ${v}`);
};
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n); return b; };
const msg = (t, s) => { const b = Buffer.from(s + '\0'); return Buffer.concat([Buffer.from(t), u32(b.length + 4), b]); };
const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
try {
ex.pgl_initdb(); ex.pgl_backend?.(); ex.use_socketfile?.();
const kv = Buffer.from('user\0postgres\0database\0template1\0\0');
const auth = exchange(Buffer.concat([u32(kv.length + 8), u32(196608), kv])) // StartupMessage, protocol 3.0
.find((f) => f.type === 'R' && f.body.readUInt32BE(0) === 5); // AuthenticationMD5Password
exchange(msg('p', 'md5' + md5(md5('password' + 'postgres') + auth.body.subarray(4, 8).toString('binary'))));
let failed = 0;
for (const sql of sqls) {
console.log('->', sql);
const errs = exchange(msg('Q', sql)).filter((f) => f.type === 'E');
errs.forEach(report);
if (errs.length) failed++;
}
console.log(`instance survived all queries (${failed}/${sqls.length} returned an ERROR)`);
} catch (e) {
console.log('guest trapped:', String(e));
console.log(fs.readFileSync(path.join(workdir, 'pg.log'), 'utf8').trimEnd().split('\n').slice(-3).join('\n'));
process.exit(1);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment