Skip to content

Toolkit on Kandelo — the C/C++ library stack behind foot, Waybar and mako - #1318

Open
mho22 wants to merge 18 commits into
explore-dri-hyprlandfrom
explore-dri-toolkit
Open

Toolkit on Kandelo — the C/C++ library stack behind foot, Waybar and mako#1318
mho22 wants to merge 18 commits into
explore-dri-hyprlandfrom
explore-dri-toolkit

Conversation

@mho22

@mho22 mho22 commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Why

explore-dri-hyprland gives Kandelo a tiling Wayland compositor, but every client on it is written for Kandelo. Nothing proved the compositor was a real Wayland server rather than one that happens to satisfy its own clients.

Running an unmodified upstream client is that proof, and it is not a compositor change — it is a toolkit problem. foot needs freetype, fontconfig and fcft. Waybar needs GTK3, which needs glib, gobject, gio, cairo, pango, harfbuzz, fribidi and pixman. glib needs libffi with real closures. mako needs a D-Bus session bus. None of that existed on the wasm32 sysroot.

This PR is the library stack. It was split out of #1241, which had grown to carry both the ports and the desktop built on them. The desktop now sits on top of this branch.

What changed

The C library stack, bottom up

  • libffi — a full port on generated call_indirect dispatch. Wasm has no calling convention to synthesize at runtime, so the port generates the dispatch table at build time. glib's closures depend on it.
  • glib 2.84.4 — gmain, gobject and gio.
  • dbus 1.14.10 — the session bus daemon plus the gdbus client core.
  • pixman 0.42.2 and utf8proc 2.9.0.
  • cairo, pango, harfbuzz and fribidi — text renders.
  • freetype, fontconfig, tllist and fcft — the font stack foot resolves monospace through.
  • foot 1.17.2 — the first unmodified upstream Wayland client to run on Kandelo.
  • GTK 3.24.34 — a real toolkit on the wasm32 sysroot.
  • Waybar 0.14.0 — the C++ stack behind an unmodified bar.
  • mako 1.10.0 over basu 0.2.1 — notifications on the bus.

The protocol XML the toolkit clients need

wlr-layer-shell-unstable-v1.xml and presentation-time.xml are declared as build inputs by packages/registry/wayland-protocols/build.toml. Waybar is a layer-shell client and foot asks for presentation feedback, so both belong here rather than in the desktop PR. Verified with xtask build-deps program-index-context-check.

What the ports exposed underneath

  • SDK: an 8 MiB shadow stack, and link inputs are passed in the order given. The C++ stacks overflow the previous default and depend on link order.
  • libc: bits/fenv.h for both arches, and the linux/input-event-codes.h entries the toolkit needs.
  • Kernel and host: fix(kernel/host): the runtime gaps the toolkit ports exposed changes crates/kernel/src/{signal,syscalls,wasm_api}.rs and host/src/{kernel-worker,worker-main}.ts, and adds six C fixtures with a host test each — poll timeout preserving its pollfds, sendmsg iovec gather, signal without restart, signal to a threaded process, thread vararg alignment, and fork with a polling thread. These are real defects reached by real software, not port-specific shims.
  • Packages: recipes write to the resolver's output directory rather than a path of their own.

ABI

ABI_VERSION goes 43 → 44, with abi/snapshot.json regenerated in the same change. bash scripts/check-abi-version.sh exits 0 and reports "ABI_VERSION and snapshot are consistent". Because v44 has no published binary release yet, the matrix build publishes binaries-abi-v44/ when this lands.

Both hosts

The kernel and host fixes in fix(kernel/host): the runtime gaps the toolkit ports exposed touch shared code (host/src/kernel-worker.ts) rather than one host entry point, so Node and the browser take the same path. The 25 changed files under host/test/ cover it.

Testing

  • cargo test -p kandelo --target aarch64-apple-darwin --lib1665 passed, 0 failed.
  • bash scripts/check-abi-version.shexit 0.
  • scripts/run-libc-tests.sh302 passed, 20 XFAIL, 0 build failures, and 1 unexpected failure: regression/raise-race. That test forks 100 children inside a signal handler; 5 waits return ECHILD, reproducibly. scripts/run-libc-tests.sh:163 skips it whenever CI=true, so it does not gate this PR, and whether it regressed or predates the stack is not yet established.
  • scripts/run-posix-tests.sh174 passed, 0 FAIL, 3 XFAIL, 2 SKIP

Both suites need WASM_POSIX_RESOLUTION_POLICY=source-only-v1 and WASM_POSIX_SOURCE_ONLY_BINARY_ROOT=<repo>/local-binaries/source-only-v1. binaries-abi-v44/index.toml does not exist until the toolkit tier merges, so the binaries/ provenance tier cannot be materialized locally. The full host vitest suite needs both provenance tiers resolvable at once, which only CI's prepared workspace provides — it is the gate for that suite and for the browser demos.

The stack has since been rebased onto main at 888e628d5. Two gates were re-run at the new stack tip ccc7c6214 (explore-dri-omarchy): bash scripts/check-abi-version.shexit 0, snapshot in sync and the 43 → 44 bump consistent; scripts/ci-run-test-suite.sh vitest exact-abi-source3049 passed, 0 failed, with two files failing to collect on paths #1321 moved to crates/runtime-core/. That collection failure is a main defect and #1337 fixes it.

229 files changed, 23,037 insertions, 1,060 deletions.

mho22 added 18 commits August 30, 2026 11:06
glib, GTK and every toolkit above them call through libffi, and wasm has
no way to build a call frame at runtime: the call target's signature has
to exist in the module's type section before the module is compiled.

The port generates a dispatch table over the signatures the stack
actually uses and reaches it through call_indirect, with static closure
trampolines for the callback direction. That is why the earlier
Wayland-scoped shim is not enough here — a toolkit registers callbacks
of shapes the shim never enumerated.
The event loop, the type system and the I/O layer every GTK program is
written against. gio is ported without its gdbus half, which arrives
with the bus itself in the next step, so the dependency runs one way:
glib does not need a bus to exist.

Callbacks land through typed thunks over the libffi dispatch, which is
what makes gobject signals work at all on this target.
A desktop notification daemon is a bus client, so the bus has to exist
before mako does. The session bus runs as an ordinary process and
clients reach it over a unix socket.

Authentication is EXTERNAL over SO_PEERCRED: the bus asks the kernel
who is on the other end of the socket rather than trusting a uid the
client sends, which is the only mechanism here that cannot be spoofed
by the client.
pixman is the pixel-region and compositing primitive cairo draws
through; utf8proc is the grapheme segmentation a terminal needs to know
what one character is. Both are leaf ports with no toolkit dependency,
which is why they land before cairo.
Four libraries that together turn a string into pixels: harfbuzz shapes
it, fribidi orders it bidirectionally, pango lays it out, cairo draws
it. A toolkit needs all four before it can render a label, so they land
as one step.
… stack

foot rasterises its own glyphs rather than going through a toolkit, so
it needs the font stack directly: freetype to rasterise, fontconfig to
resolve a family name to a file, fcft to cache the result, tllist as
fcft's list primitive.
…lient

foot is a real terminal that was never written with this kernel in
mind, which is the point: everything it needs has to already work, and
what does not work fails at its call site rather than in code written
around the gap.

It found several: the libc surface gaps fixed here are the ones its
build and startup exposed.
GTK is the first consumer that exercises the whole stack beneath it at
once — libffi dispatch, gobject signals, the gio loop, cairo and pango
for drawing, fontconfig for text. Porting it is what makes an
unmodified GTK application, rather than a purpose-built client, able to
run here.
Waybar is a C++ GTK application, so it brings the parts of the C++
runtime the C ports never needed: exceptions, the standard library's
locale and iostream machinery, and gtkmm's binding layer over gobject.

It is the bar the desktop runs as-is, which is the test that matters —
a status bar that talks to the compositor over the layer-shell protocol
and to the system over dbus, with nothing written for this port.
mako is the notification daemon: it owns org.freedesktop.Notifications
on the session bus and draws toasts as layer-shell surfaces. basu is
the sd-bus implementation it links, a systemd-free fork, which is what
lets it speak the bus without pulling systemd in.
GTK and Waybar recurse far deeper than any C program the SDK had linked
before — deep enough that the default shadow stack overflowed during
widget construction, which surfaces as a corrupted heap rather than a
clean overflow.

The linker also has to honour the order inputs are given. C++ static
initialisers and the archive members they pull in are order-sensitive,
so reordering inputs silently drops symbols that only one member
defines.
Six defects that only appear once real applications run: a threaded
event loop, a bus client, a process that takes signals while parked.

Signals now reach a thread parked in a host-converted epoll wait — foot
reaps children with SIGCHLD while sitting in its loop, and the wait has
to end for the handler to run. A park that ends to run a handler only
restarts when SA_RESTART says so. A poll that times out keeps its
pollfd array instead of returning the kernel's scratch, and its retry
map is snapshotted so a concurrent wake cannot mutate it mid-walk. A
thread's initial stack pointer is 16-byte aligned, which C++ requires
and the previous alignment happened to satisfy only by luck. sendmsg
and recvmsg walk every iovec rather than the first. A shared memfd
mapping falls back to populate-only when it cannot be mapped shared.
Harnesses that spelled an ABI number into an expected message now
derive it, or state the fixture's own pinned number where the fixture
is what is being asserted against.

The two poll-sigmask export calls added by the epoll signal fix each
carry control scalars only — a pid, a guest tid, and a mask word read
from the caller's address space through an already-bounded pointer — so
each is registered as a reviewed occurrence rather than left to fail
the default-deny audit.
A recipe that writes outside the directory the resolver hands it leaves
its outputs where the cache cannot see them, so the next resolve
rebuilds it. Pointing every recipe at the resolver's own output root is
what makes a warm cache actually warm.

msmtpd's build script also has to join its own cache key: a script that
is an input to the package it builds must be hashed with it, or an edit
to the script reuses a stale archive. The new libraries are classified
as platform packages, since the sysroot exposes them to any client.
Waybar is a layer-shell client and foot asks for presentation feedback,
so both protocols have to be generatable before either builds. The
package already declares them as inputs; this adds the XML itself.
foot's slave execvp's the shell, and exec bytes come only from the
retained kernel target — never from a host resolver callback. The smoke
handed dash's bytes to `onResolveExec`, which that path does not
consult, and then set `PATH=/usr/bin:/bin`. On host-FS passthrough the
guest therefore walked into the developer machine's own /bin/dash,
which is not a Wasm module, and the slave failed with ENOEXEC.

Stage the real wasm32 dash under a private bin inside the test root and
point PATH at it. The callback goes with the assumption behind it.
`scripts/ci-vitest-evidence-classes.tsv` must exactly cover the live
Vitest file inventory, so `ci-run-test-suite.sh vitest exact-abi-source`
refuses to enumerate while the two drift. This branch raises
ABI_VERSION, which is exactly when that group runs.

Classify the test files this branch adds, and drop the rows for files it
no longer carries. A file is `source-only` only when its whole import
closure stays clear of the binary resolver; the runner enforces that
separately from the cover check. The smokes exec staged wasm, so they
are `prepared-product`; the remaining unit tests import only `host/src/`.
`foot.wasm`, `waybar.wasm` and `mako.wasm` each carry an absolute host
path that no longer exists:

    .../libs/.fontconfig-2.15.0-rev1-wasm32-<sha>.build-stage-34774-0/
        share/fontconfig/conf.avail

That is fontconfig's `--with-templatedir`, which it derives from
`--prefix` when not told otherwise. `--prefix` is the resolver's staging
directory, deleted at publication. `waybar.wasm` carries gdk-pixbuf's
`share/locale` the same way, from `--localedir`.

Nothing breaks at runtime. The one use of `FC_TEMPLATEDIR` is
`src/fcinit.c:98`, `FcConfigParseOnly (config, FC_TEMPLATEDIR, FcFalse)`
— `complain` is false and the result is discarded. The config that
matters comes from `--with-baseconfigdir=/etc/fonts`, already a guest
path. `GNOME_LOCALEDIR` is compiled in even under `--disable-nls` and
read only when a catalogue exists.

What does break is reproducibility. The staging directory name carries
the builder's PID, so two builds of fontconfig produce different
`libfontconfig.a`, and that propagates into all three binaries. The
resolver compares the loser's receipt against the winner's and aborts
the whole local build on a mismatch — the failure ncurses already
produces. foot, waybar and mako have simply not lost the race yet.

Give both paths explicitly. fontconfig also needs
`-ffile-prefix-map=$SRC_DIR=.`: the build is out-of-tree against an
absolute srcdir, so `assert` in fcmatch.c, fcserialize.c and fcweight.c
expands `__FILE__` to the work directory.

Bump both revisions: output bytes change.

Verified by building each recipe twice into separate output roots:

    fontconfig   6 files, differing: libfontconfig.la, fontconfig.pc
    gdk-pixbuf  16 files, differing: libgdk_pixbuf-2.0.la, gdk-pixbuf-2.0.pc

Both `.a` files are now byte-identical and hold no build path. The `.la`
and `.pc` files differ only in their `prefix=` line, which
`rewrite_install_prefix_paths` rewrites to the canonical entry before
publication.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant