This guide covers how to port C/C++ software to Kandelo, create Node.js runners, and prepare browser-facing package images.
The general workflow is:
- Cross-compile the software using the SDK (see SDK Guide)
- Create a runner script or package image that loads the kernel and program
- Handle any platform-specific needs (filesystem setup, fork/exec support, networking)
Most C projects use autoconf, CMake, or plain Makefiles. The SDK handles all three.
Autoconf projects (dash, grep, sed, coreutils):
wasm32posix-configure [--enable-static] [other flags]
makeExecutable links allow unresolved symbols only from the generated host
import list (-Wl,--allow-undefined-file=...). Since ABI 46, ordinary
Autoconf link probes reject missing functions; they no longer succeed for
every symbol. Shared-library side modules still allow unresolved symbols
because the dynamic loader resolves them against the main program.
See SDK Guide for the linking contract.
The configure wrappers load sdk/config.site for target cache answers.
If a cached result disagrees with the target, inspect the actual sysroot:
wasm32posix-nm "$WASM_POSIX_SYSROOT/lib/libc.a" | grep -E ' [TW] function_name$'T and W identify defined functions, including weak definitions; U
is an unresolved reference. A function that links may still report
ENOSYS at runtime, so symbol presence is not proof of syscall support.
For a function supplied by a dependency, inspect that dependency's target
archive and include its required libraries in LIBS.
Inspect upstream's configure.ac, macros, or generated configure before
seeding cache answers. Custom probes may use different keys or meanings
from ac_cv_func_<name>. For example, jq's AC_FIND_FUNC macros also read
ac_cv_funclib_<name>; a cached ac_cv_func_<name>=yes can skip populating
that key and leave the custom result empty. Its math probes use
ac_cv_lib_m_<name>. Seed the keys the macro actually consumes from target
evidence, preserving each macro's expected answer format, rather than
forcing features off to make the build pass.
Cross-compilation still prevents configure from running target programs.
Use verified target answers, and investigate a failed probe's actual
compile/link diagnostic before adding a cache override. Check
POSIX status for runtime semantics, and declare required
libraries explicitly in LIBS rather than forcing library searches to
report success.
CMake projects (MariaDB, PCRE2):
cmake -B build -DCMAKE_TOOLCHAIN_FILE=wasm32-posix-toolchain.cmake [flags]
cmake --build buildMakefile projects (Redis, SQLite):
make CC=wasm32posix-cc AR=wasm32posix-ar RANLIB=wasm32posix-ranlib [flags]Missing features: Check wasm-limitations.md for what cannot be implemented (mprotect, raw server sockets in browser, PTHREAD_CANCEL_ASYNCHRONOUS). Most software has graceful fallbacks for these.
fork() support: If the program uses fork() or fork-like behavior, run
wasm-fork-instrument after linking and after any wasm-opt pass of your
own. The tool runs wasm-opt -O2 over its output itself. Fork-like behavior includes vfork(), _Fork(), shell
pipelines, command substitution, system(), popen(), and helper processes
implemented through fork.
"$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" program.wasm -o program.wasmThe tool auto-discovers the fork-call closure via call-graph analysis (direct + indirect calls). No onlylist file is needed, and no manual tracing of fork paths. Optimize before instrumenting (the SDK's -O link already runs wasm-opt): wasm-opt's inlining shrinks the call graph the tool has to instrument, and the tool's own post-pass then cleans up the code it added. See "Optimization around instrumentation" in fork-instrumentation.md.
Instrumentation is mandatory for fork-using programs. Do not treat it as an
optional optimization, and do not use Binaryen Asyncify as a fallback. The host
requires complete wpk_fork_* exports for fork continuation and rejects legacy
asyncify_* artifacts. See fork-instrumentation.md
for the full transform and ABI.
Thread support: Programs that create threads (MariaDB, Redis) work via the kernel's clone() syscall. No special compilation flags needed, but the host runner must implement the onClone callback.
C++ and libc++: For C++ programs, depend on the libcxx package and
compile against its resolved headers and libraries. Package builds overlay
them onto a private copy of the SDK sysroot with
kandelo_package_prepare_private_sysroot (scripts/package-build-roots.sh);
never copy them into the shared worktree sysroot, whose contents would then
depend on build order. Do not copy libc++
headers from an arbitrary host LLVM install; the libcxx package generates and
ships a version-matched header tree with its libc++.a and libc++abi.a.
See packages/registry/mariadb/build-mariadb.sh for a complete example.
Host code generators: Some build systems run their own generators on the
build machine and compile the output for the target — moc/rcc/uic for Qt,
the same role wayland-scanner fills for Wayland. Declare the generator as a
[[host_tools]] entry and take it from flake.nix, not from an ambient host
install. When the generator ships as part of the same project being
cross-compiled, its version is usually locked to the target version: Qt's CMake
reads the host tools through QT_HOST_PATH and refuses a host/target mismatch,
so flake.nix's pinned Qt fixes the version the recipe may declare. The
resolver understands only >= constraints, so a recipe needing an exact match
must check it itself and fail loudly. See
packages/registry/qtbase/build-qtbase.sh.
A generator the pinned host package does not ship: A host package built for
the build machine carries only the generators that machine's platform enables,
which is not always the set a cross-build needs. nixpkgs' darwin qtbase has no
qtwaylandscanner, because qtbase looks for the Wayland::Scanner it depends
on only if(LINUX); a Qt Wayland cross-build on a Mac therefore stops at
Failed to find the host tool "Qt6::qtwaylandscanner". Build the missing
generator in flake.nix from the same source and version the host package
pins, and publish the CMake package the cross-build looks for. Gate it on the
same condition the host package uses, so the platform that already ships the
generator does not receive a second copy. Keep it out of the recipe's own
output: a package archive is content-hashed and published, so a host binary
inside one makes an arm64 Mac and an x86_64 runner produce different archives
for the same source. See nix/qtwaylandscanner/ and
host/test/qtwaylandscanner-host-tool.test.ts.
Write that package's version file by hand, or clear CMAKE_SIZEOF_VOID_P
before write_basic_package_version_file. CMake's helper bakes the pointer
width of the machine that built the tool into a bitness check, and a wasm32
consumer then rejects the package it just found — reported as
version: 6.10.2 (64bit) under "configuration files were considered but not
accepted". A test project written in LANGUAGES NONE has no pointer width and
will not reproduce it; set CMAKE_SIZEOF_VOID_P to 4 so it does.
OS detection in headers: CMAKE_SYSTEM_NAME=Linux settles the build
system only. Source that branches on preprocessor macros still sees a
toolchain defining __unix__ and __wasm32__ but not __linux__, which the
SDK withholds on purpose — Kandelo has no Linux kernel (sdk/config.site).
A port whose headers demand a known OS needs -D__linux__=1 in its own recipe,
as basu, erlang, spidermonkey and qtbase do. Expect the consequence:
the source then takes Linux paths, and each one must be backed. Qt needed its
futex path disabled and an empty <linux/fs.h> supplied from the package's own
include directory.
Every consumer of that library needs the same defines. They are not recorded in
the archive: a program including <QGuiApplication> without -D__linux__=1
stops at the same qsystemdetection.h error the recipe hit, and one without
-DQT_LINUXBASE compiles against headers that disagree with the archives about
the futex. Carry the recipe's target defines into the consumer's compile line —
see packages/registry/qtbase/test/build-gui-smoke.sh.
Baked install prefixes: A library that records its configure-time
CMAKE_INSTALL_PREFIX writes the resolver's staging directory — whose name
carries the builder's PID — into the shipped artifact, so two builds of the
same source differ. Configure against the guest path the code will run under
and relocate at install time with cmake --install --prefix, then assert the
staging path is absent before the recipe exits. Qt does this through
qt_prfxpath in libQt6Core.a; fontconfig and gdk-pixbuf did it through
--with-templatedir and --localedir.
Kandelo's standard low-level audio API is OSS /dev/dsp, backed by the generic
PCM stream core. Do not enable ALSA or copy Linux /dev/snd headers into a
port. Direct users include <sys/soundcard.h>, open /dev/dsp with
O_WRONLY, negotiate an advertised format/rate/channel count, and use
blocking write() or poll(POLLOUT) plus nonblocking writes. See the exact
ioctl matrix.
Writes are a continuous byte stream, so an application may split a PCM frame
across calls. SNDCTL_DSP_SYNC and final close pad a terminal partial frame
with format silence and wait for it to reach the audio clock.
Upstream SDL needs no DSP-backend source changes. The registry packages build the official releases with only their OSS audio backend:
cargo xtask build-deps resolve sdl2
cargo xtask build-deps resolve sdl3For an external SDL2 build, retain the equivalent of:
./configure --host=wasm32-unknown-none \
--enable-static --disable-shared \
--enable-audio --enable-oss \
--disable-alsa --disable-pulseaudio --disable-pipewire \
--disable-jack --disable-sndioSDL2 2.32.x does not classify wasm32-unknown-none as a Unix target upstream,
so the Kandelo package carries a minimal platform-classification patch to its
generated configure logic. It does not change src/audio/dsp/.
For SDL3, configure a truthful Kandelo CMake toolchain and retain:
cmake -S SDL -B build \
-DCMAKE_TOOLCHAIN_FILE=/path/to/kandelo-toolchain.cmake \
-DSDL_STATIC=ON -DSDL_SHARED=OFF \
-DSDL_UNIX_CONSOLE_BUILD=ON \
-DSDL_AUDIO=ON -DSDL_OSS=ON \
-DSDL_ALSA=OFF -DSDL_PULSEAUDIO=OFF -DSDL_PIPEWIRE=OFF \
-DSDL_JACK=OFF -DSDL_SNDIO=OFFThe registry's SDL3 patch only teaches its platform detector that Kandelo is Unix; its OSS backend is also unmodified. Do not claim Linux or Emscripten as the target to make feature detection pass.
At runtime the default path is already /dev/dsp. Set
SDL_AUDIODRIVER=dsp when a deterministic backend choice is needed (tests,
headless runners, or images that may gain another driver later). SDL also
honors its upstream device-path overrides (AUDIODEV, and SDL_PATH_DSP in
SDL2). Playback supports U8/S16_LE/S16_BE, mono/stereo, and 8–192 kHz. SDL2's
backend is paced primarily by blocking write(); SDL3 additionally waits on
truthful SNDCTL_DSP_GETOSPACE results. Capture/duplex opens fail with
ENOTSUP, so do not advertise recording support in application UI.
The host audio integration suite also builds SDL_mixer 2.8.2's upstream
playwave example unchanged and runs it with SDL_AUDIODRIVER=dsp. It checks
44.1 kHz signed-16 stereo and 22.05 kHz unsigned-8 mono WAVs byte-for-byte at
the paced Node sink, including negotiated fragment geometry, the SDL2 OSS
ioctl sequence, complete drain, and zero discarded data.
npx tsx examples/run-example.ts /path/to/program.wasm [args]Many ported programs depend on a tree of read-only runtime files at execution time - vim's syntax and indent scripts, NetHack's nhdat, Python's stdlib, ncurses terminfo, and so on. Use the lazy-archive pattern to deliver them. It is the canonical approach for Kandelo browser UI images and retained browser labs that need on-demand runtime files.
Two alternatives exist and should be avoided unless you have a specific reason:
- Baking files directly into the VFS image inflates the demo's initial download even when the program is never launched.
- Per-file lazy registration (
registerLazyFile) works for a binary or two but scales badly to thousands of small files because each file issues its own HTTP request on first access.
Any time a ported program needs more than a handful of runtime files that together exceed a few hundred KB. The binary itself can (and usually should) go into the same archive — vim's vim.zip contains both the wasm binary and the runtime tree.
At image-build time, KandeloImageFs.registerLazyArchive({ url, entries, mountPrefix }) (images/vfs/lib/kandelo-image-fs.ts) validates every member path of a zip's central directory, then creates deferred inodes for each file under mountPrefix and records the archive URL in the image. At runtime the kernel owns those inodes: on first read of any member it asks the host for the archive through host_fetch_deferred, keeps the archive and the members it extracts in kernel memory, and serves later reads from there. stat alone never downloads content. The fetch happens once per booted machine; nothing is evicted while the machine runs, which is why large archives count against the kernel's memory budget (see docs/agent-guidance/host-runtime.md).
At runtime the URL stored in the image is usually bare — a plain filename
like vim.zip. The host resolves it when the kernel asks for it: a URL in
lazyUrlMap uses the mapped address, and any other relative URL resolves
against lazyUrlBase (the deployment's base URL by default). The image itself
is never rewritten. When the image records a digest for the fetched bytes, the
kernel checks it before the bytes become file contents, and a failed fetch surfaces as an I/O error on the read that needed
it rather than as a zero-byte file.
A porter producing a lazy-archive-backed program creates three things:
packages/registry/<program>/build-<program>.sh— cross-compiles the wasm binary intopackages/registry/<program>/bin/<program>.wasm.packages/registry/<program>/bundle-runtime.sh(only if the source tree already has runtime files that need trimming) — copies the minimal runtime tree intopackages/registry/<program>/runtime/.- A declared
<program>-browser-bundlepackage output — its build script stagesbin/<program>andshare/<program>/…into<program>.zip, and itspackage.tomldeclares that ZIP as the output. Paths inside the archive are relative (e.g.bin/vim,share/vim/vim91/syntax/c.vim), and the mount prefix chosen at registration time (usually/usr/) turns them into absolute VFS paths.
The VFS-image package depends on that bundle package and resolves its exact
programs/wasm32/<program>.zip output. It must not run the ZIP builder again:
the package output is the one distribution identity that both the image
composer and the eventual Node/browser fetch consume.
Bundle builders must create the archive through
images/vfs/scripts/create-deterministic-zip.sh. The helper gives independent
package and image builds the same byte identity by sorting entry paths,
normalizing distribution modes and ZIP-compatible timestamps, and stripping
host-specific extra fields. Both the bundle wrapper and every helper that
determines its bytes belong in the bundle package's build.toml inputs.
Programs whose runtime files are small enough to version in-tree (NetHack's nhdat after DLB packing, for instance) can skip step 2 and have the zip script pull directly from the build's out/ directory.
images/vfs/scripts/shell-lazy-archives.ts and the shell package are the
reference example:
const archive = registerDeclaredShellLazyArchive(
fs,
SHELL_LAZY_ARCHIVE_SPECS[0],
resolveVfsArtifact,
);The call resolves vim-browser-bundle's declared vim.zip, reads those bytes
once for central-directory indexing and hashing, and creates /usr/bin/vim
and /usr/share/vim/vim91/... as stubs inside the shell VFS. The Kandelo UI
does not need a matching registerLazyFiles entry for the binary - the
stub from the archive is enough.
Create image-level aliases in the VFS image builder (see
populateExtendedSymlinks in build-shell-vfs-image.ts), not inside the
archive. Package-owned links that are part of a program's runtime may remain
inside its archive; the deterministic ZIP helper preserves them and the
registrar validates every member path and link target before adding anything
to the VFS.
Vim:
packages/registry/vim/build-vim.sh— cross build.packages/registry/vim/bundle-runtime.sh— minimal runtime tree.packages/registry/vim-browser-bundle/— declaredvim.zippackage output.images/vfs/scripts/shell-lazy-archives.ts— resolve, validate, index, and hash the exact package output.packages/registry/shell/— direct consumer of the bundle dependency.apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts- rewrites lazy archive URLs when loading image-backed gallery entries.
Follow the same layout for new ports; reviewers will expect it.
The simplest way to run a Wasm program is with examples/run-example.ts. For custom runners, use CentralizedKernelWorker directly.
import { readFileSync } from "fs";
import { CAPTURED_STDIO, CentralizedKernelWorker } from "../host/src/kernel-worker";
import { NodePlatformIO } from "../host/src/platform/node";
import { NodeWorkerAdapter } from "../host/src/worker-adapter";
const CH_TOTAL_SIZE = 40 + 65536;
const MAX_PAGES = 16384;
const kernelBytes = readFileSync("host/wasm/kandelo-kernel.wasm");
const programBytes = readFileSync("program.wasm");
const io = new NodePlatformIO();
const workerAdapter = new NodeWorkerAdapter();
const kernelWorker = new CentralizedKernelWorker(
{ maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true },
io,
{
onFork: async ({
parentPid,
childPid,
parentMemory,
continuation,
}) => {
// childPid is already allocated by Rust. Copy parent memory,
// attach the child host state, and spawn its worker. Pass
// continuation.forkBufAddr to the child init message; it is the
// kernel worker's authoritative linked-frame address, not an address
// that can be derived from the child channel.
// See examples/run-example.ts for full implementation
},
onExec: async (pid, path, argv, envp) => {
// Resolve path to wasm binary, replace process
// Return 0 on success, -2 (ENOENT) if not found
},
onClone: async (attachment) => {
const { pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, memory } =
attachment;
// PID/TID and launch values are bound to Rust's exact clone result by a
// one-shot host transport proof. Allocate a mailbox, consume it with
// kernelWorker.attachThreadChannel(attachment, channelOffset), then
// spawn the thread worker. The host never supplies a numeric TID.
},
onExit: (pid, status) => {
// Handle process exit
},
},
);
// Initialize kernel
await kernelWorker.init(kernelBytes.buffer);
// Create shared memory
const memory = new WebAssembly.Memory({
initial: 17, maximum: MAX_PAGES, shared: true,
});
memory.grow(MAX_PAGES - 17);
// Place channel at end of address space
const channelOffset = (MAX_PAGES - 2) * 65536;
new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0);
// Ask Rust to create the process, then attach its host memory and channel.
const pid = kernelWorker.createProcess(CAPTURED_STDIO);
kernelWorker.registerProcess(pid, memory, [channelOffset]);For a complete example with fork/exec/clone support, see examples/run-example.ts.
// Initialize with kernel wasm bytes
await kernelWorker.init(kernelWasmBytes: ArrayBuffer)
// Create a kernel Process and receive its assigned PID
const pid = kernelWorker.createProcess(CAPTURED_STDIO)
// Attach host memory and channels to that existing Process
kernelWorker.registerProcess(pid, memory, channelOffsets, options?)
// Set process working directory
kernelWorker.setCwd(pid, path)
// Give a spawned (non-PTY) process host-supplied stdin: fd 0 becomes the
// read end of a kernel pipe whose write end the host owns
kernelWorker.installHostStdinPipe(pid)
// Write into that pipe: setStdinData closes it after the bytes, so readers
// see EOF; appendStdinData leaves it open for more
kernelWorker.setStdinData(pid, data: Uint8Array)
kernelWorker.appendStdinData(pid, data: Uint8Array)
// Unregister (after exit)
kernelWorker.unregisterProcess(pid)
// For zombies (keep in kernel until reaped)
kernelWorker.deactivateProcess(pid)ProcessTable in the Rust kernel owns the only PID/TID sequence. It starts at
100 and allocates identities for top-level creation, fork, posix_spawn, and
thread clone. Integration code must treat callback PIDs/TIDs as read-only:
there is no host watermark or API for choosing the next identity.
The browser UI uses BrowserKernel from host/src/browser-kernel-host.ts, which handles the browser kernel worker, process lifecycle, and filesystem in a browser-friendly API. The product UI lives under apps/browser-demos/pages/kandelo/, retained browser labs live under apps/browser-demos/pages/, and the host runtime itself is maintained under host/src/ beside the Node.js host.
Standalone browser lab pages, such as the Network lab, live in apps/browser-demos/pages/<name>/. Each page has:
pages/<name>/
index.html # Page HTML
main.ts # Page logic (TypeScript, bundled by Vite)
Register the page in apps/browser-demos/vite.config.ts:
build: {
rollupOptions: {
input: {
// ... existing pages
"my-demo": path.resolve(__dirname, "pages/my-demo/index.html"),
},
},
},Do not add standalone package demos here by default. New browser-facing software should normally be exposed through a repository-defined Kandelo UI gallery preset or a direct VFS image link.
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Demo</title>
<style>
#output {
background: #1e1e1e; color: #d4d4d4; padding: 1rem;
white-space: pre-wrap; min-height: 200px; font-size: 0.85rem;
}
.stderr { color: #f48771; }
</style>
</head>
<body>
<h1>My Demo</h1>
<button id="run">Run</button>
<pre id="output"></pre>
<script type="module" src="./main.ts"></script>
</body>
</html>main.ts:
import { BrowserKernel } from "@host/browser-kernel-host";
import myProgramUrl from "../../../../path/to/program.wasm?url";
const output = document.getElementById("output") as HTMLPreElement;
const runBtn = document.getElementById("run") as HTMLButtonElement;
const decoder = new TextDecoder();
function appendOutput(text: string, cls?: string) {
const span = document.createElement("span");
if (cls) span.className = cls;
span.textContent = text;
output.appendChild(span);
}
runBtn.addEventListener("click", async () => {
runBtn.disabled = true;
output.textContent = "";
const programBytes = await fetch(myProgramUrl).then(r => r.arrayBuffer());
const kernel = new BrowserKernel({
onStdout: (data) => appendOutput(decoder.decode(data)),
onStderr: (data) => appendOutput(decoder.decode(data), "stderr"),
});
// Fetches the kernel wasm and the canonical rootfs image automatically.
await kernel.initFromImage({ vfsImage: "default" });
const exitCode = await kernel.spawn(programBytes, ["my-program", "--arg"]);
appendOutput(`\nExited with code ${exitCode}\n`);
runBtn.disabled = false;
});const kernel = new BrowserKernel({
maxWorkers?: number, // Max concurrent workers (default: 4)
maxMemoryPages?: number, // Max Wasm pages per process (default: 16384 = 1GB)
env?: string[], // Environment variables
onStdout?: (data: Uint8Array) => void,
onStderr?: (data: Uint8Array) => void,
onListenTcp?: (pid, fd, port) => void, // Called when process binds a port
threadModule?: WebAssembly.Module, // Pre-compiled module for threads
});
// Boot the kernel from a VFS image (fetches kernel wasm if not provided).
// The kernel owns the filesystem from here on.
await kernel.initFromImage({ kernelWasm?: ArrayBuffer, vfsImage: Uint8Array | "default" })
// Change files between process spawns (served by the kernel's filesystem)
await kernel.writeFileToVfs("/data/config.txt", data, 0o644)
await kernel.readFileFromVfs("/data/config.txt")
await kernel.unlinkFileFromVfs("/data/config.txt")
// Spawn a process
const exitCode = await kernel.spawn(programBytes, argv, {
env?: string[],
cwd?: string,
stdin?: Uint8Array, // Complete stdin data (EOF after consumed)
pty?: boolean, // Allocate a PTY for this process
})
// Stdin operations. Host stdin is a kernel pipe on fd 0, so children that
// inherit fd 0 share the stream and its read offset, as on Unix.
kernel.setStdinData(pid, data) // Write the bytes, then close (EOF)
kernel.appendStdinData(pid, data) // Write the bytes, keep open (interactive)
// PTY operations (for terminal demos)
kernel.ptyWrite(pid, data) // Write to PTY master
kernel.ptyResize(pid, rows, cols) // Resize PTY
kernel.onPtyOutput(pid, callback) // Receive PTY output
// TCP connection injection (for HTTP bridge demos)
await kernel.injectConnection(pid, listenerFd, peerAddr, peerPort)
// Pipe operations (for app-level clients like MySQL, Redis)
await kernel.pipeWrite(pid, pipeIdx, data)
await kernel.pipeRead(pid, pipeIdx)
kernel.pipeCloseWrite(pid, pipeIdx)
kernel.pipeCloseRead(pid, pipeIdx)
// Service worker bridge
kernel.sendBridgePort(hostPort, httpPort)
// Cleanup
await kernel.destroy()The kernel owns the filesystem: it boots from a VFS image — a pre-built
binary snapshot of the filesystem — and serves / from it:
import { BrowserKernel } from "@host/browser-kernel-host";
// Fetch kernel wasm and VFS image in parallel
const [kernelBuf, vfsImageBuf] = await Promise.all([
fetch(kernelUrl).then(r => r.arrayBuffer()),
fetch(vfsImageUrl).then(r => r.arrayBuffer()),
]);
// The main thread decodes the image and transfers the bytes; the kernel
// verifies and loads it. The page keeps no filesystem of its own.
const kernel = new BrowserKernel({ onStdout, onStderr });
await kernel.initFromImage({
kernelWasm: kernelBuf,
vfsImage: new Uint8Array(vfsImageBuf),
});See docs/browser-support.md for how to create VFS image build scripts.
For simple demos with few files, boot the canonical image and write the files you need between spawns:
const kernel = new BrowserKernel();
await kernel.initFromImage({ vfsImage: "default" });
// Write a config file
await kernel.writeFileToVfs("/etc/my.conf", new TextEncoder().encode("key=value\n"));
// Load a wasm binary into the filesystem (for exec)
const toolBytes = await fetch(toolWasmUrl).then(r => r.arrayBuffer());
await kernel.writeFileToVfs("/usr/local/bin/my-tool", new Uint8Array(toolBytes), 0o755);Symlinks, ownership and directory trees belong in the image: build them with
KandeloImageFs at image-build time rather than at page load.
For demos that serve HTTP (nginx, PHP-FPM, WordPress), a service worker intercepts browser requests and routes them to the kernel:
import { HttpBridgeHost } from "../../lib/http-bridge";
const DEPLOYMENT_PREFIX = import.meta.env.BASE_URL;
const APP_PREFIX = DEPLOYMENT_PREFIX + "app/";
const SW_URL = DEPLOYMENT_PREFIX + "service-worker.js";
const bridge = new HttpBridgeHost();
// Register service worker and init bridge
const registered = await navigator.serviceWorker.register(SW_URL, {
scope: DEPLOYMENT_PREFIX,
updateViaCache: "none",
});
const reg = await navigator.serviceWorker.ready;
const expectedScope = new URL(DEPLOYMENT_PREFIX, location.href).href;
if (registered.scope !== expectedScope || reg.scope !== expectedScope) {
throw new Error("service worker scope differs from deployment prefix");
}
const reply = new MessageChannel();
await new Promise<void>((resolve) => {
reply.port1.onmessage = () => resolve();
reg.active!.postMessage(
{ type: "init-bridge", appPrefix: APP_PREFIX },
[bridge.getSwPort(), reply.port2],
);
});
// Connect bridge to kernel
kernel.sendBridgePort(bridge.detachHostPort(), 8080);
// When nginx starts listening, load iframe
kernel.options.onListenTcp = (pid, fd, port) => {
document.getElementById("frame").src = APP_PREFIX;
};The service worker (public/service-worker.js) handles:
- Adding COOP/COEP headers for cross-origin isolation
- Routing requests matching
appPrefixto the kernel via MessagePort - Cookie jar for session persistence (WordPress)
Put the worker script at the root of the deployment prefix and use its exact
script-directory scope. Do not use Service-Worker-Allowed to broaden a
worker to / or a sibling deployment. Bridge authority, cookies,
cross-origin-isolation retry state, and the lazy VFS cache belong to that one
scope. The origin-wide Kandelo theme preference is separate UI state and is
not part of the bridge or machine. See
Directory-scoped production hosting
for the complete hosting and troubleshooting contract.
For programs that create threads (MariaDB, Redis), pre-compile the thread module on the main thread to get optimized code:
import { patchWasmForThread } from "../../../../host/src/worker-main";
const programBytes = await fetch(programUrl).then(r => r.arrayBuffer());
const threadPatchedBytes = patchWasmForThread(programBytes);
const threadModule = await WebAssembly.compile(threadPatchedBytes);
const kernel = new BrowserKernel({
maxWorkers: 8,
threadModule,
// ...
});For shell or REPL demos, use PtyTerminal with xterm.js:
import { PtyTerminal } from "../../lib/pty-terminal";
import "xterm/css/xterm.css";
const kernel = new BrowserKernel({ /* ... */ });
await kernel.init();
const terminal = new PtyTerminal(kernel);
terminal.mount(document.getElementById("terminal"));
// Spawn with PTY
const exitCode = await kernel.spawn(programBytes, ["sh"], { pty: true });Fetch wasm, spawn, display output. For the Kandelo UI, wire this through a gallery preset and apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts.
nginx, PHP-FPM, WordPress. Service worker intercepts requests, connection pump bridges to kernel. See apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts.
MariaDB, Redis. Kernel spawns server process, main-thread client connects via pipe operations. See apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts, apps/browser-demos/lib/redis-client.ts, and apps/browser-demos/lib/mysql-client.ts.
PTY allocation, xterm.js terminal, incremental stdin. See apps/browser-demos/pages/kandelo/panes/Shell.tsx and apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts.
Multiple processes (MariaDB + nginx + PHP-FPM + WordPress), database bootstrap, filesystem pre-population, HTTP bridge. See apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts.
A "package" is anything under packages/registry/<name>/ with a
package.toml. The same shape covers static libraries (zlib,
ncurses, openssl, ...), ported programs (vim, php, redis, ...),
composite VFS images (mariadb-vfs, wordpress, shell), and source-only
extracts that other builds reach into (pcre2-source). The resolver
treats all of them uniformly — declaring a package is what gives you
the local-first source-build-and-cache flow described in
docs/package-management.md.
packages/registry/<name>/
package.toml # required — recipe (project-agnostic)
build.toml # required (unless source-only) — project view
build-<name>.sh # required — writes only resolver-owned work/output roots
The recipe — project-agnostic identity, source pin, deps,
outputs. See packages/registry/zlib/package.toml (single library) and
packages/registry/dinit/package.toml (multi-output program with a
library dep) for canonical references; the schema reference is in
docs/package-management.md §Schema.
kind = "program" # or "library" or "source"
name = "myprog"
version = "1.2.3"
kernel_abi = 47 # current ABI_VERSION; required for packages with a [build] block
depends_on = ["zlib@1.3.1"] # transitive deps the resolver will pull first
[source]
url = "https://example.test/myprog-1.2.3.tar.gz"
sha256 = "<64-char lowercase hex>"
[license]
spdx = "GPL-2.0-or-later"
url = "https://example.test/LICENSE"
[build]
script_path = "packages/registry/myprog/build-myprog.sh"
# One [[outputs]] per produced file. Programs typically have 1 wasm;
# multi-output packages (dinit, mariadb, php) declare each separately.
# Layout: 1 output → flat under programs/<arch>/; ≥2 → nested under
# programs/<arch>/<name>/. Bash never hardcodes this; query via
# `xtask build-deps output-path` (or in run.sh use pkg_has_output).
[[outputs]]
name = "myprog"
wasm = "myprog.wasm"package.toml MUST NOT carry revision, [build].repo_url, or
[build].commit — those are project-view fields that live in
build.toml. validate_source rejects them in package.toml with a
clear error pointing at the new home.
For source-only packages (kind = "source"), omit [[outputs]] and
skip build.toml; the resolver extracts the tarball into the cache
and exports the extracted dir as WASM_POSIX_DEP_<NAME>_SRC_DIR for
consumers to reach into.
The project view — sits next to package.toml. Declares this
project's script path + repo + commit + revision + declared build
inputs. Source-only packages don't need one.
script_path = "packages/registry/myprog/build-myprog.sh"
repo_url = "https://github.com/brandonpayton/kandelo.git"
commit = "<commit at which the recipe was last touched>"
revision = 1
# Optional: exact external repository input used by the build.
[[git_inputs]]
name = "upstream_catalog"
repository = "https://github.com/example/catalog.git"
commit = "<exact 40-character lowercase commit>"revisionbumps invalidate the cached build for this package; bump only when output bytes legitimately change (build flag tweaks, fork-instrument output). Don't bump for doc-only changes.commitandrepo_urlare informational recipe provenance.- Use
[[git_inputs]]when output bytes depend on another Git repository. Do not clone it in the build script. Read the resolver-provided sealed checkout fromWASM_POSIX_BUILD_GIT_<NAME>_DIRand verify/use the paired..._COMMIT; its identity is already part of the package cache key. - Gitlinks are rejected by default. If a pinned upstream tree requires its
gitlinks to exist as uninitialized directories, declare the exact
treeand setallow_uninitialized_gitlinks = trueon that input. The resolver still requires each gitlink to be an empty real directory and every ordinary tracked file to be present.
The build script's job is to keep all scratch state below the resolver-owned work root and install exactly its declared outputs below the resolver-owned output root. It must not write generated files into the package checkout or publish a second local mirror itself.
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
source "$REPO_ROOT/sdk/activate.sh"
# These private roots are created and cleaned up by the resolver.
: "${WASM_POSIX_DEP_WORK_DIR:?resolver-owned work directory is required}"
: "${WASM_POSIX_DEP_OUT_DIR:?resolver-owned output directory is required}"
WORK_DIR="$WASM_POSIX_DEP_WORK_DIR"
OUT_DIR="$WASM_POSIX_DEP_OUT_DIR"
# Consume only dependencies declared in package.toml. The DAG resolver
# authenticates and injects their exact output roots before this script runs.
: "${WASM_POSIX_DEP_ZLIB_DIR:?declare zlib in depends_on}"
ZLIB_PREFIX="$WASM_POSIX_DEP_ZLIB_DIR"
# Keep configure, compilation, and every other scratch file under WORK_DIR.
BUILD_DIR="$WORK_DIR/build"
mkdir -p "$BUILD_DIR"
# … typical autoconf / cmake / make flow producing
# "$BUILD_DIR/myprog.wasm", using -I$ZLIB_PREFIX/include and
# -L$ZLIB_PREFIX/lib for compile/link flags. See
# docs/package-management.md §Migrating a consumer to the cache for
# the full CPPFLAGS/LDFLAGS contract.
# The default executable policy is fork instrumentation. Install only the
# exact declared artifact; the resolver validates it before publication.
"$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" \
"$BUILD_DIR/myprog.wasm" -o "$OUT_DIR/myprog.wasm"scripts/dev-shell.sh bash -lc '
set -euo pipefail
host_target=$(rustc -vV | sed -n "s/^host: //p")
# The resolver creates private WORK/OUT roots, verifies the sole declared
# myprog.wasm output, then publishes it into the cache and resolver view.
cargo run -p xtask --target "$host_target" -- build-deps resolve myprog \
--arch wasm32 --binaries-dir "$(pwd)/binaries"
# Query the published layout; do not inspect a package-checkout bin/ directory.
cargo run -p xtask --target "$host_target" --quiet -- \
build-deps output-path myprog myprog.wasm
# → myprog.wasm (single-output, flat)
'If a browser VFS product consumes the package, declare that edge in the
product manifest rather than reading packages/registry/myprog/bin or another
builder's work directory:
[[software.package]]
name = "myprog"
outputs = ["myprog"]
role = "runtime"
materialization = "embedded"
source_roles = []That dependency entry does not itself make a product selectable. A product
also needs a VFS-producing package whose declared output filename equals the
product manifest's output, plus a [[products]] binding in
packages/sets/local-supported.toml. The checked-in Ruby todo product is the
executable example:
# images/vfs/products/browser-ruby-todo.toml
id = "browser-ruby-todo"
output = "ruby-todo-vfs.vfs.zst"# packages/registry/ruby-todo-vfs/package.toml
name = "ruby-todo-vfs"
[[outputs]]
name = "ruby-todo-vfs"
wasm = "ruby-todo-vfs.vfs.zst"
fork_instrumentation = "disabled"# packages/sets/local-supported.toml
[[products]]
id = "browser-ruby-todo"
package = "ruby-todo-vfs"
manifest = "images/vfs/products/browser-ruby-todo.toml"Exercise that registered product through the same local DAG used by other consumers:
scripts/dev-shell.sh bash -lc '
set -euo pipefail
host_target=$(rustc -vV | sed -n "s/^host: //p")
cargo run -p xtask --target "$host_target" -- local-build run \
--set packages/sets/local-supported.toml \
--source-cache-root "$HOME/.cache/kandelo/source-only" \
--output-root "$PWD/local-binaries/source-only-v1" \
--product browser-ruby-todo \
--jobs 16
'Before calling the browser integration complete, select the product through
the consumer registry and build the production app with a non-root prefix such
as VITE_BASE=/candidate-b/. Serve that output at /candidate-b/ and verify
its product URL, service-worker scope, bridge route, and authenticated VFS
group remain beneath that prefix. The exact production commands and complete
group handoff are documented in
Browser Support.
CI source-builds the affected packages from the recipe and runs the test suites against them, exactly as a local build does. Package resolution is local-first: there is no binary staging release, index, or durable ABI release to publish or fetch. A new or changed recipe invalidates the package's cache key, so CI (and every developer) rebuilds it from source under the new key.
Locally, run ./run.sh browser (or ./run.sh local-build) to build the
package through the local SourceOnly graph and its dependent VFS products,
then verify the result.
If users should be able to ./run.sh build <name> or see the package
in ./run.sh status, add a has_<name> / build_<name> pair using
the pkg_has_output helper. See PR #445 for the resolver-driven
pattern that derives layout from package.toml instead of
hardcoding it.
- Forgetting
kernel_abi = <current>. Required for packages with a[build]block on the current ABI. The parser rejects the manifest otherwise. - Hardcoding the output layout in scripts. Multi-output packages
go nested (
programs/<arch>/<pkg>/<out>), single-output flat (programs/<arch>/<out>). Always query viaxtask build-deps output-path— never duplicate the decision in bash. - Bumping
revisionfor doc-only changes. A revision bump invalidates the cache for that package and triggers a full re-source-build of that package and its dependents. Bump only when output bytes legitimately change (compiler flag tweaks, fork-instrument output, etc.). - Source-tree reads instead of declared deps. If your build
script reads
packages/registry/<other>/<x>-src/..., declare<other>independs_on. The resolver builds deps before you and exports their paths viaWASM_POSIX_DEP_<NAME>_DIR/_SRC_DIR. Hidden source-tree reads break on clean source-build runs.
All build scripts are in packages/registry/. They serve as reference implementations:
| Software | Script | Build system | Notes |
|---|---|---|---|
| dash | packages/registry/dash/build-dash.sh |
autoconf | Minimal POSIX shell |
| coreutils | packages/registry/coreutils/build-coreutils.sh |
autoconf | 50+ utilities as multicall binary |
| grep | packages/registry/grep/build-grep.sh |
autoconf | PCRE not included |
| sed | packages/registry/sed/build-sed.sh |
autoconf | Straightforward |
| PHP | packages/registry/php/build-php.sh |
autoconf | CLI + FPM, depends on zlib/libxml2/sqlite/openssl |
| MariaDB | packages/registry/mariadb/build-mariadb.sh |
CMake | Host build + cross build, Aria storage engine only |
| Redis | packages/registry/redis/build-redis.sh |
Makefile | Custom make invocation |
| CPython | packages/registry/cpython/build-cpython.sh |
autoconf | Sealed native-generator + target build; emits the interpreter and complete stdlib runtime closure |
| nginx | packages/registry/nginx/build-nginx-local.sh |
custom configure | Shell-based configure script |
| SQLite | packages/registry/sqlite/build-sqlite.sh |
custom | Single-file amalgamation |
| zlib | packages/registry/zlib/build-zlib.sh |
custom configure | Dependency for PHP |
| libxml2 | packages/registry/libxml2/build-libxml2.sh |
CMake | Dependency for PHP |
| OpenSSL | packages/registry/openssl/build-openssl.sh |
custom Configure | Dependency for PHP |
| pixman | packages/registry/pixman/build-pixman.sh |
autoconf | 0.42.2 (last autotools release), all SIMD disabled |
| utf8proc | packages/registry/utf8proc/build-utf8proc.sh |
direct compile | Single TU, no upstream build system |
| freetype | packages/registry/freetype/build-freetype.sh |
autoconf | png/harfbuzz/brotli/bzip2 disabled |
| fontconfig | packages/registry/fontconfig/build-fontconfig.sh |
autoconf | libxml2 backend, gperf host tool, ac_cv_* RNG/statfs overrides |
| tllist | packages/registry/tllist/build-tllist.sh |
header-only | Staged, nothing compiles |
| fcft | packages/registry/fcft/build-fcft.sh |
meson bypass | Two TUs + three generated headers, no harfbuzz/SVG |
| foot | packages/registry/foot/build-foot.sh |
meson bypass | First stock upstream Wayland client; two patches: gbm prime-fd shm pools, serial font loading |
| libffi | packages/registry/libffi/build-libffi.sh |
in-tree | Full port, no upstream source: gen-dispatch.sh generates the ffi_call call_indirect switch + the static closure trampoline pool (wasm32 cannot JIT) |
| glib | packages/registry/glib/build-glib.sh |
meson bypass | 2.84.4: glib/gmodule/gobject/gio incl. the gdbus client core and the GApplication/GAction/GMenu family (GtkApplication's parent types), hand-curated config.h + glibconfig.h, three patches (no dbus built-ins, wasm callback signatures, wasm credentials backend); GRegex is in, compiled against the pcre2 package — glibmm's Glib::Error::register_init() calls g_regex_error_quark at startup, so every glibmm consumer needs it |
| pcre2 | packages/registry/pcre2/build-pcre2.sh |
cmake | 10.44, 8-bit code unit width only, static, no JIT (wasm cannot generate code at runtime), no pcre2grep/pcre2test; backs glib's GRegex. Separate from the pcre2-source package, which stages the unbuilt tree MariaDB configures itself |
| expat | packages/registry/expat/build-expat.sh |
autoconf | dbus config-parser dependency; entropy from kernel getrandom() |
| dbus | packages/registry/dbus/build-dbus.sh |
autoconf | 1.14.10 (last autotools series): dbus-daemon/dbus-send/dbus-monitor, session bus only, EXTERNAL auth over SO_PEERCRED, package-specific cross-probe cache answers |
| harfbuzz | packages/registry/harfbuzz/build-harfbuzz.sh |
meson bypass | Single-TU amalgam (src/harfbuzz.cc) with the freetype + glib backends; hand-installed headers and .pc; C++, so harfbuzz.pc carries -lc++ -lc++abi from the resolved libcxx (a C consumer's link driver does not add them) |
| fribidi | packages/registry/fribidi/build-fribidi.sh |
autoconf | pango's bidi dependency, plain cross-compile |
| cairo | packages/registry/cairo/build-cairo.sh |
meson | 1.18.6 via upstream meson and sdk/meson/wasm32posix.ini: image surfaces + ft/fc fonts + png, pdf/ps/svg (GTK3 needs cairo-pdf.h; meson ties them and the script surface to zlib), cairo-gobject; upstream 1.18 already passes typed spline callbacks, so no arity patch; the LD_PRELOAD-based cairo-trace tool is not published (Kandelo programs are static) |
| pango | packages/registry/pango/build-pango.sh |
meson | 1.56.4 via upstream meson and sdk/meson/wasm32posix.ini (newest series whose glib/harfbuzz/fontconfig floors the registry meets): pango/pangoft2/pangocairo, no Xft/libthai/introspection; upstream's pango-view/pango-list/pango-segmentation tools are not published from this library package; src/wasm-callback-arity.patch wraps 1-argument free/copy functions cast to GFunc (g_list_foreach / g_slist_foreach) or GCopyFunc (g_ptr_array_copy) (see the arity section below) |
| librsvg | packages/registry/librsvg/build-librsvg.sh |
meson + cargo-c | 2.63.2, the first package with a Rust core: upstream meson builds the C side and runs cargo cbuild for wasm32-unknown-kandelo-std against a private Rust sysroot the script assembles in its work root; libc is pinned to the Kandelo fork, crates are vendored from the tarball's Cargo.lock (checksum-verified), and two crates are patched at the custom-target boundary (patches/, see docs/package-management.md "Patches"); pixbuf API on, no pixbuf loader/introspection/avif; the converter is the separate rsvg-convert package |
| rsvg-convert | packages/registry/rsvg-convert/build-rsvg-convert.sh |
cargo | 2.63.2, librsvg's converter (a Rust program): same source and Rust setup as librsvg (packages/registry/librsvg/rust-build-env.sh), built with cargo build -p rsvg_convert because upstream's meson rule copies an unsuffixed rsvg-convert from the cargo target dir and a Kandelo executable is rsvg-convert.wasm |
| gdk-pixbuf | packages/registry/gdk-pixbuf/build-gdk-pixbuf.sh |
autoconf | 2.36.12 (last autotools release): png loader compiled in statically, no dynamic loader modules; gio_can_sniff=no forces builtin signature sniffing — the default GDK_PIXBUF_USE_GIO_MIME path selects loaders via g_content_type_guess, which returns application/octet-stream without a shared-mime-info database and rejects every image as "Unrecognized image file format" |
| atk | packages/registry/atk/build-atk.sh |
meson bypass | 2.36.0 (final release; GTK 3.24.34 needs >= 2.32, last autotools 2.28 is too old): hand config.h, glib-mkenums + glib-genmarshal generation, upstream TU list; src/wasm-callback-arity.patch adds gpointer class_data to 1-argument class_init functions and routes atkhyperlink's action interface through g_wasm_iface_init_thunk |
| libepoxy | packages/registry/libepoxy/build-libepoxy.sh |
autoconf | 1.5.4 via autoreconf (tarball ships no configure; xorg-macros from flake.nix): EGL dispatch only, vendored EGL/KHR platform headers + headers-only egl.pc stub; GL symbols resolve by dlopen at first call, so no GL library exists until a context is created |
| gtk3 | packages/registry/gtk3/build-gtk3.sh |
autoconf | 3.24.34 (last GTK 3): Wayland backend only, no X11/cups/cloudprint; host glib-compile-resources via env override (the wasm gio-2.0.pc carries no glib_compile_resources variable); libffi/zlib LDFLAGS for the build's own executables (glib's pc files say bare -lffi/-lz); src/wasm-callback-arity.patch fixes arity-changing callback casts (see the arity section below); src/wayland-shm-gbm-pool.patch allocates gdk-wayland's wl_shm pools as gbm prime-fd dumb bos (foot's shm contract — a memfd is private to the process that made it, so the compositor's gbm_bo_import rejects it and the window shows no pixels) |
| basu | packages/registry/basu/build-basu.sh |
meson bypass | 0.2.1 (standalone sd-bus, extracted from systemd by mako's author): hand config.h, errno gperf tables generated with the wasm cpp, no libcap/audit; built with -D__linux__ — the kernel emulates the Linux ABI (SO_PEERCRED, SCM_CREDENTIALS) and without it bus-socket.c hits "#error auth not implemented for this OS" |
| mako | packages/registry/mako/build-mako.sh |
meson bypass | 1.10.0: mako + makoctl on basu's sd-bus (mako speaks sd_bus_*, not gdbus); two patches: gbm prime-fd pool buffers (foot's shm contract), and a parse_boolean rename — basu exports a same-named 1-argument function and both land in one wasm symbol namespace, so basu-internal calls would bind to mako's 2-argument definition and trap; no icons (add gdk-pixbuf + HAVE_ICONS when a demo needs images) |
| libsigcxx | packages/registry/libsigcxx/build-libsigcxx.sh |
autoconf | 2.10.3 (last release shipping configure): the gtkmm stack's signal library. Package name avoids + — the resolver's per-dep env var (WASM_POSIX_DEP_<NAME>_DIR) must be a valid shell identifier |
| glibmm / cairomm / pangomm / atkmm / gtkmm3 | packages/registry/{glibmm,cairomm,pangomm,atkmm,gtkmm3}/build-*.sh |
autoconf | The last autotools release of each -2.4/-1.0/-1.4/-1.6/-3.0 ABI series (2.62.0, 1.12.2, 2.42.0, 2.28.0, 3.24.2); every later point release is meson-only. All C++ TUs need -fwasm-exceptions. Each builds only its library subdir and installs via the top-level install-data-am (umbrella headers + generated config headers + .pc, without recursing into tools/tests/examples). glibmm carries one patch: libc++ dropped the non-standard char_traits<unsigned char>, so contenttype.cc's orphaned basic_string<guchar> overload is deleted. cairomm/pangomm/gtkmm3 pass the freetype/fontconfig includes explicitly — the host pkg-config does not reliably traverse cairo.pc's Requires.private for --cflags |
| gtk-layer-shell | packages/registry/gtk-layer-shell/build-gtk-layer-shell.sh |
meson bypass | 0.9.2: layer-shell for GTK3 windows; wayland-scanner glue for wlr-layer-shell + xdg-shell, version macros passed as -D (no version-header template in this release) |
| libxkbregistry | packages/registry/libxkbregistry/build-libxkbregistry.sh |
meson bypass | The rxkb_* half of libxkbcommon 1.7.0 (one TU against libxml2), split into its own package so adding it does not re-key libxkbcommon and cascade a rebuild through gtk3 and every compositor consumer. Waybar's hyprland/language module links it |
| fmt / spdlog / jsoncpp | packages/registry/{fmt,spdlog,jsoncpp}/build-*.sh |
cmake | Waybar's C++ base: fmt 11.2.0, spdlog 1.15.3 (SPDLOG_FMT_EXTERNAL), jsoncpp 1.9.6. No toolchain file — each passes -DCMAKE_SYSTEM_NAME=Linux, the wasm32posix-{cc,c++} wrappers, -fwasm-exceptions, and CMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY so cmake's compiler probe never links an executable. fmt carries one patch: format.h calls malloc/free without including <cstdlib>, which glibc provides transitively and libc++-on-musl does not |
| waybar | packages/registry/waybar/build-waybar.sh |
meson bypass | 0.14.0, the O2 gate's bar: core + the dependency-free compositor module families (hyprland, sway, river, dwl, niri, wayfire, wlr-taskbar) + simpleclock. The is_linux modules (battery, cpu, memory, systemd units) stay out — their headers are guarded on __linux__, which wasm32-unknown-unknown does not define, and nothing here serves /proc, /sys or logind. src/glib-static-init.c forces glib's initializer ahead of gobject's with a priority-101 constructor: upstream relies on shared-library load order for that, which a static link does not have |
| FFmpeg | packages/registry/ffmpeg/build-ffmpeg.sh |
custom configure | n9.0, unpatched: --arch=wasm --target-os=none (upstream knows wasm and its simd128 extension; --target-os=none makes configure probe everything instead of assuming the build machine's OS). Never --disable-asm, which also turns off simd128. --disable-autodetect keeps the dependency set equal to depends_on, and then needs --enable-pthreads, since threads are autodetected. The configure arguments compiled into the binaries name dependency prefixes symbolically (ffmpeg_normalize_configuration), so the build is the same on every machine. ffplay draws through SDL2's GLES2 renderer, so it needs GL: browser only |
Wasm checks the exact type of every call_indirect. A C idiom that
casts a function pointer to a type with a different argument count (or
a different return type) compiles fine, works on every native ABI, and
traps at runtime on wasm with null function or function signature mismatch.
glib is built on this idiom. The port carries
packages/registry/glib/src/wasm-callback-signatures.patch, which
routes every arity-changing cast through a typed thunk:
g_list_free_full/g_slist_free_full/g_queue_free_fullcall theGDestroyNotify(1 argument) throughGFunc(2 arguments).g_list_sort/g_slist_sort/g_array_sort/g_tree_newstore aGCompareFunc(2 arguments) and invoke it asGCompareDataFunc(3 arguments).- The
G_DEFINE_TYPE/G_DEFINE_INTERFACE/G_DEFINE_DYNAMIC_TYPE_EXTENDEDmacros ingtype.h/gtypemodule.hregister 1-argumentclass_init/instance_init/iface_initfunctions through 2-argumentGTypeInfoslots. The patched macros generate matching*_intern_*wrappers, so code that uses the macros (all of gio, GTK later) is fixed at compile time. G_IMPLEMENT_INTERFACEroutes the conventional 1-argument interface init throughg_wasm_iface_init_thunk, carried inGInterfaceInfo.interface_data. The thunk skips a NULL init, so the commonG_IMPLEMENT_INTERFACE (TYPE, NULL)idiom (GtkBox's GtkOrientable, GtkTextView's GtkScrollable) stays valid.g_object_add_weak_pointer/g_object_remove_weak_pointercast the 1-argumentg_nullify_pointertoGWeakNotify(2 arguments) — routed through a matching 2-argument wrapper. EveryGtkEventControllerregisters a weak pointer on its widget, so any widget with a gesture trapped on dispose.- Class and interface vtable handlers: for an n-parameter signal the
per-type marshal calls
callback (instance, p1..pn, user_data), but a class closure's callback is the vtable slot,handler (instance, p1..pn)— one argument short on every class-closure emission (GTK'sdisplay::opened,widget::destroy, every default handler). The patchedg_type_class_meta_marshal{,v}/g_type_iface_meta_marshal{,v}ingobject/gclosure.cskip the per-type marshal and invoke the vtable slot through libffi at exact arity (g_wasm_vtable_meta_marshal{,_va}, modeled ong_cclosure_marshal_genericminus the trailing data argument). Still unpatched by design: user handlers connected with fewer arguments than the signal (g_signal_connect (win, "destroy", G_CALLBACK (gtk_main_quit), NULL)) ride the per-type c-closure marshal and still trap — connect with a wrapper of exact arity. - GTest:
g_test_add_func/g_test_add_data_func{,_full}store the 0- or 1-argument test function (and theGDestroyNotify) cast to the 2-argumentGTestFixtureFuncand call it through that type, so every GTest case trapped before it ran. The patchedgtestutils.ckeeps the typed callbacks on the test case and calls each through its own type, with upstream's teardown/free ordering unchanged.
GTK 3 carries the same idiom in its own code. The port's
packages/registry/gtk3/src/wasm-callback-arity.patch fixes the
instances the smoke test hits, in seven shapes:
- Handlers connected with fewer arguments than the signal
(
default_display_notify_cb,display_opened_cb,display_closed_cb) get the full marshal arity. - Hand-written
get_typefunctions that cast a 1-argumentclass_inittoGClassInitFunc(2 arguments) or a 1-argument instance init toGInstanceInitFunc(2 arguments) — theG_DEFINE_TYPEmacro thunks from the glib patch do not coverg_type_register_staticcall sites (GtkWidget, GtkContainer, GtkCellRenderer, GtkToolButton, GtkStyleProvider, GtkFileChooserEmbed). - Hand-written
GInterfaceInfoliterals that cast a 1-argument interface init toGInterfaceInitFunc— rerouted through glib'sg_wasm_iface_init_thunkwith the init function ininterface_data. g_signal_connect_swappedof a 1-argument function to a signal whose swapped closure calls at 2 arguments (_gtk_style_provider_private_changedingtkstylecascade.c) — replaced by a 2-argument wrapper at connect and disconnect.(GtkCallback)casts of 1-argument functions (gtk_widget_destroy,gtk_widget_show_all, and static helpers) passed togtk_container_foreach/forall, which call at 2 arguments — each cast site gets a*_wasm_cbwrapper of exact arity.(GWeakNotify)casts of 1-argument functions (gail_focus_object_destroyedingtk/a11y/gtkaccessibility.c) — the function gains theGObject *where_the_object_wasargument. The atk patch fixes the same shape inatkgobjectaccessible.c.- Function-pointer registration casts:
gtk_main_do_event(1 argument) cast toGdkEventFunc(2 arguments) atgdk_event_handler_setingtkmain.c— every event dispatch goes through_gdk_event_emit, so this traps on the first event. Replaced by a 2-argumentgtk_main_do_event_wasm_cbwrapper.
When a new GTK code path traps with this signature, symbolize the
stack (rebuild with a no-op wasm-opt shim on PATH so the name
section survives) and extend the patch with the same shapes.
When porting a GObject-based library, watch for the same pattern in
the library's own code: any (SomeFunc) cast where the target has a
different argument count needs a thunk. The failure mode is a trap at
first use, not a build error. Current executable links reject missing
symbols outside the declared host import list. Shared-library side modules
resolve imports dynamically, so inspect their unresolved symbols with
wasm32posix-nm when a port traps before main.
Also inspect function signatures and dynamic-linking diagnostics rather
than assuming the executable linker silently accepted a missing function.
CPython's source recipe takes its source, work directory, output directory,
sysroot, zlib prefix, and guest prefix from the package-resolver contract. It
builds native generators and the target runtime only under the caller-owned
work root, layers CPython-specific cross probes on sdk/config.site, and emits
python.wasm plus the declared non-Wasm python-runtime.zip. The
disabled legacy python-vfs compatibility recipe consumes those two artifacts
from one resolver dependency root; it does not read CPython's source or build
directories. Staging does not publish that image.
SQLite's upstream test/testrunner.tcl permutations can be run through
Kandelo with scripts/run-sqlite-project-unit-tests.sh. The wrapper runs the
existing official test runner on the Node host, the browser host, or both, and
writes per-host artifacts plus combined-summary.md under test-runs/.
Build the Tcl and SQLite testfixture prerequisites first:
bash packages/registry/tcl/build-tcl.sh
bash packages/registry/sqlite/build-testfixture.shKandelo builds both the shipped SQLite library and the official testfixture
with compound-select, expression, JSON, and trigger recursion limits that fit
current browser and Node WebAssembly host stacks. The testfixture patch set
reads those compiled limits and omits only upstream stress cases that
deliberately exceed them; it does not turn platform failures into successful
SQLite results. The sqllimits1.test SQL-length filler stays comment-only so
the length-limit check does not hit the lower expression-depth limit first.
Then run the harness:
scripts/run-sqlite-project-unit-tests.sh --host both --permutation fullUse --explain to ask SQLite's testrunner to print the planned jobs without
starting a full permutation run. Browser runs launch the SQLite-only demo page
through Vite with KANDELO_BROWSER_DEMO_INPUTS=sqlite-test and disable HMR
with KANDELO_BROWSER_TEST_NO_HMR=1 so long test runs do not churn on
artifact writes.
"sysroot not found": Run ./run.sh setup, which builds the musl
sysroot from scratch when it is missing. If the sysroot already exists
but you just edited libc/musl-overlay/ or libc/glue/, setup only
re-syncs headers — rebuild it explicitly with bash scripts/build-musl.sh.
Graphics shim libraries missing: Programs using DRM/KMS/GBM/EGL/GLES link
against sysroot libraries built by scripts/build-musl.sh. Rebuild the sysroot
with scripts/dev-shell.sh bash scripts/build-musl.sh, then use
wasm32posix-pkg-config --cflags --libs libdrm gbm egl glesv2 from the package
build script. Do not vendor these libraries into the package archive; package
the resulting program or VFS image instead.
"kandelo-kernel.wasm not found": Run ./run.sh setup first.
Fork fails or the host rejects asyncify_* exports: Rebuild the program
through scripts/run-wasm-fork-instrument.sh. Fork-using programs must export
the complete wpk_fork_* set. Legacy Asyncify artifacts are intentionally not
accepted. See fork-instrumentation.md.
"Maximum call stack size exceeded" in browser: Identify the trapped
functions first. Deep guest recursion can exhaust the engine's native
Wasm call stack independently of fork instrumentation or the linear-memory
shadow stack. If the trace points to fork continuation code, inspect the
fork-path closure with wasm-fork-instrument --discover-only and check
whether call_indirect broadens it. Literal table indexes are checked
against active element slots, but dynamic indexes, passive table.init,
and dynamic table writes remain conservative. See the large-compiler
diagnostics below for other resource boundaries.
Deterministic heap corruption / memory access out of bounds in malloc:
Check the linked shadow-stack size before suspecting the allocator. A
frame larger than the remaining stack can silently overwrite globals.
The SDK defaults to 8 MiB only when no stack size was requested; explicit
requests are honored, and repeated requests use the last linker operand.
A script invoking Clang directly must choose its own stack size or get
wasm-ld's roughly 64 KiB default. wasm-objdump -x prog.wasm shows
__stack_pointer; subtract the linked stack size to find its bottom.
See SDK stack policy.
Process hangs on read: The fd might be in blocking mode waiting for data. Check that writers are properly closing their end of the pipe.
Browser SharedArrayBuffer unavailable: Ensure COOP/COEP headers are set. In production, the service worker handles this. In dev, Vite's config sets them.
Large toolchains can fail at several independent layers. Capture the first diagnostic and a symbolized trace before changing resource limits.
- Native LLVM linker crash with very large custom sections: Inspect object and archive section sizes, including compiler-facts metadata, rather than only code size. LLVM toolchain builds have produced several GiB of facts, exceeding native linker's signed section-offset capacity. Preserve the facts required for fork analysis; do not remove metadata or skip instrumentation/admission to make the link finish. Consult compiler-facts format and the SDK guide for the supported representation.
- Native Binaryen crash during optimization: A trace in recursive
StackIRGeneratoron a small macOS worker stack indicates a host-tool stack boundary. For that diagnosed case,BINARYEN_CORES=1runs the normal optimization on the main thread. Keep the full optimization and admission path; this is not a default requirement for all packages. - Browser executable-memory allocation failure: Record the engine diagnostic and compare a fresh machine with repeated tool invocations. Compiled code consumes an engine-managed arena separate from guest linear memory. See the documented Firefox executable-code limit.
- Browser native call-stack exhaustion: Deep compiler recursion can
hit an engine's call-stack limit even when the guest shadow stack has
room. A larger
-z stack-sizeor memory maximum does not fix it. Compare engines and preserve the real failing trace as a documented boundary.
Report successful compiler cases separately from diagnosed engine failures. A test that asserts the boundary proves the diagnostic, not successful compilation of the failed case.