Skip to content

Latest commit

 

History

History
1285 lines (1044 loc) · 66.7 KB

porting-guide.md

File metadata and controls

1285 lines (1044 loc) · 66.7 KB

Porting Guide

This guide covers how to port C/C++ software to Kandelo, create Node.js runners, and prepare browser-facing package images.

Overview

The general workflow is:

  1. Cross-compile the software using the SDK (see SDK Guide)
  2. Create a runner script or package image that loads the kernel and program
  3. Handle any platform-specific needs (filesystem setup, fork/exec support, networking)

Porting C Software

Step 1: Cross-compile

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]
make

Executable 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 build

Makefile projects (Redis, SQLite):

make CC=wasm32posix-cc AR=wasm32posix-ar RANLIB=wasm32posix-ranlib [flags]

Step 2: Handle common issues

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.wasm

The 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.

OSS playback with /dev/dsp

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 sdl3

For 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-sndio

SDL2 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=OFF

The 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.

Step 3: Test it

npx tsx examples/run-example.ts /path/to/program.wasm [args]

Shipping runtime files: the lazy-archive pattern

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.

When to use it

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.

How it works

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.

Build-side contract

A porter producing a lazy-archive-backed program creates three things:

  1. packages/registry/<program>/build-<program>.sh — cross-compiles the wasm binary into packages/registry/<program>/bin/<program>.wasm.
  2. packages/registry/<program>/bundle-runtime.sh (only if the source tree already has runtime files that need trimming) — copies the minimal runtime tree into packages/registry/<program>/runtime/.
  3. A declared <program>-browser-bundle package output — its build script stages bin/<program> and share/<program>/… into <program>.zip, and its package.toml declares 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.

Registration

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.

When you also want /bin/<program> symlinks

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.

Reference implementation

Vim:

  • packages/registry/vim/build-vim.sh — cross build.
  • packages/registry/vim/bundle-runtime.sh — minimal runtime tree.
  • packages/registry/vim-browser-bundle/ — declared vim.zip package 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.

Creating a Node.js Runner

The simplest way to run a Wasm program is with examples/run-example.ts. For custom runners, use CentralizedKernelWorker directly.

Minimal runner

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.

Key API: CentralizedKernelWorker

// 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.

Browser UI Integration

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.

Lab page setup

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.

Minimal browser lab

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;
});

BrowserKernel API

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()

Filesystem pre-population

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.

HTTP bridge demos (nginx, WordPress)

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 appPrefix to 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.

Thread support in browser UI and labs

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,
  // ...
});

Interactive terminal surfaces

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 });

Browser UI Patterns

Pattern: Simple program runner

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.

Pattern: Server with HTTP bridge

nginx, PHP-FPM, WordPress. Service worker intercepts requests, connection pump bridges to kernel. See apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts.

Pattern: Database with wire protocol client

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.

Pattern: Interactive shell/REPL

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.

Pattern: Full stack (LAMP)

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.

Adding a new package to the registry

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.

1. Scaffold the directory

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

2. Write package.toml

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.

2b. Write build.toml

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>"
  • revision bumps 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.
  • commit and repo_url are 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 from WASM_POSIX_BUILD_GIT_<NAME>_DIR and 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 tree and set allow_uninitialized_gitlinks = true on that input. The resolver still requires each gitlink to be an empty real directory and every ordinary tracked file to be present.

3. Write build-<name>.sh

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"

4. Verify locally

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.

5. Open a PR

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.

6. Register in run.sh (optional)

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.

Common pitfalls

  • 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 via xtask build-deps output-path — never duplicate the decision in bash.
  • Bumping revision for 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> in depends_on. The resolver builds deps before you and exports their paths via WASM_POSIX_DEP_<NAME>_DIR / _SRC_DIR. Hidden source-tree reads break on clean source-build runs.

Existing Build Scripts

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

Callback casts that change arity trap on wasm

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_full call the GDestroyNotify (1 argument) through GFunc (2 arguments).
  • g_list_sort / g_slist_sort / g_array_sort / g_tree_new store a GCompareFunc (2 arguments) and invoke it as GCompareDataFunc (3 arguments).
  • The G_DEFINE_TYPE / G_DEFINE_INTERFACE / G_DEFINE_DYNAMIC_TYPE_EXTENDED macros in gtype.h / gtypemodule.h register 1-argument class_init / instance_init / iface_init functions through 2-argument GTypeInfo slots. 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_INTERFACE routes the conventional 1-argument interface init through g_wasm_iface_init_thunk, carried in GInterfaceInfo.interface_data. The thunk skips a NULL init, so the common G_IMPLEMENT_INTERFACE (TYPE, NULL) idiom (GtkBox's GtkOrientable, GtkTextView's GtkScrollable) stays valid.
  • g_object_add_weak_pointer / g_object_remove_weak_pointer cast the 1-argument g_nullify_pointer to GWeakNotify (2 arguments) — routed through a matching 2-argument wrapper. Every GtkEventController registers 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's display::opened, widget::destroy, every default handler). The patched g_type_class_meta_marshal{,v} / g_type_iface_meta_marshal{,v} in gobject/gclosure.c skip the per-type marshal and invoke the vtable slot through libffi at exact arity (g_wasm_vtable_meta_marshal{,_va}, modeled on g_cclosure_marshal_generic minus 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 the GDestroyNotify) cast to the 2-argument GTestFixtureFunc and call it through that type, so every GTest case trapped before it ran. The patched gtestutils.c keeps 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_type functions that cast a 1-argument class_init to GClassInitFunc (2 arguments) or a 1-argument instance init to GInstanceInitFunc (2 arguments) — the G_DEFINE_TYPE macro thunks from the glib patch do not cover g_type_register_static call sites (GtkWidget, GtkContainer, GtkCellRenderer, GtkToolButton, GtkStyleProvider, GtkFileChooserEmbed).
  • Hand-written GInterfaceInfo literals that cast a 1-argument interface init to GInterfaceInitFunc — rerouted through glib's g_wasm_iface_init_thunk with the init function in interface_data.
  • g_signal_connect_swapped of a 1-argument function to a signal whose swapped closure calls at 2 arguments (_gtk_style_provider_private_changed in gtkstylecascade.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 to gtk_container_foreach / forall, which call at 2 arguments — each cast site gets a *_wasm_cb wrapper of exact arity.
  • (GWeakNotify) casts of 1-argument functions (gail_focus_object_destroyed in gtk/a11y/gtkaccessibility.c) — the function gains the GObject *where_the_object_was argument. The atk patch fixes the same shape in atkgobjectaccessible.c.
  • Function-pointer registration casts: gtk_main_do_event (1 argument) cast to GdkEventFunc (2 arguments) at gdk_event_handler_set in gtkmain.c — every event dispatch goes through _gdk_event_emit, so this traps on the first event. Replaced by a 2-argument gtk_main_do_event_wasm_cb wrapper.

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 Official Project Tests

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.sh

Kandelo 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 full

Use --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.

Troubleshooting

"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 compiler builds and runtime limits

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 StackIRGenerator on a small macOS worker stack indicates a host-tool stack boundary. For that diagnosed case, BINARYEN_CORES=1 runs 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-size or 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.