Planet Igalia

May 15, 2026

Gyuyoung Kim

Blink for Apple tvOS: 2026 Update

At BlinkOn 20 in 2025, I introduced our experimental work on bringing Blink to Apple tvOS. You can also find a blog post covering that initial work here: https://blogs.igalia.com/gyuyoung/2026/05/09/introduce-blink-for-apple-tvos/.

If you’re interested in the background and early prototype, you can find more details in my previous post on Blink for iOS and related work: https://blogs.igalia.com/gyuyoung/2024/08/08/chrome-ios-browser-on-blink/.

Over the past year, we have continued developing this effort, and I recently had a chance to share an update along with a demo running on a real Apple TV device at BlinkOn 21 in 2026. In this post, I’d like to walk through what has changed since the initial prototype, what works today, and what challenges still remain.

A quick recap

Apple TV runs tvOS, which is derived from iOS, but it comes with important differences. Most notably, tvOS does not provide a WebKit WebView for third-party applications. This means that any application requiring web functionality needs to embed its own web engine. This constraint was one of the key motivations behind exploring whether Blink, originally being ported to iOS, could also be adapted to tvOS.

While the idea sounds straightforward, the reality is more complicated. tvOS lacks several low-level system APIs required for Chromium’s multi-process architecture, which makes it impossible to use the standard process model. On top of that, BrowserEngineKit, which the iOS Blink effort relies on, is not available on tvOS. There are also platform restrictions such as the lack of JIT support, and the input model is fundamentally different since Apple TV relies on a remote control rather than touch or pointer-based interaction. Because of these constraints, our goal has not been to build a full-featured browser, but rather to enable Blink-based web capabilities in a way that works within the limitations of the platform.

Progress over the last year

Over the past year, we have made steady progress toward that goal. One of the most significant milestones is that we have upstreamed the initial tvOS implementation. This means that the work is no longer just an isolated experiment, but part of the upstream Chromium codebase. As part of this effort, we enabled content_shell running on the tvOS simulator and on actual Apple TV devices. Moving from simulator-only execution to running on real hardware was an important step, as it allowed us to validate real-world behavior and platform integration.

We have also improved platform integration in several areas. Crashpad support has been added, and input handling for the Apple TV Remote has been significantly improved. The latter is particularly important because navigating web content with a remote requires a focus-based interaction model, which is quite different from what Blink typically assumes on desktop or mobile platforms.

On the web platform side, we have enabled a number of features that make it possible to run more realistic content. WebAssembly now works in interpreted mode, which allows execution within the constraints of the platform. We have also enabled VP9 software decoding and verified hardware-accelerated decoding for H.264 and H.265. These improvements are essential for media playback scenarios and were necessary to support the demo content.

To support ongoing development, we also set up reference bots for tvOS builds and tests. This helps ensure that the port can be maintained over time and reduces the risk of regressions as upstream Chromium continues to evolve.

Demo on a real device

This demo shows playing a YouTube video in content_shell running on a real Apple TV device. The demo also showed that we can navigate the video using the remote controller, which highlights the progress we’ve made in adapting Blink to the tvOS interaction model. While simple, this demonstration is an important milestone because it proves that Blink can run real-world web content on actual hardware.

Current limitations

Despite this progress, several challenges remain. One of the most noticeable issues is build stability. The tvOS port has been broken frequently, mainly because both the iOS and tvOS ports are still experimental and not always considered in upstream changes. In particular, configurations such as the WebAssembly interpreter mode are not consistently handled by all changes, leading to breakage.

Testing is another area where limitations are evident. Since the port relies on a single-process model, running web tests is currently not supported, which makes it harder to validate correctness and compatibility. There are also platform-level gaps, such as missing accessibility support and the absence of certain UI components like file choosers and color pickers. As a result, some web pages do not behave as expected on tvOS.

Next steps

Looking ahead, our focus is on improving the robustness and maintainability of the port. This includes stabilizing the build, expanding test coverage, and investigating ways to run web tests in a single-process environment. We also plan to keep the port up to date with the latest tvOS SDK and continue maintaining it in upstream Chromium.

Closing thoughts

Over the past year, Blink for tvOS has evolved from an initial experiment into a working upstream port that can run on real devices. While it is still early and many challenges remain, the progress so far shows that it is possible to bring Blink-based web capabilities to a constrained platform like tvOS. We will continue exploring this space and see how far this effort can go.

Finally, I would like to thank all the contributors, reviewers, and sponsors who made this work possible.

Igalia Contributors

  • Abhijeet Kandalkar
  • Gyuyoung Kim
  • Jeongeun Kim (Julie)
  • Raphael Kubo da Costa

by gyuyoung at May 15, 2026 03:31 AM

May 11, 2026

Igalia WebKit Team

WebKit Igalia Periodical #64

Update on what happened in WebKit in the week from May 4 to May 11.

This week we have a bag of exciting updates, such as fixes to crashes, better YouTube playback, a handful of advancements to WebXR, and the development releases of WebKitGTK and WPE WebKit 2.53.2.

Cross-Port 🐱

If the filesystem runs out of space while the NetworkProcess is writing into its network cache, the process will crash with SIGBUS. This would surface to users as the "Internal error fired from WebLoaderStrategy.cpp(559) : internallyFailedLoadTimerFired" error, and would be handled by re-spawning another NetworkProcess that would similarly fail.

This was addressed by using fallocate, if available, to reserve the required size. If fallocate fails to reserve, the NetworkProcess will skip caching, avoiding the crash. If fallocate is not available, the existing behaviour is preserved.

Networking 📶

Networking support, including the libsoup HTTP library.

libsoup now supports the zstd compression encoding.

Multimedia 🎥

GStreamer-based multimedia support for WebKit, including (but not limited to) playback, capture, WebAudio, WebCodecs, and WebRTC.

getUserMedia() and getDisplayMedia() support should work better thanks to a couple PipeWire related fixes.

Playback of some YouTube videos (usually at low framerate) has been fixed. Eventually a better solution will involve supporting edit lists in the GStreamer MSE backend.

Graphics 🖼️

A crash when accessing the diagnostics webkit://gpu page was fixed, making sure we handle the case where libGL.so.1 or libOpenGL.so.0 are missing.

Fixed missing glyph before ZWJ/ZWNJ if no font is found for the cluster.

The WebXR implementation based on OpenXR has gained support for quad, equirect and cylinder layers.

Releases 📦️

The second unstable releases for the current development cycle have been published: WebKitGTK 2.53.2 and WPE WebKit 2.53.2. Development releases are intended is to gather early feedback on upcoming changes, and as such issue reports are welcome in Bugzilla.

That’s all for this week!

by Igalia WebKit Team at May 11, 2026 08:04 PM

Alex Bradbury

Building 32-bit RISC-V sysroots and images with Yocto

Thanks to the Debian 64-bit RISC-V port it's really easy to build a sysroot appropriate for cross-compiling Clang/LLVM and its separate test suite. Either use my rootless-deboostrap-wrapper script or the command I documented in LLVM's cross-compilation instructions, being sure to see the note on working around a Ninja dependency issue. For a bootable QEMU image, Debian-based recipes are similarly straightforward. But we don't have the luxury of a precompiled distribution for 32-bit RISC-V and so we'll lean on Yocto to produce the needed sysroot by building from source. I cover three cases: 1) building a sysroot for cross-compiling projects like LLVM, 2) doing the same but in a way that requires fewer build steps, 3) building an image approximating my debootstrap image recipes.

In this article I use release 5.3 ('Whinlatter'), which introduced the bitbake-setup helper tool. For documentation, I found the Yocto quick build guide, and bitbake-setup docs, and image customisation guide helpful.

I'm not a Yocto developer, so if you reading this and think there are other approaches to consider or alternative ways of solving the problem that are better, please do drop me a note!

Common setup

I'm running on Arch Linux which isn't one of the tested Yocto host distributions, but seemed to work just fine.

I found I needed to enable the en_US locale:

sudo sed /etc/locale.gen -i -e "s/^\#en_US.UTF-8 UTF-8.*/en_US.UTF-8 UTF-8/"
sudo locale-gen

And install the following additional packages:

sudo pacman -S inetutils chrpath cpio diffstat rpcsvc-proto flex bison zstd

Now we will check out bitbake into a work directory and set a directory to be used to hold downloaded files:

mkdir yocto-work && cd yocto-work
git clone https://git.openembedded.org/bitbake
./bitbake/bin/bitbake-setup settings set default dl-dir $HOME/.cache/yocto/dl

Producing a sysroot based on core-image-minimal

As is often the case, the workload I'm interested in here is LLVM. If you're looking to build a sysroot to cross-compile something else, you may need a slightly different package list.

In this first stanza, we use bitbake-setup to initialise our development environment. Because there isn't a predefined machine target for riscv32 in bitbake/default-registry/configurations/poky-whinlatter.conf.json, we avoid selecting machine and will address it later. Importantly, we set a SSTATE_DIR which will be used for the shared state cache, avoiding rebuilding packages when not necessary (I'm not totaly sure when this isn't exposed in bitbake-setup settings like dl-dir is).

./bitbake/bin/bitbake-setup init --non-interactive \
  --skip-selection machine \
  ./bitbake/default-registry/configurations/poky-whinlatter.conf.json \
  poky \
  distro/poky

printf 'SSTATE_DIR = "%s"\n' "$HOME/.cache/yocto/sstate" >> bitbake-builds/site.conf

With that done, we can source the generated definitions to enter the build environment (note we're using the default setup directory, you can override it to something other than poky-whinlatter by using --setup-dir-name) and use enable-fragment to set the qemuriscv32 machine:

. bitbake-builds/poky-whinlatter/build/init-build-env
bitbake-config-build enable-fragment machine/qemuriscv32

Now configure the build, indicating the additional libraries that need to be present and run bitbake to actually produce it:

cat >> conf/local.conf <<'EOF'
IMAGE_INSTALL:append = " \
  glibc-dev \
  libgcc \
  libgcc-dev \
  libatomic \
  libatomic-dev \
  libstdc++ \
  libstdc++-dev \
"
EOF

bitbake core-image-minimal

This results in 4482 build tasks and takes quite some time to complete if you haven't run it before (i.e. aren't hitting in the sstate cache). The next section of this article explores how to produce the needed output while building much less, but let's finish the job and extract a rootfs from what was built. I would like to now follow advice in the documentation and do runqemu-extract-sdk tmp/deploy/images/qemuriscv32/core-image-minimal-qemuriscv32.rootfs.tar.zst ~/rv32sysroot, except that fails because the runqemu-extract-sdk script doesn't recognise .tar.zst (I've submitted a patch). So instead we manually extract the .tar from the .tar.zst and then run the runqemu-extract-sdk script:

zstd -d -k -f tmp/deploy/images/qemuriscv32/core-image-minimal-qemuriscv32.rootfs.tar.zst
runqemu-extract-sdk tmp/deploy/images/qemuriscv32/core-image-minimal-qemuriscv32.rootfs.tar ~/rv32sysroot

At this point, you have a sysroot that's almost directly usable for cross-compiling Clang/LLVM (with --target=riscv32-poky-linux) but there are three finalisation steps we will perform:

  • Add an additional symlink to the tree so that upstream Clang's search procedure for the GCC install finds the correct directory. The combination of these two downstream patches which Yocto applies to its own Clang builds would make this unnecessary. I'm not sure if upstreaming has ever been pursued.
  • Convert all absolute symlinks to relative ones. Yocto provides a Python script for this, which is in our $PATH after sourcing build/init-build-env.
  • (Optional) Apply workaround for a ninja issue that would otherwise mean incremental builds don't work.
mkdir -p "$HOME/rv32sysroot/usr/lib/gcc" && ln -s ../riscv32-poky-linux "$HOME/rv32sysroot/usr/lib/gcc/riscv32-poky-linux"
sysroot-relativelinks.py $HOME/rv32sysroot
ln -s usr/include $HOME/rv32sysroot/include

Producing a sysroot with fewer build steps

The core-image-minimal recipe above is straightforward, but does a lot more work than strictly necessary. We can reduce this by instead adding a dependency-only recipe that explicitly lists the needed build-time dependencies and contains logic to produce the sysroot.

First, create a layer:

. bitbake-builds/poky-whinlatter/build/init-build-env
bitbake-layers create-layer --add-layer ../layers/meta-rv32-llvm-sysroot

Then add the recipe:

recipe_dir="../layers/meta-rv32-llvm-sysroot/recipes-devtools/rv32-llvm-deps-sysroot"
mkdir -p "$recipe_dir"

cat > "$recipe_dir/rv32-llvm-deps-sysroot.bb" <<'EOF'
SUMMARY = "Dependency-only recipe to export an RV32 sysroot"
LICENSE = "MIT-0"

INHIBIT_DEFAULT_DEPS = "1"
EXCLUDE_FROM_WORLD = "1"
PACKAGE_ARCH = "${MACHINE_ARCH}"

DEPENDS = "virtual/libc libgcc virtual/${MLPREFIX}compilerlibs zlib"

inherit deploy nopackages

do_configure[noexec] = "1"
do_compile[noexec] = "1"
do_install[noexec] = "1"
do_populate_sysroot[noexec] = "1"

do_deploy() {
  export_dir="${DEPLOYDIR}/${PN}-${MACHINE}"
  rm -rf "$export_dir"
  mkdir -p "$export_dir"
  cp -a "${RECIPE_SYSROOT}/." "$export_dir/"

  sysroot-relativelinks.py "$export_dir"

  mkdir -p "$export_dir/usr/lib/gcc"
  ln -s ../riscv32-poky-linux "$export_dir/usr/lib/gcc/riscv32-poky-linux"
  ln -s usr/include "$export_dir/include"
}
addtask deploy after do_prepare_recipe_sysroot before do_build
EOF

The do_deploy function implements the sysroot preparation logic that largely mirrors the previous section. Otherwise, DEPENDS specifies the needed dependencies (of these, virtual/${MLPREFIX}compilerlibs is a bit magic - this resolves to the compiler runtime provider which pulls in things like libstdc++).

Build the sysroot with:

bitbake rv32-llvm-deps-sysroot

This performs ~850 build tasks and will produce the sysroot at tmp/deploy/images/qemuriscv32/rv32-llvm-deps-sysroot-qemuriscv32/.

The sysroot is slightly larger than the one in the section above because it contains large unstripped static archives like usr/lib/libstdc++.a.

Producing a featureful image bootable in QEMU

Watch this space!


Article changelog
  • 2026-05-11: Initial publication date.

May 11, 2026 12:00 PM

May 08, 2026

Gyuyoung Kim

Introduce Blink for Apple tvOS

At BlinkOn 20 in 2025, I gave a short lightning talk about an experimental project called Blink for Apple tvOS. Although the presentation took place about a year ago, I wanted to take some time to provide more context on why we started this work, what challenges we encountered along the way, and where the project stands today in this blog again.

Motivation

Apple TV runs tvOS, which is based on iOS, but it differs in some important ways. One of the most notable differences is that tvOS does not provide a WebKit WebView for third-party applications. This limitation has significant implications, as applications that need web functionality must embed their own web engine.

A well-known example is the YouTube app on Apple TV, which uses a custom web engine called Cobalt. This engine is based on an outdated Chromium fork, and maintaining such a fork becomes increasingly difficult over time, especially as the web platform continues to evolve.

At the same time, the Chromium community has been exploring Blink-based implementations on Apple platforms, including the experimental Blink for iOS project. As that work progressed, it naturally led to a new question: whether Blink could also be brought to tvOS. Beyond that, we also started wondering if it would be possible to eventually upstream Blink support for tvOS. These questions became the starting point of this project.

Challenges

Although tvOS is derived from iOS, porting Blink to this platform turned out to be far from straightforward. One of the biggest challenges comes from the lack of support for the multi-process architecture that Chromium relies on. Several low-level system APIs, such as fork(), mach_msg(), and posix_spawn_*(), are not available on tvOS, which makes it impossible to adopt the standard process model.

Another major limitation is the absence of BrowserEngineKit, which the Blink port on iOS uses for process management and integration. Without this framework, alternative approaches are required to make the system work on tvOS.

In addition, tvOS does not allow JIT compilation due to platform restrictions, which directly affects the execution model of V8. This requires running JavaScript in a more restricted mode compared to other platforms.

The input model also differs significantly. Apple TV primarily relies on a remote control, which leads to a focus-based navigation model rather than pointer-based interaction. This affects how web content needs to be handled and navigated.

Given all these constraints, it became clear that the goal of this project should not be to build a fully-featured browser. Instead, we focused on enabling Blink-based web capabilities on tvOS in a way that is both practical and maintainable.

Current progress

To get Blink running on tvOS, we made a number of changes across both the build system and the runtime. On the build side, we introduced tvOS-specific configurations, including a new toolchain, the IS_IOS_TVOS build flag in C++ and Objective-C code, and a target_platform = "tvos" setting in GN.

On the runtime side, the lack of multi-process support required us to enable a single-process mode. We also removed or disabled code paths that depend on BrowserEngineKit and ensured that unsupported low-level system APIs are not used in the tvOS build.

Several modifications were also necessary in core components. For example, JIT was disabled in V8, and build configurations were adjusted for third-party libraries such as ANGLE, V8, and Dawn to make them compatible with tvOS.

At the same time, we worked on integrating platform-specific features. This includes support for hardware-accelerated graphics, media codecs, and Crashpad, as well as improvements to input handling to better support remote-based interaction.

As a result of these efforts, content_shell is now able to run in our internal repository, and work toward upstreaming is currently in progress.

Demo

To demonstrate the current state of the project, we prepared a simple demo showing Blink running on tvOS. In this demo, a YouTube video is played inside content_shell on the tvOS simulator, which illustrates that Blink is capable of rendering and running real-world web content in this environment.

Next steps

There is still a significant amount of work ahead. In the short term, our focus is on making content_shell build and run in upstream Chromium, setting up a reference bot for tvOS builds, and passing relevant unit tests, browser tests, and web platform tests.

In other words, we are currently transitioning from a prototype that “works” to something that is stable, maintainable, and ready for upstream integration.

Closing thoughts

One of the most interesting aspects of this project is how different tvOS is, despite being closely related to iOS. Even relatively small platform restrictions can have large architectural implications when working with a complex system like Blink.

While tvOS is a constrained environment, that is precisely what makes it an interesting engineering challenge. We will continue exploring how far we can take this effort and whether Blink on tvOS can eventually become part of upstream Chromium.

Acknowledgements

This work would not have been possible without the support of many contributors, including Blink and Chromium reviewers, the Google YouTube team, and many collaborators in the community.

Thank you all!

Igalia Contributors

  • Abhijeet Kandalkar
  • Gyuyoung Kim
  • Jeongeun Kim (Julie)
  • Raphael Kubo da Costa

by gyuyoung at May 08, 2026 03:46 PM

May 04, 2026

Alex Bradbury

Bootable QEMU image menagerie with rootless debootstrap

Quite some time ago I shared a script and methodology for performing a cross-architecture debootstrap in a rootless way. I had a short note on producing an image bootable in QEMU, but it was fairly minimal. This page provides a cookbook / quick reference on producing such images across various Debian target architectures supported by QEMU. The goal is that the starting point here "gets the basics right" for local experimentation, but of course you are encouraged to evolve the recipe for your needs.

The basic process is to:

  1. Build a root filesystem with rootless-debootstrap-wrapper.
  2. Configure just enough networking, DNS, serial login, and SSH.
  3. Create a 30 GiB ext4 filesystem image directly with mkfs.ext4.
  4. Boot it with qemu-system-*, passing the Debian kernel and initrd directly.

We use Debian trixie for amd64, arm64, armhf, ppc64el, riscv64, and s390x. We use sid for ppc64 big endian and loong64. I ran all of this on a current Arch Linux install.

Common setup

sudo pacman -S debootstrap fakeroot qemu-user-static qemu-user-static-binfmt \
  qemu-emulators-full e2fsprogs socat debian-archive-keyring debian-ports-archive-keyring

Put rootless-debootstrap-wrapper somewhere in your PATH, then create a working directory:

mkdir -p qemu-debian-images
cd qemu-debian-images
mkdir -p "$HOME/debcache"

Paste the following into your terminal, which will be called to do the common guest-side configuration. The main thing that's slightly non-standard in this setup are the systemd drop-in overrides which allow authorised SSH keys to be specified by teh systemd credential mechanism. If that's not something you're interested in doing, you can skip the parts touch /etc/systemd/system/ssh* altogether.

configure_qemu_rootfs() {
  rootfs=$1
  console=$2
  suite=$3
  hostname=$4

  "$rootfs/_enter" sh <<EOF
mkdir -p /etc/systemd/network /etc/ssh/sshd_config.d

cat > /etc/systemd/network/10-qemu.network <<'INNER'
[Match]
Type=ether

[Network]
DHCP=yes
INNER

cat > /etc/ssh/sshd_config.d/20-qemu-login.conf <<'INNER'
PermitRootLogin yes
PasswordAuthentication yes
INNER
rm -f /etc/ssh/ssh_host_*_key /etc/ssh/ssh_host_*_key.pub

cat > /etc/systemd/system/ssh.service.d/10-ephemeral-authorized-keys.conf <<'INNER'
[Service]
ImportCredential=ssh.ephemeral-authorized_keys-all
ExecStart=
ExecStart=/usr/sbin/sshd -D \$SSHD_OPTS -o "AuthorizedKeysFile .ssh/authorized_keys" -o "AuthorizedKeysCommand /usr/bin/cat \${CREDENTIALS_DIRECTORY}/ssh.ephemeral-authorized_keys-all" -o "AuthorizedKeysCommandUser root"
INNER

cat > /etc/systemd/system/sshd-vsock@.service.d/10-ephemeral-authorized-keys.conf <<'INNER'
[Service]
ImportCredential=ssh.ephemeral-authorized_keys-all
ExecStart=
ExecStart=-/usr/sbin/sshd -i \$SSHD_OPTS -o "AuthorizedKeysFile .ssh/authorized_keys" -o "AuthorizedKeysCommand /usr/bin/cat \${CREDENTIALS_DIRECTORY}/ssh.ephemeral-authorized_keys-all" -o "AuthorizedKeysCommandUser root"
INNER

/usr/bin/systemd-firstboot --locale=C.UTF-8 --hostname=${hostname} --force
ln -sf ../locale.conf /etc/default/locale
printf '127.0.1.1 %s\n' "$hostname" >> /etc/hosts
printf 'uninitialized\n' > /etc/machine-id
mkdir -p /var/lib/dbus
rm -f /var/lib/dbus/machine-id
ln -sf /etc/machine-id /var/lib/dbus/machine-id

systemctl enable systemd-networkd systemd-resolved systemd-timesyncd ssh
systemctl enable serial-getty@${console}.service

ln -sf ../run/systemd/resolve/resolv.conf /etc/resolv.conf
printf 'root:root\n' | chpasswd
adduser --gecos ",,," --disabled-password user
usermod -aG sudo user
printf 'user:user\n' | chpasswd
EOF

  if [ "$suite" = trixie ]; then
    cat >> "$rootfs/etc/apt/sources.list" <<'EOF'
deb https://security.debian.org/debian-security trixie-security main
deb https://deb.debian.org/debian trixie-updates main
EOF
  fi
}

This should not be exposed on any public network without further configuration. You can ssh in to either the root user or user via ssh, using password root or user respectively. The commands below expose ssh via a unix domain socket. One potential gotcha: this unix domain socket must not have any - in its name as that collides with the splitting done for the hostfwd argument. The examples given below avoid this issue. The boot commands pass net.ifnames=0, so the single QEMU network device is consistently named eth0 and matched by the networkd config above (I found this more reliable than ln -sf /dev/null /etc/udev/rules.d/80-net-setup-link.rules).

For simplicity we make use of mkfs.ext4's ability to populate the image from a directory. Pleasingly, mkfs.xfs gained a similar ability in the xfsprogs 6.17.0 release in Oct 2025. If you have a new enough version, and you prefer an XFS rootfs over ext4 you can tweak the recipes below to do the following for the final image population step:

fakeroot -i "$ROOTFS/.fakeroot.env" \
  mkfs.xfs -f -q -L rootfs \
    -d file,name="$WORK/rootfs.img",size=30g \
    -p "$ROOTFS",atime=0

amd64 / x86-64

Build:

WORK=$PWD/amd64-trixie-qemu
ROOTFS=$WORK/rootfs
mkdir -p "$WORK"

rootless-debootstrap-wrapper \
  --arch=amd64 \
  --suite=trixie \
  --mirror=https://deb.debian.org/debian \
  --cache-dir="$HOME/debcache" \
  --target-dir="$ROOTFS" \
  --include=linux-image-amd64,zstd,dbus,systemd-resolved,systemd-timesyncd,openssh-server,sudo

configure_qemu_rootfs "$ROOTFS" ttyS0 trixie qemu-amd64-trixie
cp "$ROOTFS"/boot/vmlinuz-* "$WORK/kernel"
cp "$ROOTFS"/boot/initrd.img-* "$WORK/initrd"
fakeroot -i "$ROOTFS/.fakeroot.env" \
  mkfs.ext4 -q -L rootfs -d "$ROOTFS" "$WORK/rootfs.img" 30G

Boot:

cd amd64-trixie-qemu
qemu-system-x86_64 \
  -accel kvm \
  -machine q35 \
  -cpu host \
  -smp 2 \
  -m 8G \
  -drive file=rootfs.img,if=none,id=hd,format=raw \
  -device virtio-blk-pci,drive=hd \
  -netdev user,id=net,hostfwd=unix:/tmp/qemu_amd64.sock-:22 \
  -device virtio-net-pci,netdev=net \
  -object rng-random,filename=/dev/urandom,id=rng \
  -device virtio-rng-pci,rng=rng \
  -kernel kernel \
  -initrd initrd \
  -nographic \
  -append "rw root=LABEL=rootfs console=ttyS0 net.ifnames=0"

The above assumes you are running on a x86-64 host, hence enables KVM. If not, then drop -accel kvm and use -cpu max instead of -cpu host.

arm64 / AArch64

Build:

WORK=$PWD/arm64-trixie-qemu
ROOTFS=$WORK/rootfs
mkdir -p "$WORK"

rootless-debootstrap-wrapper \
  --arch=arm64 \
  --suite=trixie \
  --mirror=https://deb.debian.org/debian \
  --cache-dir="$HOME/debcache" \
  --target-dir="$ROOTFS" \
  --include=linux-image-arm64,zstd,dbus,systemd-resolved,systemd-timesyncd,openssh-server,sudo

configure_qemu_rootfs "$ROOTFS" ttyAMA0 trixie qemu-arm64-trixie
cp "$ROOTFS"/boot/vmlinuz-* "$WORK/kernel"
cp "$ROOTFS"/boot/initrd.img-* "$WORK/initrd"
fakeroot -i "$ROOTFS/.fakeroot.env" \
  mkfs.ext4 -q -L rootfs -d "$ROOTFS" "$WORK/rootfs.img" 30G

Boot:

cd arm64-trixie-qemu
qemu-system-aarch64 \
  -machine virt \
  -cpu cortex-a57 \
  -smp 2 \
  -m 8G \
  -drive file=rootfs.img,if=none,id=hd,format=raw \
  -device virtio-blk-device,drive=hd \
  -netdev user,id=net,hostfwd=unix:/tmp/qemu_arm64.sock-:22 \
  -device virtio-net-device,netdev=net \
  -object rng-random,filename=/dev/urandom,id=rng \
  -device virtio-rng-device,rng=rng \
  -kernel kernel \
  -initrd initrd \
  -nographic \
  -append "rw root=LABEL=rootfs console=ttyAMA0 net.ifnames=0"

armhf / 32-bit ARM

For this one I had to add the relevant virtio modules to the initrd.

Build:

WORK=$PWD/armhf-trixie-qemu
ROOTFS=$WORK/rootfs
mkdir -p "$WORK"

rootless-debootstrap-wrapper \
  --arch=armhf \
  --suite=trixie \
  --mirror=https://deb.debian.org/debian \
  --cache-dir="$HOME/debcache" \
  --target-dir="$ROOTFS" \
  --include=linux-image-armmp,zstd,dbus,systemd-resolved,systemd-timesyncd,openssh-server,sudo

configure_qemu_rootfs "$ROOTFS" ttyAMA0 trixie qemu-armhf-trixie
printf '%s\n' virtio_mmio virtio_blk virtio_net >> "$ROOTFS/etc/initramfs-tools/modules"
"$ROOTFS/_enter" update-initramfs -u -k all
cp "$ROOTFS"/boot/vmlinuz-* "$WORK/kernel"
cp "$ROOTFS"/boot/initrd.img-* "$WORK/initrd"
fakeroot -i "$ROOTFS/.fakeroot.env" \
  mkfs.ext4 -q -L rootfs -d "$ROOTFS" "$WORK/rootfs.img" 30G

Boot:

cd armhf-trixie-qemu
qemu-system-arm \
  -machine virt \
  -cpu cortex-a15 \
  -smp 2 \
  -m 4G \
  -drive file=rootfs.img,if=none,id=hd,format=raw \
  -device virtio-blk-device,drive=hd \
  -netdev user,id=net,hostfwd=unix:/tmp/qemu_armhf.sock-:22 \
  -device virtio-net-device,netdev=net \
  -object rng-random,filename=/dev/urandom,id=rng \
  -device virtio-rng-device,rng=rng \
  -kernel kernel \
  -initrd initrd \
  -nographic \
  -append "rw root=LABEL=rootfs console=ttyAMA0 net.ifnames=0"

riscv64

Build:

WORK=$PWD/riscv64-trixie-qemu
ROOTFS=$WORK/rootfs
mkdir -p "$WORK"

rootless-debootstrap-wrapper \
  --arch=riscv64 \
  --suite=trixie \
  --mirror=https://deb.debian.org/debian \
  --cache-dir="$HOME/debcache" \
  --target-dir="$ROOTFS" \
  --include=linux-image-riscv64,zstd,dbus,systemd-resolved,systemd-timesyncd,openssh-server,sudo

configure_qemu_rootfs "$ROOTFS" ttyS0 trixie qemu-riscv64-trixie
cp "$ROOTFS"/boot/vmlinux-* "$WORK/kernel"
cp "$ROOTFS"/boot/initrd.img-* "$WORK/initrd"
fakeroot -i "$ROOTFS/.fakeroot.env" \
  mkfs.ext4 -q -L rootfs -d "$ROOTFS" "$WORK/rootfs.img" 30G

Boot:

cd riscv64-trixie-qemu
qemu-system-riscv64 \
  -machine virt \
  -cpu rv64 \
  -smp 2 \
  -m 8G \
  -drive file=rootfs.img,if=none,id=hd,format=raw \
  -device virtio-blk-device,drive=hd \
  -netdev user,id=net,hostfwd=unix:/tmp/qemu_riscv64.sock-:22 \
  -device virtio-net-device,netdev=net \
  -object rng-random,filename=/dev/urandom,id=rng \
  -device virtio-rng-device,rng=rng \
  -bios /usr/share/qemu/opensbi-riscv64-generic-fw_dynamic.bin \
  -kernel kernel \
  -initrd initrd \
  -nographic \
  -append "rw root=LABEL=rootfs console=ttyS0 net.ifnames=0"

The above assumes you have opensbi installed in /usr/share/qemu (it is put here by the qemu-system-riscv-firmware package on Arch).

ppc64el

Build:

WORK=$PWD/ppc64el-trixie-qemu
ROOTFS=$WORK/rootfs
mkdir -p "$WORK"

rootless-debootstrap-wrapper \
  --arch=ppc64el \
  --suite=trixie \
  --mirror=https://deb.debian.org/debian \
  --cache-dir="$HOME/debcache" \
  --target-dir="$ROOTFS" \
  --include=linux-image-powerpc64le,zstd,dbus,systemd-resolved,systemd-timesyncd,openssh-server,sudo

configure_qemu_rootfs "$ROOTFS" hvc0 trixie qemu-ppc64el-trixie
cp "$ROOTFS"/boot/vmlinux-* "$WORK/kernel"
cp "$ROOTFS"/boot/initrd.img-* "$WORK/initrd"
fakeroot -i "$ROOTFS/.fakeroot.env" \
  mkfs.ext4 -q -L rootfs -d "$ROOTFS" "$WORK/rootfs.img" 30G

Boot:

cd ppc64el-trixie-qemu
qemu-system-ppc64 \
  -machine pseries \
  -cpu power9 \
  -smp 2 \
  -m 8G \
  -drive file=rootfs.img,if=none,id=hd,format=raw \
  -device virtio-blk-pci,drive=hd \
  -netdev user,id=net,hostfwd=unix:/tmp/qemu_ppc64el.sock-:22 \
  -device virtio-net-pci,netdev=net \
  -object rng-random,filename=/dev/urandom,id=rng \
  -device virtio-rng-pci,rng=rng \
  -kernel kernel \
  -initrd initrd \
  -nographic \
  -append "rw root=LABEL=rootfs console=hvc0 net.ifnames=0"

s390x (SystemZ)

Build:

WORK=$PWD/s390x-trixie-qemu
ROOTFS=$WORK/rootfs
mkdir -p "$WORK"

rootless-debootstrap-wrapper \
  --arch=s390x \
  --suite=trixie \
  --mirror=https://deb.debian.org/debian \
  --cache-dir="$HOME/debcache" \
  --target-dir="$ROOTFS" \
  --include=linux-image-s390x,zstd,dbus,systemd-resolved,systemd-timesyncd,openssh-server,sudo

configure_qemu_rootfs "$ROOTFS" ttysclp0 trixie qemu-s390x-trixie
cp "$ROOTFS"/boot/vmlinuz-* "$WORK/kernel"
cp "$ROOTFS"/boot/initrd.img-* "$WORK/initrd"
fakeroot -i "$ROOTFS/.fakeroot.env" \
  mkfs.ext4 -q -L rootfs -d "$ROOTFS" "$WORK/rootfs.img" 30G

Boot:

cd s390x-trixie-qemu
qemu-system-s390x \
  -machine s390-ccw-virtio \
  -smp 2 \
  -m 8G \
  -drive file=rootfs.img,if=none,id=hd,format=raw \
  -device virtio-blk-ccw,drive=hd \
  -netdev user,id=net,hostfwd=unix:/tmp/qemu_s390x.sock-:22 \
  -device virtio-net-ccw,netdev=net \
  -object rng-random,filename=/dev/urandom,id=rng \
  -device virtio-rng-ccw,rng=rng \
  -kernel kernel \
  -initrd initrd \
  -nographic \
  -append "rw root=LABEL=rootfs console=ttysclp0 net.ifnames=0"

ppc64 big-endian

This is a Debian ports target, so we use sid and the ports mirror.

Build:

WORK=$PWD/ppc64-sid-qemu
ROOTFS=$WORK/rootfs
mkdir -p "$WORK"

rootless-debootstrap-wrapper \
  --arch=ppc64 \
  --suite=sid \
  --mirror=https://deb.debian.org/debian-ports \
  --cache-dir="$HOME/debcache" \
  --target-dir="$ROOTFS" \
  --keyring=/usr/share/keyrings/debian-ports-archive-keyring.gpg \
  --include=linux-image-powerpc64,zstd,dbus,systemd-resolved,systemd-timesyncd,openssh-server,sudo

configure_qemu_rootfs "$ROOTFS" hvc0 sid qemu-ppc64-sid
cp "$ROOTFS"/boot/vmlinux-* "$WORK/kernel"
cp "$ROOTFS"/boot/initrd.img-* "$WORK/initrd"
fakeroot -i "$ROOTFS/.fakeroot.env" \
  mkfs.ext4 -q -L rootfs -d "$ROOTFS" "$WORK/rootfs.img" 30G

Boot:

cd ppc64-sid-qemu
qemu-system-ppc64 \
  -machine pseries \
  -cpu power9 \
  -smp 2 \
  -m 8G \
  -drive file=rootfs.img,if=none,id=hd,format=raw \
  -device virtio-blk-pci,drive=hd \
  -netdev user,id=net,hostfwd=unix:/tmp/qemu_ppc64.sock-:22 \
  -device virtio-net-pci,netdev=net \
  -object rng-random,filename=/dev/urandom,id=rng \
  -device virtio-rng-pci,rng=rng \
  -kernel kernel \
  -initrd initrd \
  -nographic \
  -append "rw root=LABEL=rootfs console=hvc0 net.ifnames=0"

loong64 / LoongArch

For this one, we need EDK2 which you can obtain from Debian's qemu-efi-loongarch64 package (QEMU_EFI.fd).

Build:

WORK=$PWD/loong64-sid-qemu
ROOTFS=$WORK/rootfs
mkdir -p "$WORK"

rootless-debootstrap-wrapper \
  --arch=loong64 \
  --suite=sid \
  --mirror=https://deb.debian.org/debian \
  --cache-dir="$HOME/debcache" \
  --target-dir="$ROOTFS" \
  --include=linux-image-loong64,zstd,dbus,systemd-resolved,systemd-timesyncd,openssh-server,sudo

configure_qemu_rootfs "$ROOTFS" ttyS0 sid qemu-loong64-sid
cp "$ROOTFS"/boot/vmlinuz-* "$WORK/kernel"
cp "$ROOTFS"/boot/initrd.img-* "$WORK/initrd"
fakeroot -i "$ROOTFS/.fakeroot.env" \
  mkfs.ext4 -q -L rootfs -d "$ROOTFS" "$WORK/rootfs.img" 30G

Boot:

cd loong64-sid-qemu
cp ../QEMU_EFI.fd .
qemu-system-loongarch64 \
  -machine virt,firmware=QEMU_EFI.fd \
  -smp 2 \
  -m 8G \
  -drive file=rootfs.img,if=none,id=hd,format=raw \
  -device virtio-blk-pci,drive=hd \
  -netdev user,id=net,hostfwd=unix:/tmp/qemu_loong64.sock-:22 \
  -device virtio-net-pci,netdev=net \
  -object rng-random,filename=/dev/urandom,id=rng \
  -device virtio-rng-pci,rng=rng \
  -kernel kernel \
  -initrd initrd \
  -nographic \
  -append "rw root=LABEL=rootfs console=ttyS0 net.ifnames=0"

Logging in

As noted above, you can log in with root/root or user/user. The launch commands above run QEMU with -nographic causing your terminal to be connected to the guest serial console. Ctrl-c alone won't kill the virtual machine, so it's helpful to know:

  • Ctrl-a x exits QEMU immediately.
  • Ctrl-a c switches between the guest serial console and the QEMU monitor. From the monitor, quit exits QEMU and system_powerdown asks the guest to shut down cleanly.
  • Ctrl-a h prints QEMU's help for the other Ctrl-a shortcuts.

Once the guest is booted, you can connect via ssh to the Unix domain socket that forwards to guest port 22. Assuming you're on a recent system with systemd-ssh-proxy (and the ssh config file it adds) present, this can be done with e.g.:

ssh root@unix/tmp/qemu_amd64.sock

Without systemd-ssh-proxy, you can specify ProxyCommand instead:

# For socat:
ssh -o "ProxyCommand=socat - UNIX-CONNECT:/tmp/qemu_amd64.sock" root@vm
# Or for OpenBSD netcat:
ssh -o "ProxyCommand=nc -U /tmp/qemu_amd64.sock" root@vm

If you'd rather use a TCP port, replace the -netdev part of the qemu launch command with something like the following and connect to localhost:2222:

-netdev user,id=net,hostfwd=tcp:127.0.0.1:2222-:22

The systemd-provided config for use of systemd-ssh-proxy disables host identity checks, which is what you typically want with this setup. If using one of the ProxyCommand options above you may want to add -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null to your `ssh invocation.

Alternative: SSH over vsock

It's possible to avoid QEMU user-mode networking and use ssh via AF_VSOCK. This can even work without any additional image changes as systemd-ssh-generator in the guest will generate an appropriate socket-activated sshd service if vsock is present. On the host, you'll need to pick a numeric address for the vsock ('guest CID') that isn't already in use on the system, and change the qemu command line to add the appropriate vsock device with that CID assigned. The vsock device used depends on the machine being emulated - e.g. whether to attach on PCI or the virtio device bus.

For amd64, ppc64el, ppc64, and loong64, add:

-device vhost-vsock-pci,guest-cid=42

For arm64, armhf, and riscv64, add:

-device vhost-vsock-device,guest-cid=42

For s390x, use:

-device vhost-vsock-ccw,guest-cid=42

Assuming your host has systemd-ssh-proxy and its OpenSSH config installed, you can connect with:

ssh root@vsock/42

Using an injected SSH key

Images set up using the recipes above allow a public key to be specified at boot time using the systemd system credential mechanism. Just append the following to the qemu launch command and you can ssh in using that key:

-smbios "type=11,value=io.systemd.credential.binary:ssh.ephemeral-authorized_keys-all=$(base64 -w0 ~/.ssh/id_ed25519.pub)"

Article changelog
  • 2026-05-11:
    • Add notes on ssh over AF_VSOCK.
    • Add note about ssh host key checking.
    • Add support for injecting ssh keys using systemd's credential mechanism.
  • 2026-05-10:
    • Add note about serial console shortcuts.
    • Use systemd-ssh-proxy.
  • 2026-05-09:
    • Tweak shell commands slightly (no cp -f) and use Type=ether for systemd-networkd match.
  • 2026-05-07:
    • Add note about how to use an XFS rootfs.
    • Get rid of vestigial errexit usage in common setup script.
  • 2026-05-05: Use net.ifnames=0 command line argument rather than ln -sf /dev/null /etc/udev/rules.d/80-net-setup-link.rules.
  • 2026-05-04: Initial publication date.

May 04, 2026 12:00 PM

Brian Kardell

Browsers and Language Features

Browsers and Language Features

Web browsers do an astounding amount of stuff with language - and we're always trying to do more. Like most things that we get "for free" and don't give a lot of attention to, there is a lot more to it than you might realize.

Recently, I've been bouncing around looking at several browser language features. Today, our web browsers can listen to us and transcribe our words, they can speak to us, they can do spell checking, and grammar checking. At a surface level there is just a seeming "need to understand words", right? But what exactly that means is actually really variant!

Speech to Text... but more.

Today, your browser can talk to you via the Web Speech API. That's not new. Recently there was a W3C workshop on Voice Interaction. All of the presentations are available on the W3C's playlist. For some reason the sound on some of them seems pretty bad, and unfortunately many very good discusions aren't recorded. One presentation Solving Lead vs. Lead: Consistent Pronunciation for Web Content was very interesting to me. In my 2017 post Greetings, Professor Falken I talked about how the underlying speech system was able to gather a lot from context. For example, even back then, all of the browsers and OSes I could try got these "right" in that they were not naively read but rather read as the correct "forms".

1. Pi is about 3.14
2. We loaded the 4x4
3. Please meet me at 3.14pm EST
4. My birthday is 2/17/1974

Going on 10 years later, today's speech systems would get most "lead" (the heavy element) vs "lead" (being out in front) examples correct because of context. But, in the talk and later discussion, there are plenty of examples where you shouldn't really rely on that. For example, if we're trying to teach someone how something is said. That isn't really just about academics, instances abound.

In that same 2017 post I also mentioned that what the speech subsystems didn't get right was "Greetings, Professor Falken". They didn't pronounce it like the movie. I overcame that in the post by feeding it a misspelling, but this was sort of non-deterministic too, and solved by trial and error. Sarah (the presenter of the W3C talk) lays into a lot of examples like this - we discussed way more examples than I was initially considering where either there was no great context, or where no amount of context is actually likely to help. A lot of these cases are proper nouns or regional pronunciations. Montpelier, VT and Montpelier in the south of France are pronounced very differently. Barre, VT and Martin Barre the lead guitarist for Jethro Tull are pronounced very differently. These are somewhat famous examples. Ana can be pronounced several ways - which one is this? How do you pronounce the names of fictional characters? Or companies and products? And so on.

Sarah is from ETS, who also participated in an earlier "Spoken Presentation Task Force" which produced a proposal for Spoken HTML - and in theory Web Speech should support SSML too. So, that's the proposed solution for that kind of problem. Keep that in the back of your mind for now.

As you can imagine, this is all true in the reverse direction as well. That is, if you are transcribing speech there are sound-alike words and so on - which do I transcribe? “cache” or “cash”? Is it "Barre" or "Barry" or "Berry" Vermont? Do I write "4 by 4" or "4x4" or "Four by Four"? Again, today's models will do very well, generally - but you'll have problems with most of those same things, including especially all of those proper nouns.

At the end of the day all of the listening part is statistics based. The listening machine is this many % confident that it heard X, a little less confident that it heard Y and so on... Then it's sort of a lot like an LLM. So, given context it can do better. Contextual biasing is a simple, common way to improve the result: Just tell it a list of words you might be more likely to use and it'll bias toward them (optionally, provide a weight as to how much more likely it is to be this word, vs something that sounds similar). So, in our example above, if your page is a discussion forum about Vermont politics, it's probably going to hear what is pronounced like "berry" but we want it to write "Barre" and not "Barry" or "bury" or "berry" or anything else, even if it's just a single word without a larger context. Like a host asks "where was that?" and someone replies "Barre".

That functionality was recently added to the Web Speech API in Chrome.

More Different Than You Might Think...

Listening, and speaking both feel so related, but the approach to the two is actually very different. In one it's just a word and optionally a number, and in the other is more complex - you need specialized knowledge about SSML and IPA (International Phonetic Alphabet), some mapping and... in practice more. That's because while SSML seems very deterministic compared to an arbitrary number and statistics, in practice, results vary. In theory, providing the IPA to pronounce "tomato" both of the popular ways- /təˈmɑːtəʊ/ and /təˈmeɪtoʊ/ (think of the old song Let's Call the Whole Thing Off) should make them be pronounced as expected. However, sending this to different speech engines yields unpredictable results. There isn't a way to check the authoritativeness. If the engine doesn't support IPA, it may read contents of the SSML itself, if it exists. If you Give it a name like Saoirse Ronan - even with SSML and a very good engine that supports phonemes and IPA - it often still won't actually pronounce it properly unless it has a good Irish voice... And there aren't actually a lot of them.

Contextual biasing is clearly no help on the pronunciation side of things. But, flip it around and you might think that IPA would be a good input as to how to hear words too - but I've not really found anyone who even tries to do that.

And now for something completely different

As I hinted at the beginning, those are only two examples. We also have things like spell checking - that's the part that marks things as spelled incorrectly - or grammar checking. You can independently style these two cases with the CSS :spelling-error and :grammar-error pseudo-classes independently, as written about by my colleague Stephen Chenney who did the work at Igalia thanks to funding from Bloomberg.

Note that neither of these suggests words in any way. None of them deal with the concepts of autosuggest or autocorrect or autofill or hints or maybe soon even stuff to indicate and generate text rewrites in the future.

None of these are the same thing. Many of them also inevitably have this same general specialized domains problem. For example, if I am editing some Hitchhiker's Guide to the Galaxy wiki and it contains a quote of Vogon poetry:

Oh freddled gruntbuggly, Thy micturations are to me, (with big yawning) As plurdled gabbleblotchits, On a lurgid bee, That mordiously hath blurted out, Its earted jurtles, grumbling Into a rancid festering confectious organ squealer. [drowned out by moaning and screaming]

None of those words are actually spelling errors in this context, and highlighting them as such entirely ruins the experience - there are so many false positives, you miss the real errors.

And almost all of these problems are also somehow still differently handled.

For example, spell check dictionaries are located at potentially several levels. Sometimes you have one in a browser, sometimes the browser is just integrated with the OS level one. Sometimes a browser has 1 per profile. In Android, virtual keyboards have their own dictionaries.

But what does that even mean, "dictionaries"?

Just as is in the cases of speech to text, or text to speech, it can mean something different. A lot of things use a thing called Hunspell which packs up language "dictionaries" that work for all languages and more efficiently can encode complexities like plural rules, autosuggestion help and all sorts of things. For example, here is the LibreOffice en_GB.dic. In this file you'll find simple words like ablaze but most words have some kind of 'affixes' and you'll find similar words in runs that look something like this

spoon-feed/SG
spoon/D6GSM
spoonbill/MS
Spooner/M
spoonerism/SM
spoonful/MS
spoonier
spooniest
spooniness/M	Noun: uncountable
spoonsful
spoony/SMY

These connect to affix definitions in an parallel ".aff" file. This is full of entries like

SFX n e ations [^ckt]e 
SFX n 0 ations [^e]r 
SFX n e ations [iou]te 
SFX n y ations py 
SFX n ke cation ke 
SFX n ke cation's ke 
SFX n ke cations ke 
SFX n y ication [^p]y 
SFX n y ication's [^p]y 

In other words... It's complicated, but covers a lot.

If you ever right clicked something and selected "learn word" or "add to dictionary" or something, you're doing the equivalent of adding ablaze in the ".dic" file - just a simple string. But, unlike Hunspell or other complex formats, it's simple enough that even an end user can do it.

Bloomberg is also sponsoring our work on a proposal called the SpellCheckCustomDictionary API. We've been working on an explainer. Realistically maybe we should call it SpellCheckExclusions to be clearer that all it really does is allow a site to provide a list of words to not match as :spelling-error. We also let you do that in groups so that any active spellchecking can just re-run once.

There was a lot of debate and thought about how much of this should be shared or centralized, but starting with a simple list that can be well defined in terms of standard exclusion seems like a nice first step. It does mean that you would need to potentially add both "Gandalf" and "Gandalf's" as valid, and "hobbit" and "hobbits" and "hobbit's" and "hobbits'". But at least this is simple and understandable, works in every language and widens the pool of developers who might do it. A nice trade-off here might be to allow some sense of regexp in this list - though, for practical purposes it should probably be a limited subset (() for grouping, | for alternation, ? for optional, plus maybe an :i sigil for case insensitivity). This would potentially help make it easier for some authors to express more with less and help shrink the memory initially required, while still not getting too specialized and fairly easy to make performant enough.

It would also be nice to follow this with a purely declarative solution.

I'm pleased that this went to Stage 1 in WHATWG this week and looking forward to figuring out how we move this forward.

Anyway, it's been really interesting and fun to dig into all of this and it's always eye-opening to look behind another curtain... I'm looking forward to continuing these conversations and ultimately getting something useful into all of the browsers! Thanks again to Bloomberg Tech for the sponsorship!

May 04, 2026 04:00 AM

April 28, 2026

Igalia WebKit Team

WebKit Igalia Periodical #63

Update on what happened in WebKit in the week from April 8 to April 28.

After a short hiatus, we return with a galore of releases, more Web Platform improvements, tricky tweaks to thread scheduling, new niceties in the Web Inspector, and new build options to take advantage of compiler optimizations.

Cross-Port 🐱

Delivered a number of changes that have strengthened WPE WebKit and WebKitGTK's behaviour around real-time thread promotion and demotion:

  • Real-time soft and hard limits are now set both when sched_setscheduler or D-Bus based paths are taken (i.e. through rtkit or the corresponding XDG portal), with the soft limit set at 80% of the hard one.
  • This means WebKit now has time to gracefully handle SIGXCPU, and will do it in an async-signal-safe manner.
  • Additionally, the NetworkProcess' Cache Storage thread is now defined as QOS::UserInitiated on Linux, which no longer maps to real-time priority. Its earlier mapping to real-time was previously reported as a NetworkProcess crash in the logs (it was in practice a kernel-delivered SIGKILL, but WebKit doesn't make any distinction while logging). After limits were adjusted, this thread was successfully demoted, and now that the mapping has changed, this is no longer promoted to real-time to begin with.

Finally, logging around portal-related failures has been updated to reduce noise.

Implemented the connectedMoveCallback() for custom elements to react to moveBefore().

Implemented the scaffolding for the moveBefore() DOM function. This is the first step towards implementing the full feature and is currently behind a runtime feature flag.

The Web Inspector now highlights the layout root element by hovering over a Layout event in the “Layout & Rendering” timeline view and reveals it in the element tree by clicking a little “go to” arrow button.

Releases 📦️

WebKitGTK 2.52.2 and WPE WebKit 2.52.2 have been released, which include a number of fixes. In particular, building for some less tested configurations should now be possible, and the WPE port includes fixes for input event handling in the Qt API bindings.

The releases were quickly followed by WebKitGTK 2.52.3 and WPE WebKit 2.52.3, with further fixes including an important patch for crashes in JavaScriptCore on architectures other than x86_64, support for the scrollbar-color CSS property, and a fix for rendering certain emoji glyphs. Additionally, the WPE port also gained a new setting to disable overlay scroll bars and use always-visible ones, fixed focus handling for touch input in the built-in Wayland platform implementation, and a build fix for the Qt one.

In addition to maintenance for the stable branch, the first unstable releases for the current development cycle are also available: WebKitGTK 2.53.1 and WPE WebKit 2.53.1. These are the first published versions that remove the option to use Cairo for 2D rendering—only Skia will be supported going forward. On the additions front, there are graphics subsystem improvements, a few API additions, and initial support in the CMake build system for builds using Profile-Guided Optimization (PGO, needs Clang for now). The goal of development releases is to gather early feedback on upcoming changes, and issue reports are welcome in Bugzilla.

Infrastructure 🏗️

PGO (Profile-Guided Optimization) builds with Clang are now supported by the CMake build system.

That’s all for this week!

by Igalia WebKit Team at April 28, 2026 08:05 AM

April 27, 2026

Eric Meyer

Canvas-ing the Web

Over the years, I’ve created an experiment or two that drew stuff to a <canvas> element: a wave function collapse experiment here, a crystallizing palette there.  After a while, I found a way to wire up a button so that clicking it would save the canvas’s contents to my computer as a PNG file.  Pretty cool, I thought.  Can I do the same thing with HTML+CSS structures?

An abstract image somewhat resembling a flower, rendered in dusky purples, greens, and similar colors.
First I generated it on a canvas, then I clicked a button to save it.

Turns out, no.  I could use, and often have used, Firefox’s “Screenshot node” menu entry in the web inspector, or the :screenshot command in Firefox’s console, but not do it with an in-page button.  Because HTML nodes don’t go in <canvas>, you see, let alone styled and scripted ones.

Or they didn’t, until just recently, when Chrome shipped a flag-gated preview of the HTML-in-canvas API.  How it works is, you add a layoutsubtree attribute to a <canvas> element, and then you can put whatever HTML you want in there, with whatever CSS and JS you would normally apply to it, add a couple of magic JScantations, and what the browser would normally have painted to the page is painted to the canvas, at whatever speed the browser can manage (usually 60 frames per second or more, because web browsers are high-end first-person scrollers).

If you want to try all this out for yourself, I commend you to Amit Sheen’s “The Web Is Fun Again” over at the Frontend Masters blog, where he details how to get yourself set up for the wackiness this makes possible, and then shows some experiments.  Water ripples over your pages, lens distortions that follow the mouse pointer, chromatic aberrations!

Which, I admit, all sound really off-putting to the “I just want to use the web” folks among us.  What possible utility is there in having an input form that, say, makes ripples spread out from every character you type?  Or having dropdown menus fall to the bottom of the page, but still actually work?  Probably not a lot, unless you’re an expensive design studio working on a brag page.

But remember, this is how any new graphic advancement goes: we, by which I mean the collective web industry, start by doing really outré and eye-catching stuff that we later have cause to regret.  Remember parallax scrolling effects?  The early days of CSS animation?  Drop shadows?  There will be an initial period of excess, and then it will all settle down.

I’ve already skipped straight to the settle down part, though.

See, when I asked myself if I could render HTML+CSS on a <canvas> and then save the image to my computer, it wasn’t just me doing that “push at the limits of web features” thing I do sometimes.  I had an actual, practical use case in mind: I wanted to save social media banners and thumbnails from a browser-based tool I built for my work at Igalia, just by clicking or otherwise triggering a button.

If you’re subscribed to our YouTube channel, you’ve seen these thumbnails; ditto if you’re following us on Mastodon or Bluesky.  To produce those, I have an in-browser thing I built out of custom elements.  It’s where the super-slider pattern developed (though they have a different name in the tool).  I’m not going to link to the tool because it’s on our intranet and very few of you have a login, so here’s a screenshot of it in all its dweeb-designed semi-glory.

The banner-making tool being discussed, showing a number of panels with range slider inputs to set things like font size for various pieces of the banner.  There are also color inputs to change the coloration of both foreground and background elements, and a couple of places to drag and drop background or highlight images.
The banner maker, with a recent thumbnail already loaded in.

The text bits in the banner are all contenteditable HTML elements, and the various themes are managed with various blocks of CSS.  (And yeah, those range inputs are all “super sliders”.)  The point of all this being, I built it so that anyone at work could use it to make banners whenever they needed, without having to wait on me to do so.

What I’ve always wanted, in order to make things easy for anyone who isn’t me, is a “click this button to save the banner as an image” feature.  Anyone at Igalia could easily learn (if they didn’t already know) the web-inspector-or-console stuff I was using, of course, but it just felt so janky.  A touch embarrassing, if I’m being honest.

Well, now I have what I wanted.  In any browser that supports HTML-in-canvas, there is a button labeled “Download banner image”.  Right now, that’s recent Chrome with the proper developer flag enabled.  For all other browsers, there’s no button, and you just use the same web inspector screenshot tricks we’ve always relied on.

Making this happen wasn’t as easy as maybe that sounded, though.  I hit a couple of snags along the way, one of which was quite frustrating.  Those are what I actually brought you here to talk about.

The first snag was that I had to get the thumbnail preview into a <canvas> element without blowing the call stack.  To explain that, let me show you a rough skeleton of the tool’s markup.

<section id="youtube_talks">
	<thumb-panel class="text"> … </thumb-panel>
	<thumb-panel class="colors"> … </thumb-panel>
	<thumb-panel class="highlightImage"> … </thumb-panel>
	<thumb-panel class="backgroundImage"> … </thumb-panel>
	<thumb-panel class="icons"> … </thumb-panel>
	<thumb-panel class="scaler"> … </thumb-panel>
	<thumb-panel class="loader"> … </thumb-panel>
	<thumb-preview> … </thumb-preview>
</section>

As you can read, it’s basically all custom elements, each with their own connectedCallback() function to do whatever scripting magic needs to be done when the browser first encounters them.  To wrap that last element, the <thumb-preview>, inside a <canvas>, I needed to create a new canvas element, shift the preview element into the new canvas, and then insert the preview-bearing canvas, ending up with this structure.

<section id="youtube_talks">
	<thumb-panel class="text"> … </thumb-panel>
	<thumb-panel class="colors"> … </thumb-panel>
	<thumb-panel class="highlightImage"> … </thumb-panel>
	<thumb-panel class="backgroundImage"> … </thumb-panel>
	<thumb-panel class="icons"> … </thumb-panel>
	<thumb-panel class="scaler"> … </thumb-panel>
	<thumb-panel class="loader"> … </thumb-panel>
	<canvas layoutsubtree>
		<thumb-preview> … </thumb-preview>
	</canvas>
</section>

Thus, when the <thumb-preview> was loaded in, I had its connectedCallback() run a check to see if HTML-in-canvas is supported.  In situations where it is supported, I did what was needed to get to the above result.

At which point, since the <thumb-preview> is a custom element that was being placed into the DOM, it fired its connectedCallback(), thus starting the process again, creating a canvas and inserting the <thumb-preview> into the new canvas, which started the process again, recursing toward infinity.  Within milliseconds, the call stack was exceeded.

So… that wasn’t going to work.

I thought for a moment that I could avoid this by setting a flag variable to true and then checking for its existence in order to skip the whole canvas-creation-preview-insertion part, but I couldn’t figure out how to make that actually work.  Then I thought maybe I could sidestep the whole imbroglio using connectedMoveCallback(), but this wasn’t a move, it was a (re-)creation.

That callback was the route to fixing this problem, though.  You see, there is a way to move elements from one part of the DOM to another: Element.moveBefore().  There’s no moveAfter() or moveInto(), sadly, just “move this node to the spot right before some other node”.

Here’s how I made use of that feature:

let canvas = document.createElement('canvas');
canvas.setAttribute('layoutsubtree','');
canvas.setAttribute('width','1280');
canvas.setAttribute('height','720');

this.closest('section').appendChild(canvas);

let beacon = document.createElement('span');
canvas.appendChild(beacon);
canvas.moveBefore(this,beacon);
beacon.remove();

Yep.  I created a canvas, stuck the canvas into the closest ancestor section, created a span, stuck the span into the canvas, moved the preview element to right before the span, and then deleted the span.  (There may well be a better way to do this, one that my DuckDucking failed to turn up.  If so, please comment below!)

Oh, and here’s what gets executed when the preview is moved, instead of append-created:

connectedMoveCallback() {
	return;
}

Heckuva way to run a railroad.

At that point, I had the canvas where I wanted it and the preview where I wanted it, and the call stack remained un-blown.  Huzzah!  I then recited the magic JScantations to make the canvas actually render its subtree (see the “Web is Fun Again” article I linked earlier for details on this), and hey presto, DOM was being rendered into a canvas!  Then, when I clicked the button, the canvas was rendered as a PNG and my browser downloaded that PNG!  I had what I wanted!

Almost.

Because the second snag, you see, is that canvases have an explicit size.  Are in effect required to do so, because otherwise they default to zero pixels tall and wide.  So if you want to see anything, you need to give them some dimensions.  I did that, as the code before showed, making the canvas 1280×720 (YouTube’s recommended thumbnail size) through setAttribute() methods.

The problem is, the default scale factor on the thumbnail preview is 0.75, which translates to 960×540.  Thus, when I clicked the image capture button, my browser downloaded a 1280×720 image with the thumbnail in the top left, and transparency below and to its right.

The previously-seen banner, which was rendered at 0.75 scale in an un-resized canvas, as shown in the macOS image editor Acorn.

“Just resize the canvas, ya dork!” you might say.  I certainly did (say that, I mean).  But if I set it to 960 wide and 540 tall, then when the scale was increased to 1, I got a 1280×720 DOM node cropped to its top left 960×540.  I needed to dynamically resize the canvas element to have its size match the size of the thumb-preview.

And this is where I ran headfirst into several brick walls, because orcing a canvas element to resize in all the situations you want it to, including when it’s spawned, is not nearly as easy as you’d think.  It wasn’t for me, anyway.  I bulled my way through to a solution, eventually, painfully, but I got there.

(As I write this, I’m wondering if I should have also created a <div>, appended the canvas to that, and then used CSS to change the div’s size while the canvas was set to have 100% height and width.  Or maybe have the DOM subtree pinned to 1280×720 and use CSS scale to change the canvas size visually.  Or perhaps some kind of resizeObserver shenanigans.  Or probably just pass some parameters to the HTML-in-canvas drawElementImage method.  Hmmm.)

Regardless of whether I overlooked a less frustrating way do what I wanted, this does still point to a fundamental tension in the HTML-in-canvas approach: sizing.

Canvases do not, as a rule, grow or shrink to fit their contents.  DOM elements, as a rule, very much do, unless you force them not to.  HTML-in-canvas is taking a very fluid, flexible, mostly unbounded layout paradigm and rasterizing it, or at least some of it, into a very bounded window of a given size.  Sixty times (or more) every second, the browser is taking a screenshot the size of the canvas’s content box and pasting said screenshot into that content box.  You can do fun stuff to it along the way, with filters or shaders or canvas draw calls or whatever you can code up, so that each one of those screenshots gets jazzed up in some fashion, but at base, it’s still fundamentally screenshot, paste, screenshot, paste, over and over.

For use cases like mine, this isn’t really a big problem.  I am, in the end, trying to get a screenshot of a static part of the page.  HTML-in-canvas is very good for that.  It could completely revolutionize the browser-based slideshow genre.  The Reveal.js plugin landscape alone could be a sight to behold.

But in the general cases — the kinds of things we mostly do most every day — I don’t think this is likely to catch on.  We might develop some patterns to make it easier, some interesting hacks to overcome the mismatch, but I don’t think that will significantly move the needle.  On the other hand, if canvases can be made as flexible and content-wrapping as a bog-standard <div>, then I would expect to see a lot more usage.

Although if that can be done, then we wouldn’t really need to stay chained to HTML-in-canvas.  Instead, we could define a syntax to mark standard HTML elements as more visually manipulable, via an HTML attribute or CSS property or DOM method or all three.

We’ve gotten close to that before: CSS Houdini and Microsoft’s original filter property, to pick two examples.  We could try again.  Maybe the HTML-in-canvas period is how we figure out what that simpler syntax should look like, by figuring out what it should make possible, and what it should make easy.

I’d be okay with that.  How about you?


Many thanks to my colleagues Brian Kardell and Stephen Chenney for their early review and feedback on this post.


Have something to say to all that? You can add a comment to the post, or email Eric directly.

by Eric Meyer at April 27, 2026 02:20 PM

April 21, 2026

Pablo Saavedra

Fixing WebKit Unified Source Build Failures

TL;DR: Some times, usually when you are not using the default build flags you can easily fall in the situation were you got fails to link the WebKit libs with undefined reference errors caused by missing #include directives in .cpp files. This post describes how I manage the situation to identify the missing headers in […]

by Pablo Saavedra at April 21, 2026 11:45 PM

April 18, 2026

April 17, 2026

Brian Kardell

Priority of Constituencies

Priority of Constituencies

I've been thinking a lot recently about the W3C's Priority of Constituencies...

You've probably heard it cited before, the Priority of Constituencies. And what you've probably heard is

User needs come before the needs of web page authors, which come before the needs of user agent implementors, which come before the needs of specification writers, which come before theoretical purity.

Everyone loves this principle. It's almost poetic right? I often hear it cited as if it were part of a founding W3C document from 1995, so it might be surprising to learn that it wasn’t.

Back in 2004 there was a kind of a schism in the W3C that led to the creation of WHATWG and the effort to create "HTML5". In many ways it was a bit of a left turn from what was happening in the W3C at the time. In 2007 there was a kind of admission that maybe the WHATWG was on to something, and a rechartering of HTML and, (inspired by a requirement suggested by David Baron), a group of people (Maciej Stachowiak, Anne van Kesteren, Marcos Caceres, Henri Sivonen and Ian Hickson) got together and drafted some Proposed Design Principles - March 2007... The original text is available from the wayback machine.

However, it wasn't "done". People have had further thoughts on refinement over the years. For example, David Baron has some thoughts that he blogged about in 2015 questioning what the nuances this principle originally left out.

The main statement was added to the W3C TAG's Design Principles in 2020 with minor tweaks and then later in 2020, Alice Boxhall (currently at Igalia, but at the time with Google) added some additional clarifications (incorporating David's thoughts) to be more or less what it reads today.

I've been thinking a lot about these additions because I think they're important, but somehow not talked about as much. They're less "poetic", but nevertheless actually critically pragmatic:

Like all principles, this isn’t absolute. Ease of authoring affects how content reaches users. User agents have to prioritize finite engineering resources, which affects how features reach authors. Specification writers also have finite resources, and theoretical concerns reflect underlying needs of all of these groups.

I really appreciate the nuance these bits add because it really isn't just about some statement - it needs to be grounded in realities. It really is about considering tradeoffs and looking for how to optimize the application of this principle. At some level, for example, a decent feature that vendors agree they can deliver is of considerably more practical value to end users than a "better" one that we cannot.

The edits also add the most simple version, a one-liner:

If a trade-off needs to be made, always put user needs above all.

The W3C Vision document also expresses something similar

User-first: We prioritize the needs of users over other constituencies, including over those of W3C Members.

But, again, it must be acknowledged that this is a principle and not an absolute mechanism. It bumps up against reality in several ways without additional nuance. To take one example: Users, and authors benefit from good MathML support. I've argued before that the ability to share native mathematical text is societally important. You could say that its weight in a simplified Priority of Consituencies should be pretty high. But it's also pretty complex, and expensive and harder to appreciate. In practice, browsers don't prioritize it. In fact, they don't even belong to the Working Group. Nearly all of the work on MathML has been the work of volunteers or outside sponsorships. The recent work on MathML-Core has been successful largely because it has taken a more pragmatic approach wrestling with these sorts of optimizations: The MathML (or insert whatever feature you like) we can get is considerably better than the one that we cannot.

Lately I’ve been thinking the web’s constituencies are broader than the familiar list suggests. I’m not arguing we should rewrite the principle, but I do think there’s value in drawing a map of the parts of the web ecosystem, asking who else is affected, who else is missing, and how our mental models shape the choices we make. I feel like, if the Priority of Constituencies has taught us anything, it’s that the way we understand the players and frame the statement can certainly have a positive influence on the way we approach it.

April 17, 2026 04:00 AM

Martín Abente Lahaye

Modern Yocto Linux Best Practices

If you’ve been working with the Yocto Project for a while, you already know it’s the de facto standard for building custom embedded Linux distributions. What you might not know is how much the tooling around it has improved.

Many teams adopted Yocto years ago and have kept roughly the same workflows ever since, copying setup scripts between projects, letting each developer figure out how to clone layers, and building releases manually on dedicated machines. These were the common patterns at the time, but by now they are just unnecessary friction.

The Yocto community and surrounding ecosystem have introduced tools and practices that significantly improve reproducibility, onboarding, and CI integration. But lack of comprehensive documentation for how to integrate these improvements has likely kept some people from adopting them. This post aims to cover the most important ones and close that gap.

The Pain Points #

Up until very recently, the Yocto documentation and tutorials encouraged developers to work with local files and gave little guidance on project structure. This nudged teams toward a set of common but costly habits: bootstrapping new projects by copying scripts from existing ones, leaving each developer to figure out layer setup on their own, and relying on specific machines for production builds. The result was projects that were fragile to onboard, hard to reproduce, and difficult to scale.

Modern Approach #

The most mature and tested solution to the problem today is kas. Kas is an open-source setup and automation tool to better manage Yocto layers. It simplifies the process of configuring a Yocto build environment into a single, declarative configuration that covers all of the issues mentioned before.

Defining your layers, repositories, revisions, and build settings in one place makes it straightforward to spin up new projects and onboard new team members: a fresh clone and a single command is all it takes to get a working build environment. Kas also provides container integration and CI/CD tooling out of the box, so the same environment that runs on a developer’s laptop runs identically in your CI pipeline. And because everything is expressed in YAML, project definitions can be versioned, diffed, and collaborated on like any other source file.

Here’s what a complete project definition looks like in practice:

# kas/derivative-image-base-raspberrypi5.yml
header:
version: 16
includes:
- repo: meta-moonforge
file: kas/include/layer/meta-moonforge-distro.yml
- repo: meta-moonforge
file: kas/include/layer/meta-moonforge-raspberrypi.yml

local_conf_header:
30_meta-moonforge-raspberrypi: |
WKS_FILE = "moonforge-image-base-raspberrypi.wks.in"

20_meta-moonforge-distro: |
OVERLAYFS_ETC_DEVICE = "/dev/mmcblk0p3"


repos:
meta-moonforge:
url: https://github.com/moonforgelinux/meta-moonforge.git
commit: 628d710b7e076be1daa2376065ea12bb8eeded3a
branch: main

distro: moonforge
machine: raspberrypi5

The above example is enough to build a working image of Moonforge Linux for the Raspberry Pi 5. If you are curious about Moonforge, check out this tutorial for how to create your own distribution.

An official alternative to kas, called bitbake-setup, has recently been released by the maintainers of the Yocto project. Although still not as feature rich as kas, being part of BitBake makes bitbake-setup worth exploring and considering. A recent comparison from Richard Weinberger clarifies the similarities and differences.

Containerized Environments #

Using containerized build environments is now common practice across Desktop Operating Systems. Containers enable members of a team to use the same environment regardless of what they are running on their development machines.

However, in the world of Yocto, many teams still follow the old practice of installing packages locally and then struggling to be able to reproduce the same conditions when building on a different machine. Nowadays, the best approach is to use a container that includes all the needed build dependencies at the desired version.

Kas integrates naturally with containers by using kas-container:

$ kas-container build kas/derivative-image-base-raspberrypi5.yml

This containerized approach fits perfectly for CI workflows as well, so the conditions are always the same regardless of the machine or the stage of development.

CI/CD Integration and Build Automation #

The manual way of installing dependencies has meant that many teams have stayed away from CI/CD when using Yocto, building releases manually on developer machines. This tends to hold teams back, as errors are discovered too late and then problems might be hard to reproduce and solve.

Running automated pipelines like GitHub Actions, GitLab CI, or even Jenkins with the same containers as those used locally by developers ensures consistency and reduces human error.

Once these pipelines are enabled, remote computing resources and processes can be shared across multiple projects within the organization, and made available via reusable GitHub actions. As can be seen in this example:

# .github/workflows/main.yml
jobs:
build:
runs-on: [self-hosted, builder]
steps:
- uses: actions/checkout@v6
- uses: moonforgelinux/build-moonforge-action@v0.1.1
with:
kas_file: kas/derivative-image-base-raspberrypi5.yml
dl_dir: /home/github-runner/kas/cache/downloads
sstate_dir: /home/github-runner/kas/cache/sstate-cache
image_id: ${{ github.ref_name }}
image_version: ${{ github.run_id }}
- uses: moonforgelinux/upload-moonforge-action@v0.2.1
with:
host_base: ${{ secrets.S3_HOST_BASE }}
access_key: ${{ secrets.S3_ACCESS_KEY }}
secret_key: ${{ secrets.S3_SECRET_KEY }}
bucket: ${{ secrets.S3_BUCKET }}
source: build/tmp/deploy/images
destination: 'builds/${{ github.run_id }}/'
exclude: '*'
include: '*.wic.bz2'
use_https: true

The above example shows how Moonforge’s GitHub actions can be reused to build and publish OS images. These actions are available to all Moonforge derivative projects. Check this tutorial for how to reuse these actions with your own distribution.

Extending #

The OpenEmbedded build system allows you to isolate different types of customizations into multiple layers. Each layers can provide a specific solution that is reusable and extensible. In general, layers can:

  • Provide recipes for new software.
  • Modify recipes from existing layers.
  • Provide new configuration files for machines and distributions.

What layers can’t do:

  • Modify existing distribution or machine configurations.
  • Manage dependencies on external repositories and layers.
  • Set sensible defaults to the local configuration.

All of these limitations can be overcome by a sensible combination of layers and kas fragments. Simply put, fragments are YAML files that can be reused by other kas files.

Kas support for include directives can help structure these fragments in reusable blocks. These fragments can also be pulled from remote repositories. With this, derivative projects can reuse existing combinations of layers and fragments to build their own distributions, reducing duplication, manual steps, increasing reproducibility and having a clear upstream and downstream separation.

The next two examples illustrate this:

# kas/include/repo/meta-raspberrypi.yml
header:
version: 16

repos:
meta-raspberrypi:
url: https://git.yoctoproject.org/meta-raspberrypi
commit: 5240b5c200e594b494a7f1a8f9d81e7c09bc8939
branch: scarthgap

The above example shows how external layers can be made available to other fragments, keeping them pinned to a specific upstream release to ensure reproducibility.

# kas/include/layer/meta-moonforge-raspberrypi.yml
header:
version: 16
includes:
- kas/include/repo/meta-lts-mixins.yml
- kas/include/repo/meta-raspberrypi.yml
- kas/include/layer/meta-moonforge-distro.yml

local_conf_header:
20_meta-moonforge-raspberrypi: |
ENABLE_UART = "1"
RPI_USE_U_BOOT = "1"
LICENSE_FLAGS_ACCEPTED += "synaptics-killswitch"


repos:
meta-moonforge:
layers:
meta-moonforge-raspberrypi:

The above example shows how having separate fragments for each layer can address the above limitations, like managing dependencies on external layers, setting sensible defaults to the local configuration and more.

Conclusion #

Yocto remains the de facto standard for embedded Linux customization, but teams have to catch up with evolving workflows. Modern tooling and processes makes Yocto more reproducible, easier to use, maintain and scale.

As a last recap, remember to:

  • Use kas for build configuration.
  • Use containers for build environments.
  • Automate builds in CI/CD.
  • Keep layers modular and provide reusable fragments.
  • Track everything in version control.

And avoid:

  • Manual setup scripts.
  • Host-dependent builds.
  • Unpinned dependencies.

April 17, 2026 12:00 AM

April 14, 2026

José Dapena

Container Timing: moving to Origin Trial

It has been a busy few months for the Container Timing API. After introducing the concept of measuring web components performance and detailing the native implementation in Blink, I have an update to share.

The API is moving to the next phase: a Chromium Origin Trial. I will also be presenting a year of work at the upcoming BlinkOn 21.

The Origin Trial #

After months of development and testing in Chromium, Container Timing is ready for real-world testing. We will run an Origin Trial from Chromium 148 to 153. You can register for the trial.

Until now, developers have had to manually enable the ContainerTiming feature flag in Chromium to test the new API. With the Origin Trial, early adopters can enable the API in production for a subset of their users by including the trial token. More information on how to use origin trial tokens.

Why is this important? We have been internally testing and evolving the API, but now we need feedback from real-world users. The Origin Trial will allow web developers to use the new API in production and experiment with it.

Please provide your feedback at the WICG Container Timing issue tracker.

  • Does the API design meet your needs?
  • How could it be changed to be more useful?
  • Any corner cases that are not working as expected?

A year in review at BlinkOn 21 #

Next week, April 20th and 21st, the Chromium community will gather for BlinkOn 21. I’ll be giving a lightning talk summarizing the updates over the last year.

I will keep a close eye on the BlinkOn Slack channels during the event, so feel free to reach out to discuss the roadmap, implementation details, or any API feedback.

Wrapping up #

The Origin Trial is a key step toward finalizing the specification. Real-world feedback from the trial itself and from BlinkOn 21 discussions will feed into the standards working group discussions, and we expect the specification to evolve from there.

If you build with Container Timing during the trial, please share what you find: what works, what does not, and what is missing. That input will shape the final API.

You can also test locally by enabling the ContainerTiming feature flag in Chromium, while you wait for your Origin Trial registration to be approved.

Thanks #

This work is part of the collaboration between Bloomberg and Igalia. Thanks!

Igalia Bloomberg

References #

April 14, 2026 12:00 AM

April 08, 2026

Stéphane Cerveau

Introducing GstPrinceOfParser 0.4.3

GstPrinceOfParser: An All-in-One Tool to Play With GStreamer on Any Platform #

Introducing gst-pop, the GStreamer Prince of Parser — a tool to make interaction with GStreamer easier, global, and remotely accessible.

What is GStreamer? #

GStreamer is an open-source multimedia framework started in 1999. It lets you build pipelines of interconnected elements to stream, encode, decode, and manipulate media. The core idea is simple: a source element produces data, passes it through one or more transform elements, and delivers it to a sink. For example, here is a pipeline that decodes an MP3 audio file:

    filesrc --> mp3dec --> audiosink

For more than 20 years, GStreamer has relied on its in-house toolbox to demonstrate the power of its pipelines. As this toolbox is used in thousands of projects and serves as a reference implementation, modifications and enhancements are deliberately kept minimal to maintain stability. gst-pop was created to go beyond these limitations.

A Unified Interface for GStreamer #

Accessible over the network, via CLI arguments, or through D-Bus, gst-pop aims to provide a multi-pipeline-capable command-line tool.

With a simple invocation of gst-pop (or its alias gst-popd), you can run a daemon that accepts multiple pipelines simultaneously, accessible through D-Bus or WebSocket via the pipeline ID. You’ll be able to control, query, and get information about each pipeline — all of that over a remote network, secured with API key authentication and origin validation to prevent unauthorized access.

As demonstrated in the blog post related to GstPipelineStudio, it will be possible to connect to a remote pipeline or launch new pipelines through the GStreamer GUI. If a GUI is not available on the platform, it will soon be possible to use a web interface to control GStreamer, offering everything GStreamer can provide and more, limited only by your imagination.

Remote Element Inspection #

gst-pop (or its alias gst-pop-inspect) is also capable of listing the elements on a local or remote host, inspecting their capabilities, and providing a remote way to interact with your GStreamer installation.

Media Discovery #

It can also provide information on a media file using GStreamer’s discovery interface using gst-pop-discovery, offering an easy and remote-capable media discovery system for your setup.

Playback #

And of course, it can serve as an alternative to the gst-play tool, with gst-pop-play, allowing you to instantiate as many playback sessions as you need, with the ability to use any sink you want.

The possibilities are vast: provide multimedia services such as transcoding, media analysis, or remote playback to your setup using the power of a remote machine, all controllable from your terminal or a GUI such as GstPipelineStudio.

Cross-Platform and Language Support #

The tool is written in Rust for memory safety and reliability and provides client libraries in both Rust and C, offering all the flexibility needed for your existing applications. It is available on Linux (deb, rpm or docker), MacOS, and Windows, see the release page.

Examples #

# Start the daemon
gst-pop

# Launch a pipeline
gst-pop launch videotestsrc ! autovideosink

# Inspect an element
gst-pop inspect videotestsrc

# Discover media info
gst-pop discover file:///path/to/video.mp4

# Play a media file
gst-pop play file:///path/to/video.mp4

# Create a pipeline with the client
gst-popctl create "videotestsrc ! autovideosink"

# List pipelines on a remote daemon
gst-popctl list

# Play the pipeline with ID 0
gst-popctl play 0

# Stop the pipeline with ID 0
gst-popctl stop 0

# Run via Docker
docker run -d -p 9000:9000 ghcr.io/dabrain34/gstpop:latest

Give it a try and let us know what ideas you might have — we have plenty coming, so stay tuned.

As usual, if you would like to learn more about gst-pop, GStreamer, or any other open multimedia framework, please contact us!

April 08, 2026 12:00 AM

April 07, 2026

Igalia WebKit Team

WebKit Igalia Periodical #62

Update on what happened in WebKit in the week from March 31 to April 7.

Support for iOS dialog light dismiss, a new API to obtain page icons, WebKit nightly builds for Epiphany Canary produced by GNOME GitLab, and more conservative checks for MPEG-4 Audio object types are all part of this week's edition of the WebKit periodical.

Cross-Port 🐱

A new API to obtain page icons (a.k.a. “favicons”) has been added to the GTK port. The new functionality reuses the recently added WebKitImage class and provides access to multiple page icons at once through the added WebKitImageList type, allowing applications to better choose an icon that suits their needs. Changes to the WebKitWebView.page-icons property are guaranteed to be done once per page load, when all icon images are available to be used. This new API has been also enabled for the WPE port, and the plan is to deprecate the old page favicon functionality going forward.

Added iOS support for dialog light dismiss, part of the experimental closedby attribute implementation.

Multimedia 🎥

GStreamer-based multimedia support for WebKit, including (but not limited to) playback, capture, WebAudio, WebCodecs, and WebRTC.

canPlayType() is now more conservative regarding MPEG-4 Audio object types. This primarily affects AAC extensions: In the past, as long as there was an AAC decoder installed, WebKit was accepting any codec string that started with mp4a. Now it only accepts codec strings that correspond to object types that have widespread support. This can prevent accidental playback of newer formats like xHE-AAC, which many decoders don't yet support — for example, as of writing, FFmpeg support for xHE-AAC is only very recent and still incomplete.

canPlayType() now also reports support for Dolby AC-4 in systems with a decoder capable of handling it.

The GStreamer WebRTC backend now rejects SDP including rtpmap attributes in the disallowed range of 64-95 payload types. Compliance with RFC 7587 was also improved.

Infrastructure 🏗️

The WebKitGTK nightly builds for Epiphany Canary are now handled entirely by the GNOME GitLab infrastructure, many thanks to them! The previous approach was not optimal, producing release builds without debug symbols. With the new builds, it is now easier to get crash stack traces including more information.

That’s all for this week!

by Igalia WebKit Team at April 07, 2026 05:07 PM

Stéphane Cerveau

Introducing GstPipelineStudio 0.5.1

GstPipelineStudio 0.5.1 #

Your GStreamer Pipelines, at a Glance #

New version of GstPipelineStudio is out!

After months of improvements and intermediate releases since October 2024, it’s time for an official announcement for 0.5.1.

GstPipelineStudio provides a visual interface to GStreamer, the marvelous Swiss Army knife of multimedia pipelines. But what is GStreamer exactly?

What is GStreamer? #

GStreamer is an open-source multimedia framework started in 1999. It lets you build pipelines of interconnected elements to stream, encode, decode, and manipulate media. The core idea is simple: a source element produces data, passes it through one or more transform elements, and delivers it to a sink. For example, here is a pipeline that decodes an MP3 audio file:

    filesrc --> mp3dec --> audiosink

GStreamer is written in C, with a growing ecosystem of plugins in Rust and bindings for languages such as Python and C++. It ships with many command-line tools to build and test pipelines, but validating ideas still requires writing C/Rust/Python code or using the command line. That’s where GstPipelineStudio comes in — providing a visual interface to help newcomers discover and adopt GStreamer, and skilled developers debug their pipelines.

The Story Behind GstPipelineStudio #

The GstPipelineStudio project started in 2021 with the idea to provide the same environment that brought me to multimedia: GraphEdit on Windows with DirectShow. Indeed, DirectShow and GStreamer share the same idea of plugins sharing data. As I started to implement a DVB decoder with DirectShow, the graphical interface made it easier to validate which filters to use. But DirectShow only works natively on Windows, unlike GStreamer which can run everywhere — Linux, macOS, Windows, iOS, Android, and even low-power devices such as a Raspberry Pi.

GstPipelineStudio aims to work on all these platforms, easing GStreamer adoption where its use was not always obvious, such as on Windows. GStreamer is based on GLib, a cross-platform toolkit that abstracts system calls and provides a common base layer. For the GUI, since Rust was offering very good bindings, GTK was the natural choice to achieve cross-platform support. There was an attempt to create a GUI using Qt, named pipeviz, which has been a great inspiration for GPS, but the Qt Rust bindings were not mature enough, unlike those for GTK.

The first official release of GPS was 0.3.4, and you can read its official blog post published in 2023. Since then, we have been devoted to providing new features to bring GPS to another level.

A first revision, GPS 0.4.0, came out before Christmas 2024 with a refreshed interface — including zoom on the graph and contextual menus on any element or pad of the pipeline. The versions of GStreamer and GTK have also been updated to get the latest plugins and features from both frameworks. A new icon has also been introduced to let GPS dive into another dimension.

What’s New in 0.5.1 #

0.5.1 is here, and it brings a game changer: the dot file reader. Previously, it was possible to open a command-line pipeline or save/open pipelines with an XML-based format, but now you can also open the generated dot files, the native format in GStreamer, to display a pipeline graphically. This is still a beta version as it can only display high-level pipelines such as those described with the command line. Nevertheless this is a great improvement and allows users to see their pipeline and manipulate it.

Here is the list of other improvements you’ll find in this release:

  • Open Dot Folder menu entry for loading dot files from the common GStreamer folder
  • Remote pipeline introspection using the GStreamer tracers
  • App ID renamed to dev.mooday.GstPipelineStudio
  • Improved look and feel of the interface
  • Auto-connect on node click (node-link-request)
  • File selector button for location property
  • Logger copy to clipboard with multi-selection support
  • Auto-arrange elements on screen
  • GStreamer 1.28.0
  • GTK 4.20
  • RPM and AppImage artifacts

Remote Pipeline Introspection #

The remote pipeline introspection is a new way to connect to the WebSocket tracer available in GStreamer, pipeline-snapshot. In addition to dot file loader, it allows you to visualize a pipeline directly in GPS from an external process running with the tracer.

As you may know, GStreamer pipelines can be very complex, so one dream was to be able to visualize them live. There is already a mini tool in GStreamer named gst-dots-viewer which creates a web server to display pipelines in a browser from the $XDG_CACHE_DIR folder, see the blog post from Thibault about it.

Now with GPS, you can directly create a WebSocket server and let the tracer connect to it and provide available dot files to be displayed.

For example, to visualize a running pipeline in GPS:

  1. In GPS: Menu → Remote Pipeline → Listen…
  2. Enter the WebSocket address (e.g., ws://localhost:8080)
  3. Run your GStreamer pipeline with the pipeline-snapshot tracer:
GST_TRACERS="pipeline-snapshot(dots-viewer-ws-url=ws://localhost:8080)" \
gst-launch-1.0 videotestsrc ! autovideosink

The pipeline graph will appear in GPS once the tracer connects.

These dot files are converted to GPS pipelines, making it possible to modify them. That’s a first step for real interaction with GStreamer pipelines — and there are more features coming in the pipeline.

Coming in 0.6.0 #

In parallel, a new tool named GstPrinceOfParser (gst-pop) has also been implemented. This tool allows remote control of all pipelines instantiated locally or over the network. It is a multi-pipeline daemon accessible through WebSocket or D-Bus, aiming to centralize all GStreamer options in one tool for launch, inspection, and discovery. GstPipelineStudio will be able to control this daemon, making gst-pop the backbone of the GStreamer GUI. A blog post will come soon, stay tuned…

A new tracer is under development: a WebSocket server that will allow you to inspect and interact with the current pipeline — modify the play state (pause, seek), fetch the logs, and of course see the current dot representation, all from the GstPipelineStudio interface.

In addition, more features are on the way: a new look and feel based on libadwaita on Linux/macOS/Windows, better localization, an auto-plug feature, seek and step-by-step playback, and bug fixes on demand.

We hope you’ll enjoy this new version of the tool and please feel free to propose new features with an RFC here or merge requests here.

Stay tuned for the next GStreamer Spring hackfest 2026 coming soon (end of May) where new features and deeper interaction with GStreamer pipelines will be discussed.

As usual, if you would like to learn more about GstPipelineStudio, GStreamer, or any other open multimedia framework, please contact us!

April 07, 2026 12:00 AM

April 02, 2026

Miyoung Shin

Extension Migration Progress Update – Part 1

Background

Following up on my previous post, I would like to share an update on the progress of the Extension migration work that has been underway over the past few months.

To briefly recap the motivation behind this effort: Igalia’s long-term goal is to enable embedders to use the Extension system without depending on the //chrome layer. In other words, we want to make it possible to support Extension functionality with minimal implementation effort using only //content + //extensions.

Currently, some parts of the Extension system still rely on the //chrome layer. Our objective is to remove those dependencies so that embedders can integrate Extension capabilities without needing to include the entire //chrome layer.

As a short-term milestone, we focused on migrating the Extension installation implementation from //chrome to //extensions. This phase of the work has now been completed, which is why I’m sharing this progress update.


Extension Installation Formats

Chromium supports several formats for installing Extensions. The most common ones are zip, unpacked and crx.

Each format serves a different purpose:

  • zip – commonly used for internal distribution or packaged deployment
  • unpacked – primarily used during development and debugging
  • crx – the standard packaged format used by the Chrome Web Store

During this migration effort, the code responsible for supporting all three installation formats has been successfully moved to the //extensions layer.

As a result, the Extension installation pipeline is now significantly less dependent on the //chrome layer, bringing us closer to enabling Extension support directly on top of //content + //extensions.

Patch and References

To support this migration, several patches were introduced to move installation-related components into the //extensions layer and decouple them from //chrome.

For readers who are interested in the implementation details, you can find the related changes and discussions here:

These links provide more insight into the design decisions, code changes, and ongoing discussions around the migration.


Demo

Below is a short demo showing the current setup in action.

This demo was recorded using app_shell on Linux, the minimal stripped-down browser container designed to run Chrome Apps and using only //content and //extensions/ layers.

To have this executable launcher, we also extended app_shell with the minimal functionality required for embedders to install the extension app.

This allows Extensions to be installed and executed without relying on the full Chrome browser implementation, making it easier to experiment with and validate the migration work.


Next Steps

The next short-term goal is to migrate the code required for installing Extensions via the Chrome Web Store into the //extensions layer as well.

At the moment, parts of the Web Store installation flow still depend on the //chrome layer. The next phase of this project will focus on removing those dependencies so that Web Store-based installation can also function within the //extensions layer.

Once this work is completed, embedders will be able to install Extension apps from Chrome WebStore with a significantly simpler architecture (//content + //extensions).

This will make the Extension platform more modular, reusable, and easier to integrate into custom Chromium-based products.

I will continue to share updates as the migration progresses.

by mshin at April 02, 2026 11:32 AM

April 01, 2026

March 31, 2026

Andy Wingo

wastrelly wabbits

Good day! Today (tonight), some notes on the last couple months of Wastrel, my ahead-of-time WebAssembly compiler.

Back in the beginning of February, I showed Wastrel running programs that use garbage collection, using an embedded copy of the Whippet collector, specialized to the types present in the Wasm program. But, the two synthetic GC-using programs I tested on were just ported microbenchmarks, and didn’t reflect the output of any real toolchain.

In this cycle I worked on compiling the output from the Hoot Scheme-to-Wasm compiler. There were some interesting challenges!

bignums

When I originally wrote the Hoot compiler, it targetted the browser, which already has a bignum implementation in the form of BigInt, which I worked on back in the day. Hoot-generated Wasm files use host bigints via externref (though wrapped in structs to allow for hashing and identity).

In Wastrel, then, I implemented the imports that implement bignum operations: addition, multiplication, and so on. I did so using mini-gmp, a stripped-down implementation of the workhorse GNU multi-precision library. At some point if bignums become important, this gives me the option to link to the full GMP instead.

Bignums were the first managed data type in Wastrel that wasn’t defined as part of the Wasm module itself, instead hiding behind externref, so I had to add a facility to allocate type codes to these “host” data types. More types will come in time: weak maps, ephemerons, and so on.

I think bignums would be a great proposal for the Wasm standard, similar to stringref ideally (sniff!), possibly in an attenuated form.

exception handling

Hoot used to emit a pre-standardization form of exception handling, and hadn’t gotten around to updating to the newer version that was standardized last July. I updated Hoot to emit the newer kind of exceptions, as it was easier to implement them in Wastrel that way.

Some of the problems Chris Fallin contended with in Wasmtime don’t apply in the Wastrel case: since the set of instances is known at compile-time, we can statically allocate type codes for exception tags. Also, I didn’t really have to do the back-end: I can just use setjmp and longjmp.

This whole paragraph was meant to be a bit of an aside in which I briefly mentioned why just using setjmp was fine. Indeed, because Wastrel never re-uses a temporary, relying entirely on GCC to “re-use” the register / stack slot on our behalf, I had thought that I didn’t need to worry about the “volatile problem”. From the C99 specification:

[...] values of objects of automatic storage duration that are local to the function containing the invocation of the corresponding setjmp macro that do not have volatile-qualified type and have been changed between the setjmp invocation and longjmp call are indeterminate.

My thought was, though I might set a value between setjmp and longjmp, that would only be the case for values whose lifetime did not reach the longjmp (i.e., whose last possible use was before the jump). Wastrel didn’t introduce any such cases, so I was good.

However, I forgot about local.set: mutations of locals (ahem, objects of automatic storage duration) in the source Wasm file could run afoul of this rule. So, because of writing this blog post, I went back and did an analysis pass on each function to determine the set of locals which are mutated inside the body of a try_table. Thank you, rubber duck readers!

bugs

Oh my goodness there were many bugs. Lacunae, if we are being generous; things not implemented quite right, which resulted in errors either when generating C or when compiling the C. The type-preserving translation strategy does seem to have borne fruit, in that I have spent very little time in GDB: once things compile, they work.

coevolution

Sometimes Hoot would use a browser facility where it was convenient, but for which in a better world we would just do our own thing. Such was the case for the number->string operation on floating-point numbers: we did something awful but expedient.

I didn’t have this facility in Wastrel, so instead we moved to do float-to-string conversions in Scheme. This turns out to have been a good test for bignums too; the algorithm we use is a bit dated and relies on bignums to do its thing. The move to Scheme also allows for printing floating-point numbers in other radices.

There are a few more Hoot patches that were inspired by Wastrel, about which more later; it has been good for both to work on the two at the same time.

tail calls

My plan for Wasm’s return_call and friends was to use the new musttail annotation for calls, which has been in clang for a while and was recently added to GCC. I was careful to limit the number of function parameters such that no call should require stack allocation, and therefore a compiler should have no reason to reject any particular tail call.

However, there were bugs. Funny ones, at first: attributes applying to a preceding label instead of the following call, or the need to insert if (1) before the tail call. More dire ones, in which tail callers inlined into their callees would cause the tail calls to fail, worked around with judicious application of noinline. Thanks to GCC’s Andrew Pinski for help debugging these and other issues; with GCC things are fine now.

I did have to change the code I emitted to return “top types only”: if you have a function returning type T, you can tail-call a function returning U if U is a subtype of T, but there is no nice way to encode this into the C type system. Instead, we return the top type of T (or U, it’s the same), e.g. anyref, and insert downcasts at call sites to recover the precise types. Not so nice, but it’s what we got.

Trying tail calls on clang, I ran into a funny restriction: clang not only requires that return types match, but requires that tail caller and tail callee have the same parameters as well. I can see why they did this (it requires no stack shuffling and thus such a tail call is always possible, even with 500 arguments), but it’s not the design point that I need. Fortunately there are discussions about moving to a different constraint.

scale

I spent way more time that I had planned to on improving the speed of Wastrel itself. My initial idea was to just emit one big C file, and that would provide the maximum possibility for GCC to just go and do its thing: it can see everything, everything is static, there are loads of always_inline helpers that should compile away to single instructions, that sort of thing. But, this doesn’t scale, in a few ways.

In the first obvious way, consider whitequark’s llvm.wasm. This is all of LLVM in one 70 megabyte Wasm file. Wastrel made a huuuuuuge C file, then GCC chugged on it forever; 80 minutes at -O1, and I wasn’t aiming for -O1.

I realized that in many ways, GCC wasn’t designed to be a compiler target. The shape of code that one might emit from a Wasm-to-C compiler like Wastrel is different from that that one would write by hand. I even ran into a segfault compiling with -Wall, because GCC accidentally recursed instead of iterated in the -Winfinite-recursion pass.

So, I dealt with this in a few ways. After many hours spent pleading and bargaining with different -O options, I bit the bullet and made Wastrel emit multiple C files. It will compute a DAG forest of all the functions in a module, where edges are direct calls, and go through that forest, greedily consuming (and possibly splitting) subtrees until we have “enough” code to split out a partition, as measured by number of Wasm instructions. They say that -flto makes this a fine approach, but one never knows when a translation unit boundary will turn out to be important. I compute needed symbol visibilities as much as I can so as to declare functions that don’t escape their compilation unit as static; who knows if this is of value. Anyway, this partitioning introduced no performance regression in my limited tests so far, and compiles are much much much faster.

scale, bis

A brief observation: Wastrel used to emit indented code, because it could, and what does it matter, anyway. However, consider Wasm’s br_table: it takes an array of n labels and an integer operand, and will branch to the nth label, or the last if the operand is out of range. To set up a label in Wasm, you make a block, of which there are a handful of kinds; the label is visible in the block, and for n labels, the br_table will be the most nested expression in the n nested blocks.

Now consider that block indentation is proportional to n. This means, the file size of an indented C file is quadratic in the number of branch targets of the br_table.

Yes, this actually bit me; there are br_table instances with tens of thousands of targets. No, wastrel does not indent any more.

scale, ter

Right now, the long pole in Wastrel is the compile-to-C phase; the C-to-native phase parallelises very well and is less of an issue. So, one might think: OK, you have partitioned the functions in this Wasm module into a number of files, why not emit the files in parallel?

I gave this a go. It did not speed up C generation. From my cursory investigations, I think this is because the bottleneck is garbage collection in Wastrel itself; Wastrel is written in Guile, and Guile still uses the Boehm-Demers-Weiser collector, which does not parallelize well for multiple mutators. It’s terrible but I ripped out parallelization and things are fine. Someone on Mastodon suggested fork; they’re not wrong, but also not Right either. I’ll just keep this as a nice test case for the Guile-on-Whippet branch I want to poke later this year.

scale, quator

Finally, I had another realization: GCC was having trouble compiling the C that Wastrel emitted, because Hoot had emitted bad WebAssembly. Not bad as in “invalid”; rather, “not good”.

There were two cases in which Hoot emitted ginormous (technical term) functions. One, for an odd debugging feature: Hoot does a CPS transform on its code, and allocates return continuations on a stack. This is a gnarly technique but it gets us delimited continuations and all that goodness even before stack switching has landed, so it’s here for now. It also gives us a reified return stack of funcref values, which lets us print Scheme-level backtraces.

Or it would, if we could associate data with a funcref. Unfortunately func is not a subtype of eq, so we can’t. Unless... we pass the funcref out to the embedder (e.g. JavaScript), and the embedder checks the funcref for equality (e.g. using ===); then we can map a funcref to an index, and use that index to map to other properties.

How to pass that funcref/index map to the host? When I initially wrote Hoot, I didn’t want to just, you know, put the funcrefs of interet into a table and let the index of a function’s slot be the value in the key-value mapping; that would be useless memory usage. Instead, we emitted functions that took an integer, and which would return a funcref. Yes, these used br_table, and yes, there could be tens of thousands of cases, depending on what you were compiling.

Then to map the integer index to, say, a function name, likewise I didn’t want a table; that would force eager allocation of all strings. Instead I emitted a function with a br_table whose branches would return string.const values.

Except, of course, stringref didn’t become a thing, and so instead we would end up lowering to allocate string constants as globals.

Except, of course, Wasm’s idea of what a “constant” is is quite restricted, so we have a pass that moves non-constant global initializers to the “start” function. This results in an enormous start function. The straightforward solution was to partition global initializations into separate functions, called by the start function.

For the funcref debugging, the solution was more intricate: firstly, we represent the funcref-to-index mapping just as a table. It’s fine. Then for the side table mapping indices to function names and sources, we emit DWARF, and attach a special attribute to each “introspectable” function. In this way, reading the DWARF sequentially, we reconstruct a mapping from index to DWARF entry, and thus to a byte range in the Wasm code section, and thus to source information in the .debug_line section. It sounds gnarly but Guile already used DWARF as its own debugging representation; switching to emit it in Hoot was not a huge deal, and as we only need to consume the DWARF that we emit, we only needed some 400 lines of JS for the web/node run-time support code.

This switch to data instead of code removed the last really long pole from the GCC part of Wastrel’s pipeline. What’s more, Wastrel can now implement the code_name and code_source imports for Hoot programs ahead of time: it can parse the DWARF at compile-time, and generate functions that look up functions by address in a sorted array to return their names and source locations. As of today, this works!

fin

There are still a few things that Hoot wants from a host that Wastrel has stubbed out: weak refs and so on. I’ll get to this soon; my goal is a proper Scheme REPL. Today’s note is a waypoint on the journey. Until next time, happy hacking!

by Andy Wingo at March 31, 2026 08:34 PM

Alex Bradbury

Minipost: Routing a Linux user's traffic through a WireGuard interface

Simple goal: take advantage of my home router's WireGuard support and have one of my external servers connect using this, and pass all traffic from a certain user through that interface.

Create WireGuard credentials

This part of the note won't be that useful to you unless you're using a Fritzbox router. But if you're me or someone suspiciously like me you may want to know to:

  • Navigate to https://192.168.178.1/#/access/wireguard
  • Click "Add WireGuard connection" and ensure "Connect a single device" is selected on the modal that appears. Then click "Next".
  • Enter a unique name for the connection (I typically use $remote_host_name-wg) and click Finish. Follow request to confirm by pressing a button on the router.
  • Click "Download settings" and a wg_config.conf will be downloaded.

Add user

VPN_USER=asbvpn
sudo useradd -m -g users -G wheel -s /bin/bash "$VPN_USER"
sudo passwd "$VPN_USER"

Configure systemd-networkd

First, extracting the relevant values from the wg_config.conf:

WG_CONF=wg_config.conf
PRIVATE_KEY="$(sed -n 's/^PrivateKey = //p' "$WG_CONF")"
PUBLIC_KEY="$(sed -n 's/^PublicKey = //p' "$WG_CONF")"
PRESHARED_KEY="$(sed -n 's/^PresharedKey = //p' "$WG_CONF")"
ENDPOINT="$(sed -n 's/^Endpoint = //p' "$WG_CONF")"

ADDRS="$(sed -n 's/^Address = //p' "$WG_CONF")"
IPV4_ADDR="$(printf '%s\n' "$ADDRS" | cut -d, -f1)"
IPV6_ADDR="$(printf '%s\n' "$ADDRS" | cut -d, -f2)"

What we want to do is:

  • Define the wireguard interface wg0 and specify the necessary keys, IP addresses etc for it to be brought up successfully.
  • Specify a routing policy so that all traffic from the given user account goes via that interface.
    • As you can see below, we specify a RouteTable called "vpn", associate that with the interface, and specify rules for that table.
    • Ideally this would "fail closed" and no traffic from the user would be routed if wg0 is down. That appears to use additional rules managed outside of systemd-networkd. I haven't tried to implement this.

The above can be achieved with:

sudo mkdir -p /etc/systemd/networkd.conf.d
sudo tee /etc/systemd/networkd.conf.d/90-vpn-table.conf >/dev/null <<'EOF'
[Network]
RouteTable=vpn:100
EOF

sudo tee /etc/systemd/network/50-wg0.netdev >/dev/null <<EOF
[NetDev]
Name=wg0
Kind=wireguard

[WireGuard]
PrivateKey=$PRIVATE_KEY
RouteTable=vpn

[WireGuardPeer]
PublicKey=$PUBLIC_KEY
PresharedKey=$PRESHARED_KEY
AllowedIPs=0.0.0.0/0
AllowedIPs=::/0
Endpoint=$ENDPOINT
EOF

sudo tee /etc/systemd/network/50-wg0.network >/dev/null <<EOF
[Match]
Name=wg0

[Network]
Address=$IPV4_ADDR
Address=$IPV6_ADDR

[RoutingPolicyRule]
User=$VPN_USER
Table=vpn
Priority=10000
Family=both
EOF

sudo chgrp systemd-network /etc/systemd/network/50-wg0.netdev
sudo chmod 0640 /etc/systemd/network/50-wg0.netdev
sudo systemctl restart systemd-networkd

Doing it this way, we've stored the secret keys in the 50-wg0.netdev file itself but restricted access to the file. It's possible to have the keys stored in a separate file, but for my setup it didn't seem worthwhile.

Then check the status with e.g.:

sudo networkctl status wg0
sudo ip rule show
sudo ip route show table 100
sudo wg show wg0
sudo -u $VPN_USER curl https://ifconfig.me/all

IPv6 does not work in this setup (curl -6 google.com will fail),

Copying authorized_keys to new user

This is more a note to myself than anything else:

sudo install -d -m 700 -o $VPN_USER -g users /home/$VPN_USER/.ssh
sudo install -m 600 -o $VPN_USER -g users "$HOME/.ssh/authorized_keys" /home/$VPN_USER/.ssh/authorized_keys

Article changelog
  • 2026-03-31: Initial publication date.

March 31, 2026 12:00 PM

March 30, 2026

Igalia WebKit Team

WebKit Igalia Periodical #61

Update on what happened in WebKit in the week from March 23 to March 30.

This week comes with a mixed bag of new features, incremental improvements, and a new release with the ever important security issue fixes. Also: more blog posts!

Cross-Port 🐱

Implemented initial support for closedby=any on dialog elements, which adds light dismiss behaviour. This is behind the ClosedbyAttributeEnabled feature flag.

Added the remaining values for the experimental closedby attribute implementation.

MiniBrowser now has a --profile-dir=DIR command line option that can be used to specify a custom directory where website data and cache can be stored, to test, for example, behavior in a clean session.

Multimedia 🎥

GStreamer-based multimedia support for WebKit, including (but not limited to) playback, capture, WebAudio, WebCodecs, and WebRTC.

Video decoding limits had been enforced on HTMLMediaElement.canPlayType() so far, but they are now also enforced in MediaCapabilities queries.

Graphics 🖼️

Fixed several OpenGL state restoration bugs in BitmapTexture . These could cause a mismatch between the GL state assumed by Skia and the actual one, leading to rendering artifacts with certain GPU drivers and configurations.

The SKIA_DEBUG CMake option has been enabled for Debug builds, enabling Skia's internal assertions, debug logging, and consistency checks (e.g. bounds checking, resource key diagnostics). It remains off by default for Release and RelWithDebInfo builds, and can still be explicitly configured via -DSKIA_DEBUG=ON|OFF.

WPE WebKit 📟

WPE Platform API 🧩

New, modern platform API that supersedes usage of libwpe and WPE backends.

The new WPE_SETTING_OVERLAY_SCROLLBARS setting is now available, and disabling it will use a more traditional, always visible scrollbar style.

Releases 📦️

A new USE_GSTREAMER build option may now be used to toggle the features that require GStreamer at once. This can be used to effectively disable all multimedia support, which previously needed toggling four CMake options.

WebKitGTK 2.52.1 and WPE WebKit 2.52.1 have been released. On top of a small corrections typical of the first point releases in a new stable series, this one includes a number of fixes for security issues, and it is a recommended update. The corresponding security advisory, WSA-2026-0002 (GTK, WPE) has been published as well.

Community & Events 🤝

Simón Pena wrote a blog post showing how to create a minimal WPE launcher, which uses a Fedora Podman container with pre-built WPE WebKit libraries and a launcher with barely 10 lines of code to display a web view. This complements Kate Lee's custom HTML context menu blog post from last week.

That’s all for this week!

by Igalia WebKit Team at March 30, 2026 09:46 PM

Ricardo Cañuelo Navarro

Why don't we do a demo? Part 2: software development

In part 1 of this series I talked about the beginning of this story and laid out the plan. In this post we'll start the actual work, beginning with the software part.

Problem 5: base peripheral device

I'll start with the most basic device: the peripheral. It will provide a simple BLE service to allow toggling the board LED remotely and displaying its current status.

Solution

The Zephyr samples are a good starting point for the firmware skeleton. The XIAO nRF54L15 is also well supported in Zephyr, so defining a custom BLE service and operating the on-board LED is not a challenge. A minimal sketch firmware with the basic functionality can be done reasonably quickly starting from scratch. To test the BLE service we can use a smartphone and nRF Connect for Mobile.

I probably don't need to go all the trouble of doing a custom BLE service and characteristic for this, but it's an exercise I'll need to do at some point, and it has the added bonus of giving us full freedom to define the functionalities we want.

For the BLE services and characteristics, I picked up a random 128-bit

UUIDUniversally Unique Identifier
generated with https://www.uuidgenerator.net/version4.

The BLE-related boilerplate code for the basic functionality uses the appropriate macros to define the GATT service and characteristics:
/* LED service UUID: 46239800-1bed5-4c51-a215-9251faaae809 */
#define LED_SERVICE_UUID_VAL \
	BT_UUID_128_ENCODE(0x46239800, 0x1bed5, 0x4c51, 0xa215, 0x9251faaae809)

static struct bt_uuid_128 led_svc_uuid =
	BT_UUID_INIT_128(LED_SERVICE_UUID_VAL);

/* Characteristic UUID: 46239801-1bed5-4c51-a215-9251faaae809 */
static struct bt_uuid_128 led_char_uuid = BT_UUID_INIT_128(
	BT_UUID_128_ENCODE(0x46239801, 0x1bed5, 0x4c51, 0xa215, 0x9251faaae809));

/* Characteristic UUID: 46239802-1bed5-4c51-a215-9251faaae809 */
static struct bt_uuid_128 led_indication_char_uuid = BT_UUID_INIT_128(
	BT_UUID_128_ENCODE(0x46239802, 0x1bed5, 0x4c51, 0xa215, 0x9251faaae809));

[...]

BT_GATT_SERVICE_DEFINE(led_svc,
	BT_GATT_PRIMARY_SERVICE(&led_svc_uuid),
	BT_GATT_CHARACTERISTIC(&led_char_uuid.uuid,
			BT_GATT_CHRC_READ | BT_GATT_CHRC_WRITE,
			BT_GATT_PERM_READ | BT_GATT_PERM_WRITE,
			read_led_state, write_led_state, &led_state),
	BT_GATT_CHARACTERISTIC(&led_indication_char_uuid.uuid,
			BT_GATT_CHRC_INDICATE,
			BT_GATT_PERM_READ | BT_GATT_PERM_WRITE,
			NULL, NULL, NULL),
	BT_GATT_CCC(led_ccc_changed,
		BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
);
Where the read_led_state, write_led_state and led_ccc_changed callbacks look something like this:
/*
 * LED state characteristic read callback.
 */
static ssize_t read_led_state(struct bt_conn *conn,
                             const struct bt_gatt_attr *attr, void *buf,
                             uint16_t len, uint16_t offset) {
	const uint8_t *val = attr->user_data;
	return bt_gatt_attr_read(conn, attr, buf, len, offset, val,
				sizeof(*val));
}

/*
 * LED state characteristic write callback.
 * A write to this characteristic will trigger a LED toggle, the data
 * sent is irrelevant so we can just ignore it.
 */
static ssize_t write_led_state(struct bt_conn *conn,
                              const struct bt_gatt_attr *attr, const void *buf,
                              uint16_t len, uint16_t offset, uint8_t flags) {
	ARG_UNUSED(conn);
	ARG_UNUSED(attr);
	ARG_UNUSED(buf);
	ARG_UNUSED(offset);
	ARG_UNUSED(flags);

	/*
	 * Ignore received data (dummy): *((uint8_t *)buf)
	 * and override (toggle) the led_state here as a side-effect.
	 */
	LOG_DBG("LED toggle received: %d -> %d", led_state, led_state ? 0 : 1);
	led_state = led_state ? 0 : 1;
	gpio_pin_set_dt(&led, led_state);
	gpio_pin_set_dt(&led_board, led_state);
	if (led_indication_enabled)
		k_work_schedule(&led_indicate_work, K_NO_WAIT);

	return len;
}

/*
 * LED indication Client Characteristic Configuration callback.
 */
static void led_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
	ARG_UNUSED(attr);

	led_indication_enabled = (value == BT_GATT_CCC_INDICATE);
	LOG_DBG("Indication %s", led_indication_enabled ? "enabled" : "disabled");
}

This should be good enough for now, we'll surely need to complicate it later.

Problem 6: unexpected LED behavior

The user LED in the XIAO nRF54L15 turns off with gpio_pin_set_dt(&led, 1) and on with gpio_pin_set_dt(&led, 0). Not a problem if we only want to toggle it instead of setting a specific value, but not ideal, since we also want to keep track of its current state and report it.

Solution

This one's easy. According to the schematic, this LED is active low, but the device tree for this SoC defines it as active high. Fixed and upstreamed.

Problem 7: modeling the behavior of the central device

In the BLE central-peripheral architecture proposed, the peripheral will work as an autonomous device that provides a service but does no other action except when requested by the user through a button press. Other than that, it'll sit there waiting for requests from the central (the controller device in our case), which will be the one governing the bulk of the application and, more importantly, managing the connection and doing the necessary actions to establish and monitor it.

Some of the tasks under the responsibility of the controller are:

  • Scanning for peripherals.
  • Connecting to peripherals.
  • Service discovery.
  • Keep track of the connected devices.
  • Handle disconnection requests and lost connections.

We need a way to model this behavior into the controller so we can integrate these tasks with the rest of the firmware gracefully.

Solution

I'll abstract the list of tasks above in a simple state machine that will run in a separate thread taking care of handling the connections, running the necessary actions as response to specific events, interacting with the rest of the firmware and reacting to the actions triggered by the user via the board buttons or by external sources.


That way, the main thread will set up the hardware and the necessary software subsystems, and the state machine will keep track of most of the BLE-related tasks and of the connected devices.

So, when the initialization is done, the main thread will start the state machine thread and then wait for events such as button presses, managing and restarting common services, while the state machine works on its own.

For our purposes we'll only need three states:

  • Event listen: the device waits for events from the user or from external sources. In the most basic scenario, it waits for a "scan" request, which will make the machine move to the "Scan" state.
  • Scan: this state handles device scanning and connection. Once connected to a suitable device, the state machine will move to the "Discover" state. If no connection is done after a period of time, the machine will go back to the "Event listen" state.
  • Discover: here, the firmware will run the discovery process for a connected peripheral, looking for a specific set of services and characteristics. If the process is successful, the controller will save the necessary data about the peripheral for later use and move to the "Event listen" state.

I can reuse most of this architecture as the basis for the console device as well, since it'll be a central device to the controllers (remember the controllers are both central and peripheral BLE devices at the same time), so I can start sketching the console firmware as well as a generic central device.

Problem 8: designing the UX for the controller device

We need a way for the controller to interact with the connected peripherals, and in the controller boards (nRF54L15 DK) we have as user-facing devices four LEDs and four buttons. The operations we'll need to perform are:

  • Scan for peripherals.
  • Disconnect from a connected peripheral.
  • Toggle the LED of a connected peripheral.
  • Check the status of the peripherals.

Solution

The most useful thing we could do with the board LEDs is to replicate the status of the peripheral LEDs. That way we could have a real-time overview of the state of the connected peripherals at all times.

The downside of this is that the board only has four LEDs, so if I want to show the status of the connected peripherals at a glance, I'm limited to four of them. And it'd be good to keep one LED to show the status of the controller itself, so lets start by limiting the amount of simultaneously connected peripherals to three.

Now, about the buttons, I'm going to need a way to perform at least three actions: scanning, disconnecting and toggling, and I'll probably need to make room for additional actions down the road.

One option is to assign one button to each peripheral "slot", so I could use button 0 to perform an action on slot 0, button 1 for slot 1, etc. In this case, I'd need to encode multiple actions on the same button: scanning and toggling at least.

A different approach is to use one or two buttons to select the active slot, and then the action buttons would operate on the selected slot. I feel like this method could be easier to adapt in case I need to add additional functionalities later, so this is what I'll do:

  • Button 0: select the next slot as the "active slot".
  • Button 1: "action button", trigger an action on the peripheral connected in the active slot. For now, the action will be to toggle the LED.
  • Button 2: select the previous slot as the "active slot".
  • Button 3: disconnect the peripheral in the active slot, if any, and start scanning on it.

I'll also need a way to tell which one is the selected slot. Since I'm using the LEDs to represent the slots, an easy way to do this is by briefly blink the LED of the currently active slot when we use buttons 0 or 2 to cycle through the slots. Additionally, I can use the same method to encode whether the slot contains a connected peripheral or not, since I'm using a static LED to show the status of the peripheral LED (i.e. we can't tell from a LED that's off if the connected peripheral has its LED off or of there's no peripheral connected at all): when cycling through the slots selecting the active one, the LED can do a short blink cycle to represent a disconnected slot and a long blink cycle to represent a connected one.


Problem 9: simulation and testing

During development, it's very inconvenient to run all the firmware changes we do on real hardware, even if these boards can be flashed very fast. And for debugging and testing, relying on the hardware is overkill most of the time, even if we have direct access to a serial console and we have plenty of tracing possibilities. I'd need a better way to test our changes.

Solution

Fortunately, Zephyr includes a native simulator that allows to build a firmware as a native binary that I can run on the development machine using emulated devices. For my purposes, the native bsim boards even let me simulate the specific SoC used in the boards, including most of the SoC hardware, and run the firmware natively in BabbleSim to simulate real BLE usage.

This offers many advantages over testing on hardware:

  • Faster development cycles.
  • Easier debugging of runtime errors.
  • Triggering of specific corner cases programmatically.

Ideally, what I'd like is to configure the environment so that I can selectively build and test the firmware on the simulator, or build a release firmware for the real hardware. A way to do this is to keep two separate project config files, create the necessary device tree overlay files for the different target boards (real and simulated) and compile certain parts of the firmware conditionally, so that I can enable test code and emulated devices only on the simulator build and I can keep hardware-dependent code only for the release build:

├── boards
│   ├── nrf52_bsim.conf
│   ├── nrf52_bsim.overlay
│   ├── nrf54l15bsim_nrf54l15_cpuapp.conf
│   └── nrf54l15bsim_nrf54l15_cpuapp.overlay
├── build.sh
├── CMakeLists.txt
├── flash.sh
├── Kconfig
├── prj.conf
├── prj_sim.conf
├── sim_bin
├── sim_build.sh
├── sim_run.sh
└── src
    ├── common.h
    ├── emul.c
    ├── emul.h
    ├── main.c
    ├── peripheral_mgmt.c
    ├── peripheral_mgmt.h
    ├── sim_test.c
    ├── sm.c
    └── sm.h

Code compiled conditionally for the simulator looks like this:

[...]
int main(void)
{
	static struct gpio_callback button_cb_data;
	int log_sources = log_src_cnt_get(0);
	int ret;
	int i;

#ifdef CONFIG_BOARD_NRF52_BSIM
	/* Set all logging to INFO level by default */
	for (i = 0; i < log_sources; i++) {
		log_filter_set(NULL, 0, i, LOG_LEVEL_INF);
	}
	int id = log_source_id_get("controller__main");
	log_filter_set(NULL, 0, id, LOG_LEVEL_DBG);
#else
	/* Disable all logging by default */
	for (i = 0; i < log_sources; i++) {
		log_filter_set(NULL, 0, i, LOG_LEVEL_NONE);
	}
#endif

From now on, I can do most of the development on the simulator, and once things are the way I want I can test them on the real hardware.

Problem 10: battery-powered peripheral setup

While the peripheral devices can be powered via USB, just the same as the bigger boards, the demo would be both more realistic and more diverse if we used batteries for them. The XIAO nRF54L15 is prepared for that and has battery pads and the necessary hardware to manage a LiPo battery. I need to provide the batteries and add the appropriate battery leads to the boards, though.

Solution

Any suitable LiPo battery will do, but I'll search for batteries with an appropriate dimensions and capacity for this application.

I found this bundle containing five batteries and a charger, which should be good enough for our purposes: we can have up to 5 battery-powered peripherals and a convenient way to recharge the batteries if they're easy to detach from the devices.


The battery connectors are Molex 51005, so I'll also need to source a bunch of male and female leads. The pads are big enough to solder the leads to them with a conventional pen solder:


Problem 11: hardware unreliability

The XIAO nRF54L15 seems very flaky. In particular, after flashing it sometimes the device crashes and Zephyr reports a bus data error in the serial console. It seems to be random, it happens only after flashing some builds and it also seems to depend on timing.

Even worse, when battery-powered, the board won't boot. When powered via USB, though, it will boot, and then I can plug in the battery, unplug the USB cable and the board will keep on running.

Solution

After some investigation and tests, it looks like the crashes are related to the logging through the UART console. Why, I don't know. The kind of crashes I'm seeing right during booting are bus faults, and the first things I'd check for are null pointer dereferences and stack overflows, but in this case I'm not even getting a valid PC in the error report. Besides, there are a few signs that this will be hard to pinpoint:

  • Altering the logging does cause different results.
  • Different builds and flashings of the same firmware sometimes crash and sometimes don't.
  • It doesn't seem related to the size of the logging stack.
  • Deferred vs immediate logging causes different results.
  • It doesn't fail on the simulator.
  • It seems related to timing.
  • There's a big randomness factor.
  • The same firmware on the same SoC but on a different board design (nRF54L15 DK) works fine.

All of these hint that there's some flakiness involved in the XIAO nRF54L15, particularly related to either power management, flashing or the use of the builtin USB for UART output.

Judging by some issues raised in the Seeed Studio forums, it looks like the USB-based SWD circuitry could be the cause of these problems. Regarding the problems booting when battery-powered, after asking about it in the forums, I got a response explaining the reason: when logging is enabled, the TX line back-feeds and powers up the USB-UART chip, causing a brownout and a shutdown/reboot.

The most reasonable fix or workaround for all of this is to simply disable all logging and UART usage when the board is battery-powered1. In order to do this, I created another build type that will be used for "production" releases. For the non-production builds (the ones I'll use for development and debugging) I'll keep logging disabled with the possibility of enabling it through shell commands. That'll reduce the chances of crashing the system at boot time.

Problem 12: network connectivity in the console device

We can take advantage of the builtin web server capabilities provided by Zephyr for the console board. Since it'll be governing the application and monitoring / controlling the connected devices, we'll need a user interface to manage it. Implementing it in the form of a web interface should be easy enough, and it'd give us a lot of freedom to design the interface. The idea would be to connect the console board to a client (a laptop, for instance) using a point-to-point Ethernet link and have the client access the web page served by the console board.

The problem is that the board doesn't have an Ethernet interface.

Solution

Everything's not lost, though. The board doesn't have an Ethernet interface but it has a general USB interface besides the one used for flashing and debugging. And, fortunately, the USB stack in Zephyr supports USB CDC NCM (Ethernet-over-USB) and we even have an example of the web server running on the same board we're using for the console device, so setting it up shouldn't be too much of an issue.

I can run the sample code on the board and check that it works, I can connect to it and see the web page published by the web server. Integrating the basic code into our sketchy console firmware is mostly painless, although I'm publishing only a placeholder web page. For now, that's good enough. I'll see what we can do with it later.

In the next post we'll continue through the rest of the software development part of the project.

1: This is now documented in the Seeed Studio wiki

by rcn at March 30, 2026 11:00 AM

March 26, 2026

Qiuyi Zhang (Joyee)

Teaching gdb to Unwind V8 JIT Frames on x64

Recently I landed a custom Python gdb unwinder in V8 that allows gdb to unwind through V8’s JIT-compiled frames on x64

March 26, 2026 04:20 PM

José Dapena

The implementation of Container Timing: aggregating paints in Blink

Measuring paint performance is a balancing act: you need precision, but the measurement itself can’t slow things down.

In my previous post, I introduced Container Timing, a new web API allowing developers to measure the rendering performance of DOM subtrees. Today, I will dive into the technical details of how I implemented this in Blink, the rendering engine used by Chromium.

The Architecture: Hooking into Paint #

In Blink, the rendering pipeline goes through several stages: Style, Layout, Paint, and Composite. The Container Timing implementation relies heavily on the Paint stage.

The main idea was not reinventing the wheel. Blink already provides paint timing detection for the implementation of Large Contentful Paint (LCP) and Element Timing. However, this is targeted for specific nodes (an image, a text block). In Container Timing we care about subtrees.

So, when a paint is detected, we need to quickly decide whether the paint is relevant to Container Timing.

Is a paint interesting for Container Timing? #

As the DOM tree is built (on parsing, or because of a script), we check the value of the attribute containertiming for each Element. When found, we flag that element and all its descendants with the flag SelfOrAncestorHasContainerTiming.

We also have the attribute containertiming-ignore. When found, we will stop the propagation.

So, later, for any paint, we will immediately know if the paint should be tracked for Container Timing or not. This minimizes the impact when the element is not tracked.

What about DOM tree updates after parsing?

This is a pain point for performance. When a DOM element starts/stops having the containertiming or containertiming-ignore attribute after the DOM tree is created, we need to traverse the tree to update the flag.

Collecting Paint Updates #

When a paint is detected, we just reuse the existing implementation in the ImagePaintTimingDetector and TextPaintTimingDetector, that are also used for LCP and Element Timing for the relevant elements.

Note

Only text and image paints are currently tracked. Video, canvas, and SVG are not yet supported.

We first determine if the paint should be recorded for Container Timing. And this is fast because of the SelfOrAncestorHasContainerTiming flag.

The timing detectors give us the area of the visual rectangle, the bounding box on screen that was painted.

For Container Timing, we added a mechanism to walk up the DOM tree from the painted node. If we encounter an ancestor that is marked with the containertiming attribute (a container timing root), we report that paint event to it.

This “bubbling up” of paint events is illustrated in the diagram below.

Within the Blink rendering pipeline, paint events from individual text and image nodes are captured by the paint timing detectors and then

Is this expensive?

It depends on the depth of the hierarchy from the node to the most remote ancestor. Further work will be needed to speed up or avoid these traversals.

Aggregating Regions #

One of the most interesting challenges was determining the size of the container. It is not just the size of the container timing root. It is the union of all painted content.

Two reasons for this:

  • Being able to incrementally determine the updated area, in a way that is inspired by Largest Contentful Paint.
  • To reduce the amount of performance events generated, we discard the paints that do not increase the area.

We maintain a PaintedRegion for each container. This is a non-overlapping union of the rectangles that cover the updated area:

  1. Initial Paint: When the first child paints, we initialize the region with its visual rectangle.
  2. Subsequent Paints: As more images load or text renders, we perform a union operation: CurrentRegion = Union(CurrentRegion, NewPaintRect).

So, as paints are detected, each container will aggregate the parts of the screen that have been painted by all their children.

We use cc::Region, based on SkRegion from the Skia graphics library to handle these unions efficiently.

The following diagram shows this process in action over three frames.

The  of a container is the union of the painted areas of its children. As new content paints, the region grows to encompass all visible parts of the container's subtree.

Buffering and Reporting #

Because a container paints over multiple frames (e.g., text renders first, then a background image, then a lazy-loaded icon), we cannot just emit one entry. We generate candidates.

For each container, when a paint that increases the painted region is detected, we schedule a new event. Right at the end of the frame presentation, we package the current state into a new performance timeline entry: a PerformanceContainerTiming object.

This object contains:

  • startTime: The presentation time of the paint. In the Chromium implementation, this is set to the moment the frame was presented to the user, and matches presentationTime from PaintTimingMixin.
  • firstRenderTime: the time of the first paint we detected in the container. Useful for getting a hint of how long a component has been showing updates to the user.
  • The container element, in two ways. The identifier is the value of the containertiming attribute. rootElement is the actual element.
  • size: The total area of the aggregated PaintedRegion.
  • lastPaintedElement: the last element that triggered a paint — handy for debugging which child caused the latest candidate.

Note

We support the PaintTimingMixin, which adds paintTime (when the paint was committed to the compositor) and presentationTime (when the frame was presented to the user). In Chromium, startTime is set to presentationTime.

This design means the observer might receive multiple entries for the same container. This is intentional: it lets developers pick the milestone that matters to them, typically the point where size stops growing.

Handling “Ignore” #

We also implemented the containertiming-ignore attribute. When a node has this attribute, it stops the SelfOrAncestorHasContainerTiming flag from propagating further down its subtree, so paints within it are not walked up to the container timing root, and never contribute to that container PaintedRegion.

Ignoring is useful for a number of things:

  • Debug overlays and instrumentation widgets, which should not inflate the measured painted area.
  • Visually independent nested components: child dialogs or overlays that paint independently from the container and would affect the size metric if included.

Tip

containertiming-ignore on large untracked subtrees also reduces traversal depth, helping with the cost mentioned above.

How to test #

With flag propagation, region aggregation, candidate buffering, and selective ignoring all in place, the implementation is complete.

Container Timing is ready for test in Chromium. Just use the Blink feature flag ContainerTiming:

chrome --enable-blink-features=ContainerTiming

What’s next? #

  • We are preparing an Origin Trial in Chromium, a new step towards enabling Container Timing by default. Stay tuned!
  • Optimizations in the traversal. We have some ideas for avoiding the traversal of the full tree when a paint is detected, to find the container timing root.
  • Support for detecting paints in other parts of the tree. Shadow DOM is specially interesting here due to its importance in web components.

Wrapping up #

Building this native implementation was a great exercise in reusing Blink’s existing performance infrastructure while extending it to support subtree-level aggregation.

The key insight: subtree-level metrics didn’t require a new paint tracking system. Only a way to aggregate and bubble up what Blink was already measuring.

The result is a native, low-overhead API for measuring the rendering performance of entire components.

Thanks! #

This has been done as part of the collaboration between Bloomberg and Igalia. Thanks!

Igalia Bloomberg

References #

March 26, 2026 12:00 AM

March 24, 2026

Javier Fernández

Protocol Handler Registration via Browser Extensions

Motivation

Custom URL schemes have traditionally served as an integration bridge between the browser and external capabilities. Schemes such as mailto: and tel: allow navigation to trigger actions beyond ordinary HTTP resource retrieval. The HTML Standard formalizes this mechanism through the Custom Scheme Handlers API, which enables websites to register themselves as handlers for specific URL schemes.

While the Web API is appropriate for origin-scoped integrations, its security model imposes several structural constraints:

  • Registration must be initiated from a visited website.
  • It requires user activation.
  • The handler URL must share the same origin as the registering site.
  • Each registration is processed individually and requires explicit user approval.

These constraints are deliberate and necessary to prevent cross-origin abuse. However, they also limit legitimate integration scenarios that are better expressed outside the web-origin layer.

In collaboration with the Open Impact Foundation’s IPFS Implementations grants program, Igalia has implemented support for declaring protocol handlers directly in the Web Extension Manifest for Chromium-based browsers – achieving interoperability with Firefox. The goal is to make protocol registration a first-class extension capability, while preserving the security invariants established by the HTML Standard.

The proposal was discussed by the Web Extensions WICG back in 2023, with the support of Firefox (already implemented) and Chrome. Safari initially supported but finally changed to opposed.

This article introduces the motivation behind the feature, explains the design decisions that shaped it, and describes its internal security and lifecycle model.

The feature has been shipped behind an experimental flag in Chrome 146. To test it, just launch Chrome from the command line with this option:

--enable-features=ExtensionProtocolHandlers

Case study: IPFS Companion

IPFS introduces schemes such as ipfs://, backed by a content-addressed data model rather than traditional origin-based addressing. In Chromium’s previous extension model, IPFS Companion must request declarativeNetRequest, webRequest, webNavigation, and <all_urls> host permissions — not because it wants to monitor all browsing activity, but because intercepting an unrecognized protocol requires inspecting every navigation and network request. The browser shows users a warning like “Read and change all your data on all websites”, which is disproportionate to what the extension actually does with those protocols. Users must decide whether to trust that warning based on the extension’s reputation alone.

These broad permissions also create friction with the Chrome Web Store review process. Extensions requesting webRequest and are flagged for in-depth review, adding days to every publish cycle and occasionally triggering outright rejections that require detailed justification of each permission.

In the absence of a native mechanism, IPFS Companion resorts to detecting when the browser converts an unrecognized ipfs:// URL into a search engine query, then intercepting and redirecting that query. This works, but it depends on browser-specific URL encoding behavior, breaks silently when search providers change their format, and may not work on all platforms due to security software interfering with such hijacking.

With manifest-declared protocol handlers, the extension can register IPFS directly. Navigation dispatch becomes declarative rather than interceptive. The permission model narrows, the architecture simplifies, and the integration aligns with the browser’s native routing mechanisms. These would be an example of the Extension Manifest:

  "protocol_handlers": [
    {
      "protocol": "ipns",
      "name": "IPFS Companion: IPNS Protocol Handler",
      "uriTemplate": "https://dweb.link/ipns/?uri=%s"
    },
    {
      "protocol": "ipfs",
      "name": "IPFS Companion: IPFS Protocol Handler",
      "uriTemplate": "https://dweb.link/ipfs/?uri=%s"
    }
  ]

This example illustrates the broader principle behind the feature: protocol handling should be expressed as a first-class navigation capability, not as a side effect of request rewriting.

Broader integration scenarios

Beyond decentralized networking use cases, manifest-declared protocol handlers enable enterprise and platform-level integrations. Organizations can define custom schemes that deep-link into internal systems, communication tools, authentication flows, or secure service endpoints. Extensions can manage these schemes centrally, update them through versioned deployments, and decouple protocol routing from web application modifications.

  "protocol_handlers": [
    {
      "protocol": "irc",
      "name": "Corporate IRC client",
      "uriTemplate": "https://mycompany.com/irc/?params=%s"
    },
    {
      "protocol": "mailto",
      "name": "Corporate Email client",
      "uriTemplate": "https://mycompany.com/webmail/?params=%s"
    },
    {
      "protocol": "webcal",
      "name": "Corporate Calendar client",
      "uriTemplate": "https://mycompany.com/calendar/?params=%s"
    },
    {
      "protocol": "web+plan",
      "name": "Corporate Planning client",
      "uriTemplate": "https://mycompany.com/planning/?params=%s"
    },

This establishes a structured integration surface between browser navigation and external systems while maintaining explicit user control and security guarantees.

The limits of existing mechanisms

The HTML Standard’s navigator.registerProtocolHandler() API abides by the same-origin security model. A website may only register handlers that resolve within its own origin, and registration requires explicit user activation. This model works well when a web application intends to claim responsibility for a scheme that maps naturally to its own domain. However, extensions operate under a fundamentally different trust and lifecycle model.

Extensions are packaged artifacts installed by the user, subject to store review and explicit permission approval. Their integration surface extends beyond a single origin, and often spans navigation interception, network rewriting, operating system integration, and enterprise policy enforcement. Attempting to reuse the web-origin registration model for extension use cases introduces friction and architectural complexity.

As a result, extensions in Chromium-based browsers have historically relied on indirect mechanisms. For example, extensions such as IPFS Companion intercept navigation requests, detect custom schemes, and rewrite them into gateway-based HTTP URLs using APIs like declarativeNetRequest. Although functional, this approach moves protocol handling into request interception layers, rather than treating it as a native navigation routing concern. It increases implementation complexity, expands the required permission surface, and introduces maintenance overhead.

The absence of manifest-declared protocol handlers in Chromium created a gap between the capabilities of extensions and the needs of advanced integration scenarios.

A step forward: PWAs as “URL handlers”

Progressive Web Apps provided a partial evolution of the model by allowing protocol handlers to be declared via the Web App Manifest. This improved declarative configuration, but remained tightly coupled to the application’s origin and lifecycle. It did not address scenarios where the integration logic belongs to an extension rather than a web application.

Back in 2020, Chrome started prototyping a feature called PWAs as URL Handlers, allowing apps to register themselves as handlers for URLs matching a certain pattern. This feature has been abandoned in favor of Scoped Extensions for Web App Manifest, which precisely allows web apps to overcome some of the challenges that the same-origin policy imposes on this type of site architecture.

These lines of work did not address scenarios where the integration logic belongs to an extension rather than a web application. However, these initiatives inspired the work to implement similar capabilities in Web Extensions.

Manifest-declared protocol handlers

As a result of our work, Chromium now supports the protocol_handlers key directly in the Web Extension Manifest. This feature aligns protocol registration with the extension lifecycle instead of the web-origin lifecycle.

"protocol_handlers": [
  {
    "protocol": "ircs",
    "name": "IRC Mozilla Extension",
    "uriTemplate": "https://irccloud.mozilla.com/#!/%s"
  }
]

Handlers declared in the manifest are parsed and validated during extension installation. Registration occurs at that time, but activation is deferred: the handlers remain inactive until they are invoked by a navigation request and explicitly approved by the user.

This design introduces several important properties:

  • Registration is declarative and tied to the extension artifact.
  • Validation enforces HTML Standard constraints at parse time.
  • Activation requires runtime user consent.
  • Disabling or uninstalling the extension automatically removes its handlers.

By shifting protocol registration into the manifest, the browser gains a clearer separation between declaration, validation, and activation.

Security Model and Validation

Because protocol handlers influence navigation routing, the feature inherits strict validation rules from the HTML Standard. During manifest parsing, the browser verifies that declared schemes belong to a predefined safe list and that handler URLs use HTTP or HTTPS.

 "bitcoin", "cabal",  "dat",    "did",  "doi",  "dweb", "ethereum",
 "geo",     "hyper",  "im",     "ipfs", "ipns", "irc",  "ircs",
 "magnet",  "mailto", "matrix", "mms",  "news", "nntp", "openpgp4fpr",
 "sip",     "sms",    "smsto",  "ssb",  "ssh",  "tel",  "urn",
 "webcal",  "wtai",   "xmpp"

Given that the same-origin requirement is relaxed in this model, we need to validate explicitly that the target handler operates in a secure context. This ensures that the user doesn’t leave a trustworthy origin due to the redirection performed by the protocol handler.

The Web API model imposes a requirement of a mandatory User Activation to confirm the JavaScript registration request. The Extension API model, instead, proposes a declarative approach to perform the handler registration, so it happens silently without explicit user consent. However, this does not remove the user-gesture requirement from the security model; instead, it relocates it to the extension installation process.

Extension installation is an explicit user action that requires them to review the requested permissions and give their consent. Registration of manifest-declared protocol handlers occurs as part of this installation transaction. In this sense, the User Activation requirement is satisfied at the lifecycle level rather than at the API invocation level.

In addition, activation of a registered handler is deferred. When a matching navigation occurs, the browser prompts the user before allowing the handler to resolve the request. This introduces a second layer of consent, ensuring that protocol usage cannot occur silently.

The resulting model separates concerns:

  • Installation authorizes registration.
  • Runtime approval authorizes use.

This layered approach preserves the security intent of the HTML model while adapting it to the extension trust boundary.

Runtime permission flow

A key design decision was to avoid front-loading protocol permissions during installation. Modern WebExtensions APIs increasingly rely on runtime permission requests to reduce cognitive overload and improve user comprehension.

Accordingly, protocol handlers declared in the manifest remain dormant until a matching navigation occurs. When such a navigation is triggered, the browser presents a permission dialog identifying both the extension requesting activation and the destination to which navigation will be redirected. The user may approve the request once or choose to persist the decision.

This runtime gating model ensures transparency while preserving a smooth installation experience. It also aligns protocol handling with contemporary permission paradigms used across browser APIs.

Cross-origin considerations

The same-origin requirement in the HTML Standard’s Custom Scheme Handlers API is not incidental; it is central to its threat model. When a website registers itself as a handler, the specification requires that the handler URL share the same origin as the registering site. This prevents a malicious origin from silently redirecting navigation events to an unrelated third-party origin. In the Web API model, the origin boundary is the primary trust primitive.

The extension model operates under a different trust boundary. Extensions are not ephemeral web origins; they are packaged components, installed by the user, with declared permissions and a well-defined lifecycle. As a result, enforcing same-origin constraints in the extension context would artificially restrict legitimate scenarios, like the ones described in the previous sections, without materially improving security.

For example, consider decentralized protocols such as IPFS. Content addressing in IPFS does not map cleanly to traditional origin semantics. A handler may need to resolve a scheme into HTTP resources, via gateway mechanism, or local node endpoints or simply connect to the network itself; these targets do not share a single origin in the conventional sense. Imposing a strict same-origin requirement in this context would block valid architectures without offering additional protection.

Relaxing the same-origin requirement in the extension model does not eliminate safeguards. Instead, the security model shifts from origin isolation to layered controls managed by the user. These include:

  • Extension store review and distribution controls.
  • Explicit consent during the installation.
  • Manifest-declared capabilities.
  • Runtime approval before handler activation.

This layered approach ensures that a protocol handler cannot be silently introduced or activated. Even though a handler may redirect navigation to a different origin, that behavior is explicitly tied to an installed by the user from a trusted source, and subject to runtime confirmation.

It is also important to distinguish between cross-origin navigation and cross-origin data access. Protocol handler resolution affects the destination of a navigation request; it does not grant the extension arbitrary access to the target origin’s data. Standard web security boundaries—such as the Same-Origin Policy and CORS—remain fully enforced after navigation completes.

In this way, the extension model preserves the security intent of the HTML specification while adapting it to a broader integration surface. The trust anchor shifts from “origin that called the API” to “extension the user chose to install,” but the system continues to require explicit consent by the user before navigation control is delegated.

Conflict resolution across registration mechanisms

With protocol handlers now registrable through multiple mechanisms—the Web API, PWA manifests, and extension manifests—conflict resolution becomes necessary. The implementation preserves backward compatibility by prioritizing Web API registrations. If a handler has been registered via navigator.registerProtocolHandler(), it becomes the default for the corresponding scheme. PWA and extension handlers are considered lower priority and remain available if higher-priority registrations are removed.

This deterministic ordering ensures predictable behavior and avoids ambiguity when multiple registration surfaces coexist.

Why this feature matters

Adding manifest-declared protocol handlers to Chromium closes a long-standing capability gap with Firefox, which has offered such capability since 2017. This allows extension authors to ship a single manifest that works across both browsers, eliminating the need to maintain separate interception codepaths per engine.

Manifest-declared protocol_handlers replace all of this with a single, narrowly scoped declaration. The permissions surface shrink from “read and change all your data on all websites” to a runtime prompt scoped to the specific protocol: “Allow this extension to open IPFS links through dweb.link”.

The new API respects the validation rules of the HTML Standard while adapting them to the extension trust model. It aligns protocol handling with the extension lifecycle, integrates cleanly with modern runtime permission patterns, and provides deterministic conflict resolution across registration surfaces. Store reviewers can verify the declared intent directly in the manifest without auditing request interception logic.

For browser engineers, the feature introduces a cleaner architectural boundary between navigation routing and network interception. For web authors building advanced integrations, it enables robust, declarative protocol handling without relying on brittle implementation techniques. For extension developers, it means protocol handling can finally be expressed as what it is (a navigation capability) rather than being disguised as request rewriting.

With the Web Extensions CG moving toward WG status, this is a good opportunity to advance the standardization of the protocol_handlers key by proposing its inclusion in the Manifest Keys section of the Draft Community Group Report.

by jfernandez at March 24, 2026 11:41 AM

March 20, 2026

Simón Pena

Getting started with WPE WebKit: a minimal launcher

My colleague Kate recently demonstrated on her blog how simple it is to write a WPE Platform-based launcher, and did so by building it side-by-side with MiniBrowser, inside the WebKit tree.

This entry takes one step back, and demonstrates the same concepts assuming you are not building WPE WebKit yourself, but rather getting it from your distribution. Many of the steps below would apply if you were using a Yocto/OpenEmbedded-based image, but that can be the focus of another post.

Getting WPE WebKit

Get WPE lists a number of options to get WPE from your preferred distribution. At the moment of writing, Fedora, Debian and ArchLinux are your best choices to get a recent version of WPE:

  • 2.52 on Fedora
  • 2.50 on Debian Forky, 2.52 on Debian Sid
  • 2.50 on ArchLinux

However, since WPE Platform hasn’t officially been released, we need to use Fedora, where my colleague Philippe maintains a Copr repository with it enabled.

sudo dnf copr enable -y philn/wpewebkit
sudo dnf install wpewebkit-devel

Alternatively, you can use a container. Here is a Containerfile based on Fedora 42:

FROM fedora:42

RUN dnf install -y \
    dnf-plugins-core \
    && dnf copr enable -y philn/wpewebkit \
    && dnf install -y \
    gcc-c++ \
    cmake \
    pkg-config \
    wpewebkit-devel

WORKDIR /src

Build and run it with:

podman build -t wpe-dev .
podman run -it -e WAYLAND_DISPLAY=$WAYLAND_DISPLAY \
-e XDG_RUNTIME_DIR=/run/user/$(id -u) \
-v $XDG_RUNTIME_DIR/$WAYLAND_DISPLAY:/run/user/$(id -u)/$WAYLAND_DISPLAY \
-v /dev/dri:/dev/dri \
wpe-dev bash

The build system

Kate’s post builds the launcher as part of the WebKit tree using WebKit’s own CMake infrastructure. For a standalone project, we need a self-contained CMakeLists.txt that finds WPE WebKit through pkg-config:

cmake_minimum_required(VERSION 3.16)
project(wpe_sample CXX)

set(CMAKE_CXX_STANDARD 17)

find_package(PkgConfig REQUIRED)

# The Wayland WPE Platform already depends on wpe-platform-2.0
pkg_check_modules(WebKitDeps REQUIRED
    IMPORTED_TARGET
    wpe-webkit-2.0
    wpe-platform-wayland-2.0
)

add_executable(wpe_sample main.cpp)

target_link_libraries(wpe_sample
    PRIVATE
        PkgConfig::WebKitDeps
)

The launcher

Here is a minimal launcher — the smallest amount of code needed to display a web page with WPE WebKit:

#include <wpe/webkit.h>

int main(int argc, const char *argv[]) {
    g_autoptr(GMainLoop) loop = g_main_loop_new(nullptr, false);
    g_autoptr(WebKitWebView) view = WEBKIT_WEB_VIEW(g_object_new(WEBKIT_TYPE_WEB_VIEW,
        nullptr));
    webkit_web_view_load_uri(view,
        (argc > 1) ? argv[1] : "https://wpewebkit.org");
    g_main_loop_run(loop);
    return EXIT_SUCCESS;
}

This snippet relies heavily on default behaviours: it will create a default WPE view, with default top levels, with the default display selection behaviour (Wayland), default context, settings…

Again, Kate’s post does a more realistic job at showing how the various pieces are created and connected together.

Building and running

cmake -B build
cmake --build build
./build/wpe_sample https://wpewebkit.org/

WPE WebKit minimal launcher

Display backends

WPE WebKit can render to different display backends depending on your environment, which you can select through environment variables:

# Wayland (e.g. desktop, Weston).
WPE_DISPLAY=wpe-display-wayland WAYLAND_DISPLAY=wayland-1 ./build/wpe_sample https://wpewebkit.org/

# DRM/KMS (e.g. embedded, no compositor)
WPE_DISPLAY=wpe-display-drm ./build/wpe_sample https://wpewebkit.org/

# Headless (e.g. testing, CI)
WPE_DISPLAY=wpe-display-headless ./build/wpe_sample https://wpewebkit.org/

You can take a look at wpe_display_get_default() in WPEPlatform/wpe/WPEDisplay.cpp to understand how the automatic selection takes place in the absence of an explicit WPE_DISPLAY request.

(In our example, we are only listing Wayland as a CMake dependency. If libwpewebkit was compiled without DRM or headless support, the environment variable approach would not work.)

Next steps

This is all for now. The next entry in the series will cover classic kiosk features: preventing navigation to unwanted sites, controlling whether new windows can be opened, and intercepting requests through policy decisions.

For a more complete example that includes a custom HTML context menu and JavaScript injection, see Kate’s post.

by Simón at March 20, 2026 12:00 AM

March 18, 2026

Igalia WebKit Team

WebKit Igalia Periodical #60

Update on what happened in WebKit in the week from March 10 to March 18.

The big ticket item in this week's update are the 2.52.0 releases, which include the work from the last six-month development period, and come with a security advisory. Meanwhile, WPE-Android also gets a release, and a number of featured blog posts.

WPE WebKit 📟

Last week we added support to WPE MiniBrowser to load settings from a key file. This extended the existing --config-file=FILE feature, which previously only loaded WPEPlatform settings under the [wpe-platform] group. Now the feature uses webkit_settings_apply_from_key_file() to load properties such as user-agent or enable-developer-extras from the [websettings] group as well.

Releases 📦️

WebKitGTK 2.52.0 and WPE WebKit 2.52.0 are now available. These include the results of the effort made by the team during the last six months, including rendering improvements and performance optimizations, better security for WebRTC, a more complete WebXR implementation, and a second preview of the WPEPlatform API for the WPE port—among many other changes.

More information about the changes and improvements brought by these major releases can be found at the blog post about WebKitGTK 2.52, and the corresponding one for WPE WebKit 2.52.

Accompanying these releases there is security advisory WSA-2026-0001 (GTK, WPE), with information about solved security issues. As usual, we encourage everybody to use the most recent versions where such issues are known to be fixed.

Bug reports are always welcome at the WebKit Bugzilla.

WPE Android 0.3.3 has been released, and prebuilt packages are available at the Maven Central repository. This is a maintenance release which updates the included WPE WebKit version to 2.50.6 and libsoup to 3.6.6, both of which include security fixes.

Community & Events 🤝

Kate Lee wrote a very interesting blog post showing how to create a small application using the WPEPlatform API to demonstrate one of its newly available features: the Context Menu API. It is rendered entirely as an HTML overlay, enabling richer and more portable context menu implementations.

WebXR support for WebKitGTK and WPE has been reworked and aligned with the modern multi-process architecture, using OpenXR to enable XR device integration on Linux and Android. Sergio Villar wrote a blog post that explains all the work done in the last months around it.

That’s all for this week!

by Igalia WebKit Team at March 18, 2026 07:46 PM

March 17, 2026

Emmanuele Bassi

Let’s talk about Moonforge

Last week, Igalia finally announced Moonforge, a project we’ve been working on for basically all of 2025. It’s been quite the rollercoaster, and the announcement hit various news outlets, so I guess now is as good a time as any to talk a bit about what Moonforge is, its goal, and its constraints.

Of course, as soon as somebody announces a new Linux-based OS, folks immediately think it’s a new general purpose Linux distribution, as that’s the square shaped hole where everything OS-related ends up. So, first things first, let’s get a couple of things out of the way about Moonforge:

  • Moonforge is not a general purpose Linux distribution
  • Moonforge is not an embedded Linux distribution

What is Moonforge

Moonforge is a set of feature-based, well-maintained layers for Yocto, that allows you to assemble your own OS for embedded devices, or single-application environments, with specific emphasys on immutable, read-only root file system OS images that are easy to deploy and update, through tight integration with CI/CD pipelines.

Why?

Creating a whole new OS image out of whole cloth is not as hard as it used to be; on the desktop (and devices where you control the hardware), you can reasonably get away with using existing Linux distributions, filing off the serial numbers, and removing any extant packaging mechanism; or you can rely on the containerised tech stack, and boot into it.

When it comes to embedded platforms, on the other hand, you’re still very much working on bespoke, artisanal, locally sourced, organic operating systems. A good number of device manufacturers coalesced their BSPs around the Yocto Project and OpenEmbedded, which simplifies adaptations, but you’re still supposed to build the thing mostly as a one off.

While Yocto has improved leaps and bounds over the past 15 years, putting together an OS image, especially when it comes to bundling features while keeping the overall size of the base image down, is still an exercise in artisanal knowledge.

A little detour: Poky

Twenty years ago, I moved to London to work for this little consultancy called OpenedHand. One of the projects that OpenedHand was working on was taking OpenEmbedded and providing a good set of defaults and layers, in order to create a “reference distribution” that would help people getting started with their own project. That reference was called Poky.

We had a beaver mascot before it was cool

These days, Poky exists as part of the Yocto Project, and it’s still the reference distribution for it, but since it’s part of Yocto, it has to abide to the basic constraint of the project: you still need to set up your OS using shell scripts and copy-pasting layers and recipes inside your own repository. The Yocto project is working on a setup tool to simplify those steps, but there are alternatives…

Another little detour: Kas

One alternative is kas, a tool that allows you to generate the local.conf configuration file used by bitbake through various YAML fragments exported by each layer you’re interested in, as well as additional fragments that can be used to set up customised environments.

Another feature of kas is that it can spin up the build environment inside a container, which simplifies enourmously its set up time. It avoids unadvertedly contaminating the build, and it makes it very easy to run the build on CI/CD pipelines that already rely on containers.

What Moonforge provides

Moonforge lets you create a new OS in minutes, selecting a series of features you care about from various available layers.

Each layer provides a single feature, like:

  • support for a specific architecture or device (QEMU x86_64, RaspberryPi)
  • containerisation (through Docker or Podman)
  • A/B updates (through RAUC, systemd-sysupdate, and more)
  • graphical session, using Weston
  • a WPE environment

Every layer comes with its own kas fragment, which describes what the layer needs to add to the project configuration in order to function.

Since every layer is isolated, we can reason about their dependencies and interactions, and we can combine them into a final, custom product.

Through various tools, including kas, we can set up a Moonforge project that generates and validates OS images as the result of a CI/CD pipeline on platforms like GitLab, GitHub, and BitBucket; OS updates are also generated as part of that pipeline, just as comprehensive CVE reports and Software Bill of Materials (SBOM) through custom Yocto recipes.

More importantly, Moonforge can act both as a reference when it comes to hardware enablement and support for BSPs; and as a reference when building applications that need to interact with specific features coming from a board.

While this is the beginning of the project, it’s already fairly usable; we are planning a lot more in this space, so keep an eye out on the repository.

Trying Moonforge out

If you want to check out Moonforge, I will point you in the direction of its tutorials, as well as the meta-derivative repository, which should give you a good overview on how Moonforge works, and how you can use it.

by ebassi at March 17, 2026 05:44 PM

Sergio Villar

Implementing WebXR in WebKit for WPE

Since 2022, my main focus has been working on the Wolvic browser, still the only open source WebXR-capable browser for Android/AOSP devices (Meta, Pico, Huawei, Lenovo, Lynx, HTC…) out there. That’s an effort that continues to this day (although to a much lesser extent nowadays). In early 2025, as a consequence of all that work in XR on the web, an opportunity emerged to implement WebXR support in WebKit for the WPE port, and we decided to take it.

March 17, 2026 08:46 AM

Ricardo Cañuelo Navarro

Why don't we do a demo? Part 1: the plan

Introduction

Some time ago, I saw myself with some extra time in my hands and I started experimenting with Zephyr as a way to reconnect with my professional past and also to see how embedded software looks like nowadays.

Initially, I had no further intentions beyond playing around a bit, gaining enough know-how to undertake typical embedded software projects and doing the occasional upstream contribution here and there, until a colleague told me "Now that you've spent some time with Zephyr, what do you think about doing a demo about it?". Not a bad idea. The goal is to have something to show at conferences and that showcases Zephyr's possibilities using a simple application.

At work, I'm not a specialist. What I do most of the time is basically one thing, and it typically doesn't fit in a specific field, area, or team: I solve problems 1. So this is an example of how to solve a single-sentence problem ("Let's do a demo") using whatever means necessary, involving software, hardware, planning, design, logistics, decision making and improvisation. It's also a personal expression of the importance, meaning and value of human work.

The following is a non-exhaustive list of the problems faced along the way and the solutions found.

Problem 1: the idea

The starting point is just a phrase: "Why don't we do a demo?", and a deadline. Nothing more. The amount of possibilities alone can already be an obstacle if we can't find a way to limit the solution space. Obviously, we'll find limitations and constraints down the road that will shape the final solution but, right now, everything is uncertainty.

What we want to show in the demo is the possibilities offered by Zephyr for embedded development using 100% open source software, how we can undertake complex application development with Zephyr, and show a variety of development cases within the same application.

Solution

There are may approaches to a technical demo. However, having been to conferences with demo booths, it's clear that the live and interactive demos are the ones that gather the most attention of the general public by a large margin. Regardless of the technical merits displayed in the demo, people are drawn to things they can touch, blinking lights, sounds, video games.

So a hard requirement since the beginning was that the demo should be interactive. Fortunately, the nature of the technology behind it lends itself to that easily, although I've seen many Zephyr-based demos that were rather static and only for display. The intention here is to allow the public to actually use it.

Another important thing to take into account is that widespread or hot technologies and buzzwords will be more attractive than obscure or niche terms. Fortunately, I'm building the demo from scratch, so I get to decide what to show. In this case, I picked up

BLEBluetooth Low Energy
as a base technology. Not world-changing, but familiar enough to everyone.

The goal, then, is to develop a hardware/software solution using Zephyr and its BLE stack, allowing interaction from the public and incorporating some way to display real-time information about it. The initial idea is to have small battery-powered devices in the demo booth and track their position using trilateration based on any available distance-measurement mechanism available in BLE devices, and have a central device that displays the position of the devices in real time.


Problem 2: selecting the hardware

Now that I settled on an initial idea, even if it's in a very rough and sketchy form, with no further technical details, I can start experimenting with the options. The first step is to do some research about the hardware and software possibilities to reach our goal, pick up some evaluation boards and start sketching ideas to have a better understanding of the feasibility of what I want to achieve and the limitations I can find (time, software/hardware constraints, skills, etc.)

Solution

A good option for BLE-based applications is to use some of the Nordic development kits. They're easy to source and inexpensive. Besides, the recent nRF54L15 SoC supports channel sounding, which promises precise distance estimations between devices. Just what I'm looking for.

I'll need two types of devices for this: one of them needs to be small (wearable size, if possible) and battery-powered. The other type will be at a fixed location and can have a cabled power supply. The idea is to have three devices at fixed locations in the booth measuring the distance to a number of battery-powered devices that will be moving. Then, a central device will collect the distance information from the metering devices and use it to calculate the position of each battery-powered device.
This central device will need to have some way to display the position of the devices in some kind of graphical interface, so I need to search for a device that can connect to the metering devices, that is well supported in Zephyr and that can support some kind of display out-of-the-box.

With all these requirements in mind I came up with this list of devices:

All the hardware is already supported in Zephyr, so that should eliminate a lot of the initial friction and save us time.

Problem 3: practical limitations, redefining the idea

After some initial experiments with the hardware, running sample BLE applications and getting familiar with the ecosystem, I found out that, while the nRF54L15 hardware supports Bluetooth channel sounding, the Zephyr BLE stack still doesn't support it, so in order to use it I'd need to use Nordic's SoftDevice controller instead of the upstream Zephyr controller, together with the nRF Connect BLE stack.

This is a problem because a key feature of this demo should be that it's done using 100% open source code and, preferably, upstream Zephyr code.

Another, even bigger obstacle, is that it's not clear that collecting distance data from multiple sources simultaneously for trilateration, and doing it for multiple peripherals at the same time, is practically viable. I couldn't find any examples or documentation on it, and I could be entering uncharted territory. Considering that we have a deadline for this, I'd rather find an alternative.

Solution

The immediate solution is to find a less audacious idea to develop using the same hardware that I already have, keeping it interactive but simpler, and keeping the same goals.

The idea I finally settled on is an extension of the typical BLE peripheral -- central application, where the peripheral publishes some services and the central device connects to it and issues GATT reads and writes to the peripheral characteristics, but adding a multi-level network topology instead of a simple star network, and adding real-time remote display and control of the devices using a graphical interface. So we'd have three device types: the battery-powered peripherals, which will provide the basic services, then the controller devices, which will connect to the peripherals to control them remotely, and then a console device which will connect to the controllers and can show and control the devices remotely using a graphical interface.


Zephyr supports BLE Mesh already, but we'd lose part of the challenge of implementing the networking routing ourselves, so I'm keeping things more interesting by implementing a custom tree topology that provides us with finer grained control, and which can be tailored to a specific application use case.

This means that the controller device will need to act both as a BLE central and peripheral device simultaneously, while the peripheral devices will act only as peripherals and the console will be only a central.

Problem 4: initial planning

With the development boards at hand, I can start designing and developing the firmwares for the three board types, including testing and documentation. The other certain thing I have right now is a deadline: the conference where we want to show the demo. Now I need to draw a rough plan with concrete dates.

Solution

Considering that I'll surely find a few bad surprises down the road and that there'll be uncertainty and problems that I can't yet anticipate, since it's the first time we're doing a demo with these characteristics, I set myself a personal hard deadline: one month before the real hard deadline. Ideally, the firmware should be all done and thoroughly tested one month before that, so that'd leave two full months for additional preparations and for sorting out whichever last-minute obstacles I could find in the end.

Of course, all of this rough planning is based purely on intuition. I could fall into the trap of wanting to plan everything beforehand and write a well-specified roadmap of everything that needs to be done in minute detail, but I'd be setting myself up for failure from the start, since 90% of the work ahead is a big question mark. I'm defining everything as we go, and in cases like this it's much more reasonable to plan and work based on different principles:

  • Define reasonable and achievable milestones and iterate based on them.
  • Iterate fast and as many times as needed.
  • Re-draw the plan after an iteration if needed.
  • Be ready to improvise.
  • Improve incrementally and have faith in the process. Don't look at the top of the mountain, you know where it is. Focus on the next meter of path in front.

Doing this as a one-person-army has both pros and cons. Fear and uncertainty are something you have to shoulder on your own, but you're also free to take whatever decision you need whenever you need.

So, now we're ready to start developing. A rough milestones sketch for the firmware development could be:

  • Base application for the peripheral: board setup and hardware handling.
  • Base application for the controller device: board setup and hardware handling.
  • Basic peripheral-central BLE application using the peripheral and controller devices.
  • Base application for the console device: board setup and hardware handling.
  • Make the controller device work as both a BLE peripheral and central device.
  • Incorporate the console device to the peripheral + controller application.
  • Graphical interface design and implementation.

Testing and simulation should be a part of every milestone.

In the next post we'll go through the firmware development part of the project.

1: I like to think that's a specialty, though. Maybe one day that'll be a role in the company.

by rcn at March 17, 2026 07:00 AM

March 16, 2026

Hironori Fujii

Async Scrolling Improvements

WPE WebKit and WebKitGTK support async scrolling for wheel events. I landed several improvements for the upcoming 2.52 release.

  • Bug 305451 – wheel event async scrolling doesn’t start while the main thread is blocked
  • Bug 305560 – rendering glitches for unpainted tiles
  • Bug 305561 – Paint scrollbars in the scrolling thread for async scrolling

Here are videos of before and after the changes. This is the test content.

There is still room for further improvement.

  • The scrollbar hiding animation timer is still running in the main thread.
    • It can use CoordinatedPlatformLayer::setAnimations.
    • Or CoordinatedPlatformLayer::setOpacity.
  • Add the showing animation and transition animations of mouse hover states like GTK Adwaita theme
  • Support touch and gesture events async scrolling

March 16, 2026 12:00 AM

Kate Lee

Building a Custom HTML Context Menu with the New WPEPlatform API

WPE WebKit is a WebKit port optimized for embedded devices — think set-top boxes, digital signage, kiosk displays, and in-vehicle infotainment systems. It is developed by Igalia and powers web experiences on millions of devices worldwide, from set-top boxes to smart TVs and beyond.

WPE WebKit has recently introduced a brand-new platform API called WPEPlatform, which replaces the legacy libwpe + wpebackend-fdo stack. In this post, I will walk you through building a minimal WPE browser launcher using only the new WPEPlatform API, and demonstrate one of its newly available features: the Context Menu API — rendered entirely as an HTML overlay.

Why a New API? #

The legacy stack (libwpe + wpebackend-fdo + Cog platform plugins) had several pain points: nested Wayland compositor complexity, dependency on Mesa’s now-deprecated EGL_WL_bind_wayland_display extension, rigid C function-pointer tables, and platform code scattered across three libraries.

The new WPEPlatform API replaces all of this with a single, clean GObject-based layer — providing automatic backend creation, DMA-BUF direct buffer sharing, unified window management (fullscreen, maximize, resize, title), and easy language bindings via GObject Introspection.

Timeline: The stable release of WPEPlatform is planned for September 2026. At that point, the legacy API will be officially deprecated. We strongly recommend new projects to adopt the WPEPlatform API from the start.

WPEPlatform Launcher: A Minimal Browser in ~250 Lines #

To demonstrate the new API, I built WPEPlatformLauncher — a minimal but functional WPE WebKit browser that uses only the WPEPlatform API. No legacy libwpe, no wpebackend-fdo, no Cog — just the new API.

The full source code is available at: kate-k-lee/WebKit@aed6402

How Simple Is It? #

Here is the core of the launcher — creating a WebView with the new API:

/* WPEPlatform backend is created automatically — no manual setup needed */
auto* webView = WEBKIT_WEB_VIEW(g_object_new(WEBKIT_TYPE_WEB_VIEW,
"web-context", webContext,
"network-session", networkSession,
"settings", settings,
"user-content-manager", userContentManager,
nullptr));

/* Get the WPEPlatform view — this is where the new API shines */
auto* wpeView = webkit_web_view_get_wpe_view(webView);
auto* toplevel = wpe_view_get_toplevel(wpeView);

/* Window management: fullscreen, resize, title — all built-in */
wpe_toplevel_fullscreen(toplevel);
wpe_toplevel_resize(toplevel, 1920, 1080);
wpe_toplevel_set_title(toplevel, "WPEPlatform Launcher");

/* Input events: just connect a GObject signal */
g_signal_connect(wpeView, "event", G_CALLBACK(onViewEvent), webView);

Compare this with the legacy API, which required:

  1. Manually creating a WPEToolingBackends::ViewBackend
  2. Wrapping it in a WebKitWebViewBackend with a destroy callback
  3. Creating a C++ InputClient class and registering it
  4. Having no window management (no maximize, minimize, title, etc.)

The new API handles backend creation, display detection, and input forwarding automatically.

Keyboard Shortcuts #

Handling keyboard events is straightforward with the WPEPlatform event system:

static gboolean onViewEvent(WPEView* view, WPEEvent* event, WebKitWebView* webView)
{
if (wpe_event_get_event_type(event) != WPE_EVENT_KEYBOARD_KEY_DOWN)
return FALSE;

auto modifiers = wpe_event_get_modifiers(event);
auto keyval = wpe_event_keyboard_get_keyval(event);

/* Ctrl+Q: Quit */
if ((modifiers & WPE_MODIFIER_KEYBOARD_CONTROL) && keyval == WPE_KEY_q) {
g_application_quit(g_application_get_default());
return TRUE;
}

/* F11: Toggle fullscreen via WPEToplevel */
if (keyval == WPE_KEY_F11) {
auto* toplevel = wpe_view_get_toplevel(view);
if (wpe_toplevel_get_state(toplevel) & WPE_TOPLEVEL_STATE_FULLSCREEN)
wpe_toplevel_unfullscreen(toplevel);
else
wpe_toplevel_fullscreen(toplevel);
return TRUE;
}

return FALSE;
}

HTML-Based Context Menu: Solving the “No Native UI” Challenge #

WPE WebKit is designed for embedded environments where there is no native UI toolkit — no GTK, no Qt. This means features like context menus (right-click menus) that desktop browsers take for granted need to be implemented by the application.

The approach: intercept WebKit’s context-menu signal, read the menu items, and render them as an HTML/CSS overlay injected into the page DOM.

The Architecture #

User right-clicks
  → WebKit emits "context-menu" signal
  → onContextMenu() handler:
      1. Reads menu items via webkit_context_menu_get_items()
      2. Gets position via webkit_context_menu_get_position()
      3. Builds JavaScript that creates DOM elements
      4. Injects via webkit_web_view_evaluate_javascript()
      5. Returns TRUE (suppresses default menu)

User clicks a menu item
  → JS: window.webkit.messageHandlers.contextMenuAction.postMessage(actionId)
  → C: onContextMenuAction() receives the action ID
      → Executes: webkit_web_view_go_back(), execute_editing_command("Copy"), etc.

User clicks outside the menu
  → JS: overlay click handler removes the DOM elements

Reading Context Menu Items #

The Context Menu API provides everything we need:

static gboolean onContextMenu(WebKitWebView* webView,
WebKitContextMenu* contextMenu, gpointer /* event */,
WebKitHitTestResult* hitTestResult, gpointer)
{
/* Save hit test result for link-related actions */
savedHitTestResult = WEBKIT_HIT_TEST_RESULT(g_object_ref(hitTestResult));

/* Iterate through menu items */
GList* items = webkit_context_menu_get_items(contextMenu);
for (GList* l = items; l; l = l->next) {
auto* item = WEBKIT_CONTEXT_MENU_ITEM(l->data);

if (webkit_context_menu_item_is_separator(item)) {
/* Render as a horizontal line */
continue;
}

const char* title = webkit_context_menu_item_get_title(item);
auto action = webkit_context_menu_item_get_stock_action(item);
/* Build HTML element with title and action ID */
}

/* Get position for menu placement */
gint posX = 0, posY = 0;
webkit_context_menu_get_position(contextMenu, &posX, &posY);

return TRUE; /* Suppress default menu */
}

The HTML Menu: Dark Theme for Embedded #

The context menu is rendered with a dark theme CSS, designed for embedded/kiosk displays:

#__wpe_ctx_menu {
position: fixed;
min-width: 180px;
background: #2b2b2b;
border: 1px solid #505050;
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
font-family: system-ui, sans-serif;
font-size: 13px;
color: #e0e0e0;
}

.__wpe_ctx_item:hover {
background: #0060df;
color: #ffffff;
}

Handling Actions via Script Message Handler #

Communication between the HTML menu and the C application uses WebKit’s script message handler mechanism:

/* Register message handler */
auto* ucm = webkit_user_content_manager_new();
webkit_user_content_manager_register_script_message_handler(
ucm, "contextMenuAction", nullptr);
g_signal_connect(ucm, "script-message-received::contextMenuAction",
G_CALLBACK(onContextMenuAction), nullptr);
// In the generated HTML menu item:
item.addEventListener('click', function() {
window.webkit.messageHandlers.contextMenuAction.postMessage(actionId);
});
/* Handle the action in C */
static void onContextMenuAction(WebKitUserContentManager*, JSCValue* value, gpointer)
{
int actionId = jsc_value_to_int32(value);

switch (actionId) {
case WEBKIT_CONTEXT_MENU_ACTION_RELOAD:
webkit_web_view_reload(webView);
break;
case WEBKIT_CONTEXT_MENU_ACTION_COPY:
webkit_web_view_execute_editing_command(webView, "Copy");
break;
case WEBKIT_CONTEXT_MENU_ACTION_OPEN_LINK:
webkit_web_view_load_uri(webView,
webkit_hit_test_result_get_link_uri(savedHitTestResult));
break;
/* ... more actions ... */
}
}

Demo #

Here is the WPEPlatformLauncher in action, showing the HTML context menu with various actions:

WPEPlatformLauncher context menu demo

Right-clicking shows the HTML context menu. Clicking “Reload” triggers an actual page reload.

Context menu on a link

Right-clicking a link shows link-specific actions like “Open Link” and “Copy Link Address”.

Building and Running #

I built and ran the WPEPlatformLauncher inside a container using the WebKit Container SDK, which provides a pre-configured development environment with all the dependencies needed to build WPE WebKit.

The WPEPlatformLauncher integrates into the WebKit build system:

# Build WPE WebKit with the launcher
Tools/Scripts/build-webkit --wpe --release

# Run
./WebKitBuild/WPE/Release/bin/WPEPlatformLauncher https://wpewebkit.org

# Run in fullscreen (kiosk mode)
./WebKitBuild/WPE/Release/bin/WPEPlatformLauncher --fullscreen https://your-app.com

The full source is a single main.cpp file (~600 lines including the context menu), integrated into the WebKit tree alongside MiniBrowser:

WebKit/Tools/
├── MiniBrowser/wpe/          ← Existing (supports both old + new API)
├── WPEPlatformLauncher/      ← New (WPEPlatform API only)
│   ├── main.cpp
│   └── CMakeLists.txt
└── PlatformWPE.cmake         ← Modified to add WPEPlatformLauncher

Summary #

The new WPEPlatform API makes building WPE WebKit applications significantly simpler:

  • No manual backend setup — the platform is detected and configured automatically
  • GObject-based — signals, properties, and ref counting instead of C function pointers
  • DMA-BUF direct sharing — no dependency on Mesa’s deprecated EGL extensions
  • Unified window management — fullscreen, maximize, minimize, resize, and title
  • Language binding friendly — works with Python, JavaScript, and more via GObject Introspection

For embedded browser developers building kiosk UIs, set-top box interfaces, or digital signage with WPE WebKit — now is the time to adopt the new API. The stable release is coming in September 2026, and the legacy stack (libwpe, wpebackend-fdo, Cog) will be deprecated at that point.

Resources #

March 16, 2026 12:00 AM

March 11, 2026

Hironori Fujii

Building WebKit and libsoup with AddressSanitizer (ASan)

I built libsoup and WebKit with ASan today. It works almost out of the box. I used Clang. GCC also supports ASan, but WebKit has a problem with it. WebKit Container SDK is based on Ubuntu 20.04 LTS at the moment. It contains clang 18 by default.

Installed required packages.

sudo apt install libclang-rt-18-dev llvm-18-dev

Set env vars.

export CC=clang CXX=clang++

Passed some flags to libsoup.

--- /jhbuild/webkit-sdk-deps.modules.orig
+++ /jhbuild/webkit-sdk-deps.modules
@@ -149,7 +149,7 @@
</dependencies>
</meson>

- <meson id="libsoup" mesonargs="-Dtests=false">
+ <meson id="libsoup" mesonargs="-Dtests=false -Db_sanitize=address -Db_lundef=false">
<branch repo="github.com"
checkoutdir="libsoup"
module="GNOME/libsoup.git" tag="3.6.6"/>

Then, build and install libsoup.

jhbuild buildone -f libsoup

Then, build WebKit with ASan.

./Tools/Scripts/build-webkit --gtk --release --cmakeargs=-DENABLE_SANITIZERS=address

WebKit has a lot of memory leaks by design. Don’t detect leaks.

export ASAN_OPTIONS=detect_leaks=0

For run-webkit-tests, I had to modify a script a bit.

diff --git a/Tools/Scripts/webkitpy/port/driver.py b/Tools/Scripts/webkitpy/port/driver.py
index eb12801a455b..c9f74eeab4e2 100644
--- a/Tools/Scripts/webkitpy/port/driver.py
+++ b/Tools/Scripts/webkitpy/port/driver.py
@@ -482,7 +482,7 @@ class Driver(object):
else:
environment['DUMPRENDERTREE_TEMP'] = str(self._driver_tempdir)
environment['LOCAL_RESOURCE_ROOT'] = str(self._port.layout_tests_dir())
- environment['ASAN_OPTIONS'] = "allocator_may_return_null=1"
+ environment['ASAN_OPTIONS'] = "allocator_may_return_null=1:detect_leaks=0"
environment['__XPC_ASAN_OPTIONS'] = environment['ASAN_OPTIONS']

# Disable vnode-guard related simulated crashes for WKTR / DRT (rdar://problem/40674034).

That’s it. Enjoy.

March 11, 2026 12:00 AM

March 10, 2026

Andy Wingo

nominal types in webassembly

Before the managed data types extension to WebAssembly was incorporated in the standard, there was a huge debate about type equality. The end result is that if you have two types in a Wasm module that look the same, like this:

(type $t (struct i32))
(type $u (struct i32))

Then they are for all intents and purposes equivalent. When a Wasm implementation loads up a module, it has to partition the module’s types into equivalence classes. When the Wasm program references a given type by name, as in (struct.get $t 0) which would get the first field of type $t, it maps $t to the equivalence class containing $t and $u. See the spec, for more details.

This is a form of structural type equality. Sometimes this is what you want. But not always! Sometimes you want nominal types, in which no type declaration is equivalent to any other. WebAssembly doesn’t have that, but it has something close: recursive type groups. In fact, the type declarations above are equivalent to these:

(rec (type $t (struct i32)))
(rec (type $u (struct i32)))

Which is to say, each type is in a group containing just itself. One thing that this allows is self-recursion, as in:

(type $succ (struct (ref null $succ)))

Here the struct’s field is itself a reference to a $succ struct, or null (because it’s ref null and not just ref).

To allow for mutual recursion between types, you put them in the same rec group, instead of each having its own:

(rec
 (type $t (struct i32))
 (type $u (struct i32)))

Between $t and $u we don’t have mutual recursion though, so why bother? Well rec groups have another role, which is that they are the unit of structural type equivalence. In this case, types $t and $u are not in the same equivalence class, because they are part of the same rec group. Again, see the spec.

Within a Wasm module, rec gives you an approximation of nominal typing. But what about between modules? Let’s imagine that $t carries important capabilities, and you don’t want another module to be able to forge those capabilities. In this case, rec is not enough: the other module could define an equivalent rec group, construct a $t, and pass it to our module; because of isorecursive type equality, this would work just fine. What to do?

cursèd nominal typing

I said before that Wasm doesn’t have nominal types. That was true in the past, but no more! The nominal typing proposal was incorporated in the standard last July. Its vocabulary is a bit odd, though. You have to define your data types with the tag keyword:

(tag $v (param $secret i32))

Syntactically, these data types are a bit odd: you have to declare fields using param instead of field and you don’t have to wrap the fields in struct.

They also omit some features relative to isorecursive structs, namely subtyping and mutability. However, sometimes subtyping is not necessary, and one can always assignment-convert mutable fields, wrapping them in mutable structs as needed.

To construct a nominally-typed value, the mechanics are somewhat involved; instead of (struct.new $t (i32.const 42)), you use throw:

(block $b (result (ref exn))
 (try_table
  (catch_all_ref $b)
  (throw $v (i32.const 42)))
 (unreachable))

Of course, as this is a new proposal, we don’t yet have precise type information on the Wasm side; the new instance instead is returned as the top type for nominally-typed values, exn.

To check if a value is a $v, you need to write a bit of code:

(func $is-v? (param $x (ref exn)) (result i32)
  (block $yep (result (ref exn))
   (block $nope
    (try_table
     (catch_ref $v $yep)
     (catch_all $nope)
     (throw_ref (local.get $x))))
   (return (i32.const 0)))
  (return (i32.const 1)))

Finally, field access is a bit odd; unlike structs which have struct.get, nominal types receive all their values via a catch handler.

(func $v-fields (param $x (ref exn)) (result i32)
  (try_table
   (catch $v 0)
   (throw_ref (local.get $x)))
  (unreachable))

Here, the 0 in the (catch $v 0) refers to the function call itself: all fields of $v get returned from the function call. In this case there’s only one, othewise a get-fields function would return multiple values. Happily, this accessor preserves type safety: if $x is not actually $v, an exception will be thrown.

Now, sometimes you want to be quite strict about your nominal type identities; in that case, just define your tag in a module and don’t export it. But if you want to enable composition in a principled way, not just subject to the randomness of whether another module happens to implement a type structurally the same as your own, the nominal typing proposal also gives a preview of type imports. The facility is direct: you simply export your tag from your module, and allow other modules to import it. Everything will work as expected!

fin

Friends, as I am sure is abundantly clear, this is a troll post :) It’s not wrong, though! All of the facilities for nominally-typed structs without subtyping or field mutability are present in the exception-handling proposal.

The context for this work was that I was updating Hoot to use the newer version of Wasm exception handling, instead of the pre-standardization version. It was a nice change, but as it introduces the exnref type, it does open the door to some funny shenanigans, and I find it hilarious that the committee has been hemming and hawwing about type imports for 7 years and then goes and ships it in this backward kind of way.

Next up, exception support in Wastrel, as soon as I can figure out where to allocate type tags for this new nominal typing facility. Onwards and upwards!

by Andy Wingo at March 10, 2026 08:19 AM

Yeunjoo Choi

Smarter Chromium GN in Vim with gn-language-server

GN Language Server for Chromium development was announced on chromium-dev. It’s very easy to install in VSCode, NeoVim or Emacs. But how can we configure it with classic Vim + YCM?

Setup

First, install the language server with Cargo.

cargo install --locked gn-language-server

Then, add this to your vimrc.

let g:ycm_language_server = [
      \ {
      \   'name': 'gn',
      \   'cmdline': [ 'gn-language-server' ],
      \   'filetypes': [ 'gn' ],
      \ }
  \ ]

That easy, right?

What’s Working

Hover Documentation

hover

Go To Imports

jump_import

Go To Dependencies

jump_deps

Current Limitations

The following features are not working yet. They may need more configuration or further work:

Code Folding

Classic Vim and YCM don’t support LSP-based folding, and I’m not a big fan of that feature anyway. But you can configure another plugin that supports LSP-based folding, or simply rely on indent-based folding.

Go To Definition

When I try to go to the definition of template, I get an error KeyError: 'uri'. I’m not sure whether this is caused by my local configuration, but it needs further investigation. go_def_error

March 10, 2026 03:06 AM

March 09, 2026

Igalia WebKit Team

WebKit Igalia Periodical #59

Update on what happened in WebKit in the week from March 2 to March 9.

As part of this week's handful of news, WebKitGTK and WPE WebKit now have support for Gamepad's "VibationActuator" property, the video decoding limit is now configurable at runtime in addition to build time, and an interesting fix that makes WebKit render fonts like other browsers by making it blend text incorrectly (!).

Cross-Port 🐱

Using libmanette's rumble support, enabled Gamepad VibrationActuator for WebKitGTK and WPE WebKit.

With these changes, playEffect() can be used to play dual-rumble vibration effects.

Multimedia 🎥

GStreamer-based multimedia support for WebKit, including (but not limited to) playback, capture, WebAudio, WebCodecs, and WebRTC.

VIDEO_DECODING_LIMIT is now configurable at runtime, in addition to build time. That will allow vendors that share a single binary build on different platforms to fine-tune their needs without a rebuild.

Graphics 🖼️

Landed a change that tweaks the text rendering done with Skia. With this change, the text looks more natural now - just like in other browsers. However, this is done by blending text incorrectly as a compromise.

Releases 📦️

One more set of release candidates for the upcoming stable branch, WebKitGTK 2.51.93 and WPE WebKit 2.51.93, have been published. For those interested in previewing the upcoming 2.52.x series this release is expected to be quite stable. Reporting issues in Bugzilla are, as usual, more than welcome.

That’s all for this week!

by Igalia WebKit Team at March 09, 2026 08:02 PM

March 04, 2026

Tiago Vignatti

Accessibility and PDF documents

Accessibility
#

When we think of accessibility, we tend to picture it as something designed for a small minority. The reality is much broader: 16% of the world’s population — 1.3 billion people — live with a significant disability¹. In Brazil alone, where I live, that means around 14.4 million people report some form of disability². And those numbers capture only permanent disabilities.

March 04, 2026 01:00 PM

March 02, 2026

Igalia WebKit Team

WebKit Igalia Periodical #58

Update on what happened in WebKit in the week from February 23 to March 2.

This installment of the periodical brings news about support for Qualcomm qtivdec2 and qtivenc2 on GStreamer, GPU texture atlas creation and replay substitution, enhancement of the scroll gesture in WPE, and two new releases: WebKitGTK 2.51.92 and WPE WebKit 2.51.92.

Cross-Port 🐱

Multimedia 🎥

GStreamer-based multimedia support for WebKit, including (but not limited to) playback, capture, WebAudio, WebCodecs, and WebRTC.

Work on adding support for the Qualcomm GStreamer qtivdec2 and qtivenc2 elements is on-going

Graphics 🖼️

Implemented GPU texture atlas creation and replay substitution in the Skia painting engine on GTK/WPE. After recording, raster images are packed into GPU atlases via BitmapTexture, with two upload paths: an optimized DMA-buf path that memory-maps GPU buffers and dispatches uploading to a dedicated worker thread, and a synchronous GL fallback using BitmapTexture::updateContents(). Atlas uploads are synchronized across workers using a countdown-latch fence. During replay, SkiaReplayCanvas intercepts raster image draws and substitutes them with atlas texture draws, mapping source coordinates into atlas space.

WPE WebKit 📟

WPE Platform API 🧩

New, modern platform API that supersedes usage of libwpe and WPE backends.

The recent WPE WebKit 2.51.92 release is the first one to have its WPEPlatform documentation online, but it was not included in the tarball. This issue has been corrected and tarballs for future releases will also include this documentation.

Scrolling using touch input with WPEPlatform would result in scrolling faster when more than one touch point was in effect. The gesture detector has been fixed to make scrolling have always a consistent speed.

Releases 📦️

The third —and likely the last— release candidates for the upcoming stable branch, WebKitGTK 2.51.92 and WPE WebKit 2.51.92, have been published. For those interested in previewing the upcoming 2.52.x series this release is expected to be quite stable; but there might be still some rough edges. Reporting issues in Bugzilla are, as usual, more than welcome.

That’s all for this week!

by Igalia WebKit Team at March 02, 2026 08:11 PM

Ziran Sun

A Day in “State of the Browser 2026” Conference

The “State of the Browser 2026” Conference was held on Saturday, the 28th of February in The Barbican Centre, London. It is a yearly conference organised by London Web Standards. This is year is the 14th Edition.

From Igalia, this year we had Luke Warlow and myself attended in person, Javier Fernández attended online. My colleague Stephanie Stimac introduced this event to Igalia a couple of years ago. Now Igalia has become one of the sponsors for this great event. Luke had participated this event previously so it’s very helpful to understand more about this event from his note.

The event is a one-day, single-track conference that is community focused. While queuing for the registrations, a couple of attendees commented that talks for this event had been very good in the past few years. I’d say, this year was not an exception. I thoroughly enjoyed the talks, and the whole experiences.

Talks throughout the day covered a wide variety of topics including CSS features, accessibility, JS footprint, playing with gaming APIs and the art of connecting to people etc.. As someone who loves food, maybe I can describe it as a feast with content, taste, depth, variety…and a bit fun factor?

The open talk was Anchor positioning by Bramus Van Damme. The walk-through on the feature with examples were pretty cool, especially the case of a popover… with a little triangle (You’ll know what I mean if you look up the talk). Igalia worked on popover for Firefox in 2024, sponsored by Google. It’s really great to see that anchor positioning is in Firefox – popover has now found its place.

It was nice to hear that Igalians’ names were mentioned in the Temporal talk by Jason Williams from Bloomberg. A big shout out to Philip Chimento, Ujjwal Sharma who have participated substantially in the discussions about standardizing Temporal over the years and my fellow Igalians who have been writing spec PRs and tests for the feature. Check on Tim Chevalier’s blog on “Implementing the Temporal proposal in JavaScriptCore” if you’d like to find out more.

The atmosphere of the event was friendly, inclusive and energetic. I was very happy bumping into some ex-colleagues and making new friends.

One final note – This event brings a range of attendees, many are web developers. There are representatives from companies and browser vendors etc.. For some web developers, “Igalia” is a new name. I had a question like “Oh, is it the company with rainbow colours in the sponsors?”. Yes, Igalia is a private, worker-owned, employee-run cooperative model consultancy focused on open source software[1]. And Igalia has been a part of the Interop Project since its inception in 2021. Here is Igalia’s “rainbowy” logo :-).

by zsun at March 02, 2026 06:11 PM

February 24, 2026

Frédéric Wang

Stage d'implémentation des normes Web (session 2026)

Les candidatures pour les « stages de programmation informatique » d’Igalia sont officiellement ouvertes jusqu’à début avril. Ils offrent aux étudiant·e·s l’occasion de participer au développement de logiciels libres tout en étant rémunéré·e·s 7 000 € brut pour 450 heures, réparties de juin à décembre 2026.

Comme chaque année, j’encadrerai un·e étudiant·e sur l’« Implémentation des normes Web » (Web Standards en anglais). L’objectif étant de modifier les navigateurs (Chromium, Firefox ou Safari…) afin d’améliorer le support de technologies Web (HTML, CSS, DOM…). Il faudra notamment étudier les spécifications correspondantes et écrire des tests de conformité. Notez bien que ce n’est pas un stage de développement Web mais de développement C++.

Un des objectifs de ce programme étant de lutter contre les discriminations professionnelles, tout le monde (y compris celles et ceux qui se sentent sous-représenté·e·s dans le secteur informatique) sont invité·e·s à candidater. Depuis 2016, mon équipe « Web Platform » a ainsi encadré 13 étudiant·e·s de différents pays dans le monde (Espagne, Inde, Italie, Australie, Cameroun, Chine, Vietnam, Angleterre et États-Unis) dont 7 femmes. L’année dernière, nous avions sélectionné Charlotte McCleary, une Américaine non-voyante qui a travaillé sur l’accessibilité dans Firefox au cours de son stage et a depuis rejoint Fizz Studio. J’aimerais encourager les étudiant·e·s Sourd·e·s à postuler et donne dans la vidéo ci-dessous une brève présentation du programme en LSF (en espérant que ce soit compréhensible et que vous serez indulgents avec mon piètre niveau en langue des signes 😅):

Si vous êtes intéréssé·e·s, remplissez ce formulaire en cochant la case Web Standards et en précisant éventuellement que vous avez trouvé cette offre via mon site Web. Enfin, si vous connaissez des étudiant·e·s qui pourraient participer, n’hésitez pas à partager l’annonce !

February 24, 2026 11:00 PM

February 23, 2026

Igalia WebKit Team

WebKit Igalia Periodical #57

Update on what happened in WebKit in the week from February 9 to February 23.

In this week we have a nice fix for video streams timestamps, a fix for a PDF rendering regression, support for rendering video buffers provided by Qualcomm video decoders, and a fix for a font selection issue. Also notable we had a new WPE Android release, and the libsoup 3.6.6 release.

Cross-Port 🐱

Added a new webkit_feature_list_find() convenience function to the public API, which searches for a WebKitFeature given its identifier.

Multimedia 🎥

GStreamer-based multimedia support for WebKit, including (but not limited to) playback, capture, WebAudio, WebCodecs, and WebRTC.

Graphics 🖼️

Fixed a PDF rendering regression caused by the canvas 2D operation recording feature, where switching between the recording canvas and the GPU surface canvas failed to preserve the full save/restore nesting, clip stack, and transparency layer state. Replaced the fragile state-copying approach with a state replay mechanism in GraphicsContextSkia that tracks the full sequence of save restore, clip, and transparency layer operations, then reconstructs the exact nesting on the target canvas when flushing a recording.

Added support for rendering video buffers provided by Qualcomm hardware-accelerated decoders, with aid from the EXT_YUV_target OpenGL extension.

Fixed the font selection issue that the system fallback font cache mixed up different font styles.

Releases 📦️

WPE Android 0.3.2 has been released, and prebuilt packages are available at the Maven Central repository. This is a stable maintenance release which updates WPE WebKit to 2.50.5, which is the most recent stable release.

libsoup 3.6.6 has been released with numerous bug and security fixes.

That’s all for this week!

by Igalia WebKit Team at February 23, 2026 07:52 PM

Mauricio Faria de Oliveira

page_owner Part 2: optimizing output

This blog post is part of a series about the page_owner debug feature in the Linux memory management subsystem, related to the talk Improving page_owner for profiling and monitoring memory usage per allocation stack trace presented at Linux Plumbers Conference 2025.

  • Part 1 is a quick introduction to page_owner and its debugfs files.
  • Part 2 describes challenges with processing page_owner files over time and a solution with new debugfs files in Linux v6.19.

Problem: stack traces over time

As described in Part 1, page_owner’s debugfs files contain stack traces for the most part:

  • /sys/kernel/debug/page_owner has one stack trace per allocated page, and
  • /sys/kernel/debug/page_owner_stacks/show_stacks lists the stack traces that allocated pages.

Reading and processing a significant amount of stack traces incurs a non-trivial computational cost in CPU and memory (copying to, and processing in, userspace) and storage usage, as the total size for such long strings might become large. This shouldn’t be an issue if done only once, but it does pose a concern if done repeatedly.

Take the processing of stack traces one step further and that concern materializes into a technical problem:

How to store information (say, number of pages) per-stack trace and over time?

For that, the stack trace must become a key to be assigned values from multiple reads over time. However, keys are usually numbers or somewhat short identifiers, not such long strings as stack traces (although doable, that is computationally more expensive in CPU and memory usage).

Workaround: stack trace hashing

One possible solution to this problem is hashing the stack traces and using the resulting hash values as keys.

However, this is inefficient with page_owner since there is significant duplication of stack traces on both debugfs files:

  • In the page_owner file, even on a single read, some stack traces may have tens/hundreds/thousands of duplicates; and they compound on multiple reads over time.
  • In the show_stacks file, there are no duplicates on a single read, but duplicates frequently happen on multiple reads over time.

With a high ratio of duplication, the dominant component in computational cost is the hashing step, which is significantly more expensive than the remaining step that simply use the resulting keys for storing values.

Additionally, the hashing step is usually repeated with the same data set (stack traces present in previous reads), which means that most of the calculations are discarded and done again on every read – wasting time and computational resources.

For illustration purposes, compare the execution time of script page_owner-to-show_stacks.py, which parses the page_owner file hashing the stack traces (with the extremely fast XXH3_64) and accumulating the number of pages per stack trace, reporting it at the end – basically mimicking show_stacks – with just reading the equivalent file.

The single read with hashing is 38.55 times slower:

# time ./page_owner-to-show_stacks.py </sys/kernel/debug/page_owner >/dev/null

real 0m1.542s
user 0m1.486s
sys 0m0.057s

# time cat /sys/kernel/debug/page_owner_stacks/show_stacks >/dev/null

real 0m0.040s
user 0m0.000s
sys 0m0.040s

So, considering the single-read results with the page_owner file, it’s not compelling to use it for multiple reads. However, multiple reads of the show_stacks file instead should perform better, though, as it contains unique stack traces and likely a lower ratio of duplication on multiple reads than in a single read of the former file.

Check the execution time of script show_stacks-over-time.py, which parses copies of show_stacks (collected over time), similarly hashing the stack traces and storing the number of pages per stack trace over time (that is, per copy).

For 100 copies, the execution time is almost 1 second:

# time ./show_stacks-over-time.py show_stacks.{1..100} >/dev/null

real 0m0.944s
user 0m0.900s
sys 0m0.044s

That is a great improvement (comparing to processing a single read of the page_owner file), but this is just a particular case on a lightly stressed, small VM with 1 GiB RAM. There is still the computational cost of hashing, which might increase processing time in cases with more stack traces (that is, a greater number of different code paths for memory allocation were exercised in the kernel).

Solution: stack trace handle numbers

The hashing of stack traces is only required in order to obtain a unique identifier for each stack trace, so that it can be used as a key. However, if such an identifier were already available, the hashing step (and associated computational cost) could be avoided altogether.

Fortunately, that is now the case with Linux 6.19! The stack trace storage used by page_owner ( stackdepot) provides a handle number to uniquely refer to stack traces – which meets the requirement.

Linux 6.19 contains two new debugfs files with handle numbers for optimized output:

  • /sys/kernel/debug/page_owner_stacks/show_handles: this lists nr_base_pages: per handle: (instead of per stack trace as in show_stacks)
  • /sys/kernel/debug/page_owner_stacks/show_stacks_handles: this lists handle: per stack trace (for resolving handle numbers to stack traces)

For the example in the previous post, show_stacks contains:

# cat /sys/kernel/debug/page_owner_stacks/show_stacks
...
 get_page_from_freelist+0x1416/0x1600
 __alloc_frozen_pages_noprof+0x18c/0x1000
 alloc_pages_mpol+0x43/0x100
 folio_alloc_noprof+0x56/0xa0
 page_cache_ra_unbounded+0xd9/0x230
 filemap_fault+0x305/0x1000
 __do_fault+0x2c/0xb0
 __handle_mm_fault+0x6f4/0xeb0
 handle_mm_fault+0xd9/0x210
 do_user_addr_fault+0x205/0x600
 exc_page_fault+0x61/0x130
 asm_exc_page_fault+0x26/0x30
nr_base_pages: 9643

...

While, for the same snippet, show_handles contains:

...
handle: 27000838
nr_base_pages: 9643

...

And the handle number can be resolved to a stack trace with show_stacks_handles:

...
 get_page_from_freelist+0x1416/0x1600
 __alloc_frozen_pages_noprof+0x18c/0x1000
 alloc_pages_mpol+0x43/0x100
 folio_alloc_noprof+0x56/0xa0
 page_cache_ra_unbounded+0xd9/0x230
 filemap_fault+0x305/0x1000
 __do_fault+0x2c/0xb0
 __handle_mm_fault+0x6f4/0xeb0
 handle_mm_fault+0xd9/0x210
 do_user_addr_fault+0x205/0x600
 exc_page_fault+0x61/0x130
 asm_exc_page_fault+0x26/0x30
handle: 27000838

...

Comparison: show_stacks vs. show_handles

From the previous post, for show_stacks:

# time cat /sys/kernel/debug/page_owner_stacks/show_stacks \
 | wc --bytes | numfmt --to=iec
402K

real 0m0.042s
user 0m0.004s
sys 0m0.046s

Now, for show_handles:

# time cat /sys/kernel/debug/page_owner_stacks/show_handles \
 | wc --bytes | numfmt --to=iec
31K

real 0m0.015s
user 0m0.004s
sys 0m0.019s

That is only 7.7% of the size and 35.7% of the time! Nice improvements.

Finally, compare the execution time of script show_handles-over-time.py with the previous one; it uses handle numbers as keys for stack traces instead of hashing them.

For 100 copies, the execution time is approximately 1/3 of a second, roughly 3 times faster.

# time ./show_handles-over-time.py show_stacks_handles show_handles.ln.{1..100} >/dev/nul

real 0m0.348s
user 0m0.319s
sys 0m0.030s

Conclusion

The original debugfs files provided by page_owner consist mainly of stack traces, which isn’t an efficient format for reading and processing repeatedly.

In order to store the number of pages used per stack trace over time, the stack traces must be converted to keys for storing values over time, for which hashing can be used. However, even efficient hashing algorithms incur a significant overhead.

In order to address this issue, Linux 6.19 provides new debugfs files for page_owner with handle numbers, which are unique identifiers for stack traces and can be used as keys, instead of hashing.

This optimizes the reading and processing of page_owner information, as it reduces the amount of data copied from kernel to userspace and allows storing the number of pages per stack trace over time without the overhead of hashing.

Scripts

Script 1: page_owner-to-show_stacks.py

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0
#
# Script to parse /sys/kernel/debug/page_owner, hashing the stack trace
# of each page and accumulating the number of pages per stack trace.
# At the end, print all stack traces and their number of pages in a format
# like /sys/kernel/debug/page_owner_stacks/show_stacks.
#
# Usage: page_owner-to-show_stacks.py </sys/kernel/debug/page_owner
#
# Author: Mauricio Faria de Oliveira <mfo@igalia.com>

import re
import sys
import xxhash

re_page = re.compile('^Page allocated via order ([0-9]+)')
re_stack = re.compile('^ ')
re_empty = re.compile('^$')

pages = {} # key -> number of pages
stacks = {} # key -> stack trace

for line in sys.stdin:

 # middle lines: try stack trace first as it occurs more often
 if re_stack.match(line):
 stack = stack + line
 continue
 
 # first line
 match = re_page.match(line)
 if match:
 order = int(match.group(1));
 stack = ''
 continue

 # last line
 if re_empty.match(line):
 key = xxhash.xxh3_64_hexdigest(stack)
 nr_pages = 2 ** order

 if key in pages:
 pages[key] += nr_pages
 else:
 pages[key] = nr_pages
 stacks[key] = stack

 continue

for key in stacks.keys():
 print(" " + stacks[key].strip())
 print("nr_base_pages: " + str(pages[key]))
 print()

Script #2: show_stacks-over-time.py

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0
#
# Script to parse /sys/kernel/debug/page_owner_stacks/show_stacks in multiple
# reads, hashing each stack trace and recording the number of base pages per
# stack trace in each read.
# At the end, print all stack traces and their number of pages in each read.
#
# Usage: show_stacks-over-time.py <read1> <read2> <read3> ... <read N>
#
# Author: Mauricio Faria de Oliveira <mfo@igalia.com>

import re
import sys
import xxhash

re_pages = re.compile('^nr_base_pages: ([0-9]+)')
re_stack = re.compile('^ ')
re_empty = re.compile('^$')

stacks = {}	# key -> stack trace (all reads)
pages = {}	# key -> array of number of pages (per read)
read = 0	# number of the current read

if len(sys.argv) < 2:
	exit(1)

files = sys.argv[1:]
nr_files = len(files)

for file in files:
	with open(file, 'r') as fd:
		stack = ''
		for line in fd:
	
			# first lines
			if re_stack.match(line):
				stack = stack + line
				continue
				
			# next to last line
			match = re_pages.match(line)
			if match:
				nr_pages = int(match.group(1));
				continue
		
			# last line
			if re_empty.match(line):
				key = xxhash.xxh3_64_hexdigest(stack)
		
				if key not in stacks:
					stacks[key] = stack;

				if key not in pages:
					pages[key] = {}

				pages[key][read] = nr_pages
		
				stack = ''
				continue

		read += 1

for key in stacks.keys():
	print(" " + stacks[key].strip())

	pages_per_read = []
	for read in range(nr_files):
		nr_pages = 0
		if read in pages[key]:
			nr_pages = pages[key][read]
		pages_per_read.append(str(nr_pages))
	
	print(' '.join(pages_per_read))
	print()

Script #3: show_handles-over-time.py

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0
#
# Script to parse /sys/kernel/debug/page_owner_stacks/show_handles in multiple
# reads, collecting handle numbers and recording the number of base pages per
# handle number in each read.
# At the end, print all stack traces and their number of pages in each read,
# resolving handle numbers with /sys/kernel/debug/page_owner_stacks/show_stacks_handles.
#
# Usage: show_handles-over-time.py <show_stacks_handles> <read1> <read2> <read3> ... <read N>
#
# Author: Mauricio Faria de Oliveira <mfo@igalia.com>

import re
import sys
import xxhash

re_pages = re.compile('^nr_base_pages: ([0-9]+)')
re_stack = re.compile('^ ')
re_empty = re.compile('^$')
re_handle = re.compile('^handle: ([0-9]+)')

stacks = {}	# handle number -> stack trace (all reads)
pages = {}	# handle number -> array of number of pages (per read)
read = 0	# number of the current read

if len(sys.argv) < 3:
	exit(1)

resolver = sys.argv[1]
files = sys.argv[2:]
nr_files = len(files)

for file in files:
	with open(file, 'r') as fd:
		for line in fd:
	
			# first line
			match = re_handle.match(line)
			if match:
				handle = int(match.group(1))
				continue
				
			# next to last line
			match = re_pages.match(line)
			if match:
				nr_pages = int(match.group(1));
				continue
		
			# last line
			if re_empty.match(line):
				key = handle
		
				if key not in pages:
					pages[key] = {}

				pages[key][read] = nr_pages
		
				continue

		read += 1

with open(resolver, 'r') as fd:
 stack = ''

 for line in fd:

 # first line
 if re_stack.match(line):
 stack = stack + line
 continue

 # next to last line
 match = re_handle.match(line)
 if match:
 handle = int(match.group(1))
 continue
 
 # last line
 if re_empty.match(line):
 stacks[handle] = stack
 stack = ''
 continue

for key in pages.keys():
	print(" " + stacks[key].strip())

	pages_per_read = []
	for read in range(nr_files):
		nr_pages = 0
		if read in pages[key]:
			nr_pages = pages[key][read]
		pages_per_read.append(str(nr_pages))
	
	print(' '.join(pages_per_read))
	print()

February 23, 2026 12:00 AM

February 20, 2026

Mauricio Faria de Oliveira

page_owner Part 1: a quick introduction

This blog post is part of a series about the page_owner debug feature in the Linux memory management subsystem, related to the talk Improving page_owner for profiling and monitoring memory usage per allocation stack trace presented at Linux Plumbers Conference 2025.

What is page_owner?

In the Linux kernel, page_owner is a debug feature that tracks the memory allocation (and release) of pages in the system – so as to tell the ‘owner of a page’ ;-).

For each memory allocation, page_owner stores its order, GFP flags, stack trace, timestamp, command, process ID (PID) and thread-group ID (TGID), and more. It also stores some information when pages are freed (stack trace, timestamp, PID and TGID).

With page_owner, one can find out “What allocated this page?” and “How many pages are allocated by this particular stack trace, PID, or comm”, for example.

This is struct page_owner in Linux v6.19. It stores additional information per-page, as an extension of struct page with CONFIG_PAGE_EXTENSION.

struct page_owner {
 unsigned short order;
 short last_migrate_reason;
 gfp_t gfp_mask;
 depot_stack_handle_t handle;
 depot_stack_handle_t free_handle;
 u64 ts_nsec;
 u64 free_ts_nsec;
 char comm[TASK_COMM_LEN];
 pid_t pid;
 pid_t tgid;
 pid_t free_pid;
 pid_t free_tgid;
};

Usage

In order to use page_owner, build the kernel with CONFIG_PAGE_OWNER=y (see mm/Kconfig.debug) and boot the kernel with page_owner=on.

The debugfs file /sys/kernel/debug/page_owner provides the information in struct page_owner for every page, listed per PFN (page frame number).

This example shows the entry for a page (line continuation added for clarity) – it tells “What allocated this page?”:

# cat /sys/kernel/debug/page_owner
...
Page allocated via order 0, \
 mask 0xd2cc0(GFP_KERNEL|__GFP_NOWARN|__GFP_NORETRY|__GFP_COMP|__GFP_NOMEMALLOC), \
 pid 5640, tgid 5640 (stress-ng-brk), ts 414987114269 ns
PFN 0x114 type Unmovable Block 0 type Unmovable Flags 0x200(workingset|node=0|zone=0)
 get_page_from_freelist+0x1416/0x1600
 __alloc_frozen_pages_noprof+0x18c/0x1000
 alloc_pages_mpol+0x43/0x100
 new_slab+0x349/0x460
 ___slab_alloc+0x811/0xd90
 __kmem_cache_alloc_bulk+0xb8/0x1f0
 __prefill_sheaf_pfmemalloc+0x42/0x90
 kmem_cache_prefill_sheaf+0xa9/0x240
 mas_preallocate+0x32f/0x420
 __split_vma+0xdc/0x300
 vms_gather_munmap_vmas+0xa4/0x240
 do_vmi_align_munmap+0xe9/0x180
 do_vmi_munmap+0xcb/0x160
 __vm_munmap+0xa7/0x150
 __x64_sys_munmap+0x16/0x20
 do_syscall_64+0xa4/0x310

...

One can use tools/mm/page_owner_sort to process the information in the file, or come up with custom commands, scripts, or programs.

For example: calculate the total size of pages allocated by stress-ng-brk with any order, in MiB:

# COMM=stress-ng-brk
# cat /sys/kernel/debug/page_owner \
 | awk -F '[ ,]' \
 '/^Page allocated via order .* \('${COMM}'\)/ { PAGES+=2^$5 } 
 END { print PAGES*4096/2**20 " MiB" }'
0.0429688 MiB

More information about page_owner is available in Documentation/mm/page_owner.rst.

Problem: output size

In the page_owner file, note the significant amount of text that is produced per-page: 745 bytes, in the example above.

Considering a system with 1 GiB of RAM and 4 kB pages, fully allocated, with similarly sized entries per page, the output size might reach approximately 186 MiB! (745 [bytes/page] * (2**30 [bytes of RAM] / 4096 [bytes/page]) / 2**20 [bytes/MiB])

For validation, a test VM with 1 GiB of RAM after just a warm-up level of stress (stress-ng --sequential --timeout 1) produced 125 MiB, which was not quick to read even in idle state:

# time cat /sys/kernel/debug/page_owner \
 | wc --bytes | numfmt --to=iec
125M

real 0m3.009s
user 0m0.512s
sys 0m3.542s

While this might not be a serious issue for reading and processing the file only once, it can likely impact a sequence of operations.

Alternative: optimized output

Fortunately, another debugfs file, /sys/kernel/debug/page_owner_stacks/show_stacks, provides an optimized output for obtaining the memory usage per stack trace. Even though it doesn’t address all needs as the generic output, it resembles the default operation of page_owner_sort (without PFN lines) and provides an often interesting information for kernel development or analysis.

This example shows the entry for a stack trace – it tells “How many pages are allocated by this particular stack trace?

# cat /sys/kernel/debug/page_owner_stacks/show_stacks
...
 get_page_from_freelist+0x1416/0x1600
 __alloc_frozen_pages_noprof+0x18c/0x1000
 alloc_pages_mpol+0x43/0x100
 folio_alloc_noprof+0x56/0xa0
 page_cache_ra_unbounded+0xd9/0x230
 filemap_fault+0x305/0x1000
 __do_fault+0x2c/0xb0
 __handle_mm_fault+0x6f4/0xeb0
 handle_mm_fault+0xd9/0x210
 do_user_addr_fault+0x205/0x600
 exc_page_fault+0x61/0x130
 asm_exc_page_fault+0x26/0x30
nr_base_pages: 9643

...

The nr_base_pages field tells the number of base pages (i.e., not huge pages) allocated by a stack trace. So, this particular stack trace for readahead (page_cache_ra_unbounded()) has allocated approximately 37 MiB (9643 [pages] * 4096 [bytes/page] / 2**20 [ bytes/MiB]).

Note this file is more efficient for this particular purpose: just 402 KiB in less than 0.05 seconds. (That is 0.3% of the size and 1.7% of the time):

# time cat /sys/kernel/debug/page_owner_stacks/show_stacks \
 | wc --bytes | numfmt --to=iec
402K

real 0m0.042s
user 0m0.004s
sys 0m0.046s

Conclusion

The page_owner debug feature (enabled with CONFIG_PAGE_OWNER=y and page_owner=on) provides information about the memory allocation of pages in the system in debugfs files /sys/kernel/debug/page_owner with a generic format (dense description per-page) and /sys/kernel/debug/page_owner_stacks/show_stacks with an optimized format (number of base pages per stack trace).

February 20, 2026 12:00 AM

February 17, 2026

Alex Bradbury

Minipost: Additional figures for per-query energy consumption of LLMs

Last month I wrote up a fairly long piece on per-query energy consumption of LLMs using the data from InferenceMAX (note: InferenceMAX has since been renamed to InferenceX). Much of the write-up was dedicated to exploring what you can actually conclude from these figures and how that interacts with some of the implementation decisions in the benchmark, but I feel the results still give a useful yardstick. Beyond concerns about overly-specialised serving engine configurations and whether the workload is representative of real-world model serving in a paid API host, the other obvious limitation is that InferenceMAX is only testing GPT-OSS 120b and DeepSeek R1 0528 when there is a world of other models out there. I dutifully added "run my own tests using other models" to the todo list and here we are. By "here we are" I of course mean I made no progress towards that goal but Zach Mueller at Lambda started publishing model cards with the needed data - thanks Zach!

The setup for Lambda is simple - each model card lists the observed token generation throughput and total throughput (along with other stats) for an input sequence length / output sequence length (ISL/OSL) of 8192/1024, as benchmarked using vllm bench serve. The command used to serve the LLM (using sglang or vllm depending on the model) is also given. As a starting point this is no worse than the InferenceMAX data, and potentially somewhat better due to figures being taken from a configuration that's not overly specialised to a particular query length.

The figures each Lambda model card gives us that are relevant for calculating the energy per query are: the hardware used, token generation throughput and total token throughput (input+output tokens). Other statistics such as the time to first token, inter-token latency, and parallel requests tested help confirm whether this is a configuration someone would realistically use. Using an equivalent methodology to before, we get the Watt hours per query by:

  • Determining the total Watts for the GPU cluster. We take the figures used by SemiAnalysis (2.17kW for a single B200) and multiply by the number of GPUs.
  • Calculate the joules per token by dividing this total Watts figure by the total token throughput. This gives a weighted average of the joules per token for the measured workload, reflecting the ratio of isl:osl.
  • Multiply this weighted average of joules per token by the tokens per query (isl+osl) to get the joules per query. Then divide by 3600 to get Wh.

Collecting the data from the individual model cards we can generate the following (as before, using minutes of PlayStation 5 gameplay as a point of comparison):

data = {
    "Qwen/Qwen3.5-397B-A17B": {
        "num_b200": 8,
        "total_throughput": 11092,
    },
    "MiniMaxAI/MiniMax-M2.5": {
        "num_b200": 2,
        "total_throughput": 8062,
    },
    "zai-org/GLM-5-FP8": {
        "num_b200": 8,
        "total_throughput": 6300,
    },
    "zai-org/GLM-4.7-Flash": {
        "num_b200": 1,
        "total_throughput": 8125,
    },
    "arcee-ai/Trinity-Large-Preview": {
        "num_b200": 8,
        "total_throughput": 15611,
    },
}

# 8192 + 1024
TOKENS_PER_QUERY = 9216

# Taken from <https://inferencex.semianalysis.com/>
B200_KW = 2.17

# Reference power draw for PS5 playing a game. Taken from
# <https://www.playstation.com/en-gb/legal/ecodesign/> ("Active Power
# Consumption"). Ranges from ~217W to ~197W depending on model.
PS5_KW = 0.2


def wh_per_query(num_b200, total_throughput, tokens_per_query):
    total_cluster_kw = num_b200 * B200_KW
    total_cluster_watts = total_cluster_kw * 1000
    # joules_per_token is a weighted average for the measured mix of input
    # and output tokens.
    joules_per_token = total_cluster_watts / total_throughput
    joules_per_query = joules_per_token * tokens_per_query
    # Convert joules to watt-hours
    return joules_per_query / 3600.0

def ps5_minutes(wh):
    ps5_watts = PS5_KW * 1000
    return (wh / ps5_watts) * 60.0

MODEL_WIDTH = 31
WH_WIDTH = 8
PS5_WIDTH = 8

header = f"{'Model':<{MODEL_WIDTH}} | {'Wh/q':<{WH_WIDTH}} | {'PS5 min':<{PS5_WIDTH}}"
separator = f"{'-' * MODEL_WIDTH} | {'-' * WH_WIDTH} | {'-' * PS5_WIDTH}"

print(header)
print(separator)

for model, vals in data.items():
    wh = wh_per_query(vals["num_b200"], vals["total_throughput"], TOKENS_PER_QUERY)
    ps5_min = ps5_minutes(wh)

    wh_str = f"{wh:.2f}" if wh < 10 else f"{wh:.1f}"
    print(f"{model.strip():<{MODEL_WIDTH}} | {wh_str:<{WH_WIDTH}} | {ps5_min:.2f}")

This gives the following figures (reordered to show Wh per query in ascending order, and added a column for interactivity (1/TPOT)):

Model Intvty (tok/s) Wh/q PS5 min.
zai-org/GLM-4.7-Flash (bf16) 34.0 0.68 0.21
MiniMaxAI/MiniMax-M2.5 (fp8) 30.3 1.38 0.41
arcee-ai/Trinity-Large-Preview (bf16) 58.8 2.85 0.85
Qwen/Qwen3.5-397B-A17B (bf16) 41.7 4.01 1.20
zai-org/GLM-5-FP8 (fp8) 23.3 7.05 2.12

As a point of comparison, the most efficient 8 GPU deployment of fp8 DeepSeek R1 0528 from my figures in the previous article was 3.32 Wh per query.

And that's all I really have for today. Some interesting datapoints with hopefully more to come as Lambda puts up more model cards in this format. There's a range of interesting potential further experiments to do, but for now, I just wanted to share this initial look.


Article changelog
  • 2026-02-17: Initial publication date.

February 17, 2026 12:00 PM

February 11, 2026

Alex Bradbury

shandbox

shandbox is a simple Linux sandboxing script that serves my needs well. Perhaps it works for you too? No dependencies between a shell and util-linux (unshare and nsenter).

In short, it aims to provide fairly good isolation for personal files (i.e. your $HOME) while being very convenient for day to day use. It's designed to be run as an unprivileged user - as long as you can make new namespaces you should be good to go. By default /home/youruser/sandbox shows up as /home/sandbox within the sandbox, and other than standard paths like /usr, /etc, /tmp, and so on it's left for you to either copy things into the sandbox or expose them via a mount. There's a single shared sandbox (i.e. processes within the sandbox can see and interact with each other, and the exposed sandbox filesystem is shared as well), which trades off some ease of use for the security you might get with a larger number of more targeted sandboxes. On the other hand, you only gain security from a sandbox if you actually use it and this is a setup that offers very low friction for me. The network is not namespaced (although this is something you could change with a simple edit).

Usability is both subjective and highly dependent on your actual use case, so the tradeoffs may or may not align with what is interesting for you! Bubblewrap is an example of a mature alternative unprivileged sandboxing tool that offers a lot of configurability as well as options with greater degrees of sandboxing. Beyond that, look to Firecracker based solutions or gvisor. shandbox obviously aims to provide a reasonable sandbox as much as Linux namespaces alone are able to offer, but if you're looking for a security property stronger than "makes it harder for something to edit or access unwanted files" it's down to you to both carefully review its implementation and consider alternatives.

Usage example

$ shandbox run uvx pycowsay
Installed 1 package in 5ms

  ------------
< Hello, world >
  ------------
   \   ^__^
    \  (oo)\_______
       (__)\       )\/\
           ||----w |
           ||     ||
$ shandbox status
running (pid 1589364)

log:
  2026-02-11 13:02:51 stopped
  2026-02-11 13:05:06 started (pid 1589289)
$ shandbox add-mount ~/repos/llvm-project /home/sandbox/llvm-project
mounted /home/asb/repos/llvm-project -> /home/sandbox/llvm-project
$ shandbox run touch /home/sandbox/llvm-project/write-attempt
touch: cannot touch '/home/sandbox/llvm-project/write-attempt': Read-only file system
$ shandbox remove-mount /home/sandbox/llvm-project
unmounted /home/sandbox/llvm-project
$ shandbox add-mount --read-write ~/repos/llvm-project /home/sandbox/llvm-project
mounted /home/asb/repos/llvm-project -> /home/sandbox/llvm-project
$ shandbox run touch /home/sandbox/llvm-project/write-attempt

shandbox enter will open a shell within the sandbox for easy interactive usage. As a convenience, if the current working directory is in $HOME/sandbox (e.g. $HOME/sandbox/foo) then the working directory within the sandbox for shandbox run or shandbox enter will be set to the appropriate path within the sandbox (/home/sandbox/foo in this case). i.e., the case where this mapping is trivial. Environment variables are not passed through.

Functionality overview

  • shandbox start: Start the sandbox, creating the necessary namespaces and mount layout. Fails if the sandbox is already running.
  • shandbox stop: Stop the sandbox by killing the process holding the namespaces. Fails if the sandbox is not running.
  • shandbox restart: Stop the sandbox and start it again.
  • shandbox status: Print whether the sandbox is running and if it is, the pid. Also print the last 20 lines of the log.
  • shandbox enter: Open bash within the sandbox, starting the sandbox first if it's not already running.
  • shandbox run <command> [args...]: Run a command inside the sandbox. The current working directory is translated to an in-sandbox path if it falls within the sandbox home directory. Starts the sandbox first if it isn't already running.
  • shandbox add-mount [--read-write] <host-path> <sandbox-path>: Bind-mount a host path into the running sandbox. Mounts are read-only by default; pass --read-write to allow writes. The sandbox must already be running. Both directories and individual files are supported.
  • shandbox remove-mount <sandbox-path>: Remove a previously added bind mount from the running sandbox.

Implementation approach

The core sandboxing functionality is provided by the Linux namespaces functionality exposed by unshare and nsenter. The script's implementation should be quite readable but I'll try to summarise some key points here.

The goal is that:

  • Within the sandbox, you appear as an unprivileged user, with uid and gid equal to your usual Linux user.
  • It should be possible to expose additional files or directories to the sandbox once it's running.
  • Applications running within the sandbox have no way (modulo bugs or vulnerabilities in the kernel or accessible applications) of reaching files on the host filesystem that aren't explicitly exposed.
    • To underline: This is a goal, it is not a guarantee.
  • It's possible to launch multiple processes within the sandbox which can all see each other, and have the same shared sandboxed filesystem.
  • This is all doable as an unprivileged user.

To implement that:

  • Two sets of namespaces are used to provide this isolation: the outer 'shandbox_root' has the user mapped to root within the namespace and retains access to standard / (allowing us to mount additional paths into after the sandbox has started). The inner 'shandbox_user' represents a new user namepsace mapping our uid/gid to an unprivileged user, but other namespaces are shared with 'shandbox_root'. Sandboxed processes are launched within the namespaces of 'shandbox_user'.
  • The process IDs of the initial process within 'sandbox_root' and 'sandbox_user' are saved and recalled so the script can use nsenter to enter the namespace.
  • To help make it easier to tell when you're in the sandbox, a dummy /etc/passwd is bind-mounted naming the current user as sandbox.
  • When shandbox start is executed, the necessary directories are bind mounted in a directory that will be used as root (/) for the user sandbox in .local/share/shandbox/root. This happens within the sandbox_root namespace, which then uses unshare again to create a new user namespace with an unprivileged user, executing within a chroot.
  • 'sandbox_root' retains access to the host filesystem, which is necessary to allow mounting additional paths after the fact. Without this requirement, we could likely rewrite shandbox start to use pivot_root.

Making it your own

The script should be straight-forward enough to customise to your needs if they're not too dissimilar to what is offered out of the box. Some variables at the top provide things you may be more likely to want to change, such as the home directory location, and a list of files or directories in $HOME to always bind-mount into the sandbox home:

SANDBOX_HOME_DIR="$HOME/sandbox"
HOME_FILES_TO_MAP=".bashrc .vimrc"
HOME_DIRS_TO_MAP=".vim bin"
SB_HOME="/home/sandbox"
SB_PATH="$SB_HOME/bin:/usr/local/bin:/usr/bin"

Article changelog
  • 2026-02-11: Initial publication date.

February 11, 2026 12:00 PM

February 10, 2026

José Dapena

Container Timing: measuring web components performance

Over the last year, as part of the collaboration between Igalia and Bloomberg to improve web performance observability, I worked on a new web performance API: Container Timing. This standard aims to make component-level performance measurement as easy as page-level metrics like LCP and FCP.

My focus has been writing the native implementation in Chromium, which is now available behind a feature flag.

In this post, I will explain why this API is needed, how it works, and how you can experiment with it today. In a follow-up post, I will dive deep into the implementation details within the Blink rendering engine.

The problem: measuring component performance #

We currently use Largest Contentful Paint (LCP) and First Contentful Paint (FCP) to measure web page loading performance. Both metrics are page-scoped, meaning they evaluate the user perceived load speed for full page.

The Element Timing API shifts the focus to individual DOM elements. By targetting specific elements, like hero images or a headers, we can measure their specific rendering performance independent of the rest of the page.

However, modern web development is component-based. Developers build complex widgets (as grids, charts, feeds or panels) that are made of many elements. It is not trivial to understand the performance of those components:

  • LCP may not be useful as another large image painting could delay it.
  • Measuring a web component with Element Timing may require instrumenting all the significant elements one by one.

A representation of a news web page, where the scope of LCP is the full web page, and Element Timing is a specific element, but we want to measure the latest news feed widget.

The solution: Container Timing #

This is where Container Timing comes in! With the new specification, a web developer can mark subtrees of the DOM as “containers”. Then, it provides performance entries aggregating the painting time of that subtree.

A representation of a news web page, where aggregating the paints of the children of the news feed widget allows to know when its painting has finished.

This way, we can answer: “when did a specific component finish painting its content?”.

Some examples:

  • Breaking down the contributors to the initial page load: with Container Timing we can focus on the components that are more relevant to the user experience.
  • Single page application navigation: when a soft navigation shows a new component on the screen, we can obtain painting information for it.
  • Lazy-loaded components: Tracking when a widget that loads below the fold is fully visible.
  • Third-party content: Monitoring the performance of ads or embedded widgets.

You just need to add, to the top element of the subtree, the new attribute containertiming. When you add it to an HTML element, the browser will track all the painting updates of that element and its descendants.

What happens under the hood? The browser will start monitoring the rendering pipeline for paints that contribute to representing the subtree. When a new frame is painted, if that paints new areas for that subtree, it reports a performance entry showing the increase in painted area. It is similar to LCP, but for a specific subtree!

How to use Container Timing? #

Using the API is straightforward. First, mark the containers you want to track in HTML:

<div id="my-widget" containertiming="widget-load">
<img src="graph.png" />
<p>Loading data...</p>
</div>

Then, use a PerformanceObserver to listen for container entries:

const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
console.log(`Container '${entry.identifier}' painted.`);
console.log(`Time: ${entry.startTime}`);
console.log(`Size: ${entry.size}`); // The area painted
});
});

observer.observe({ type: "container", buffered: true });

When the web contents load, new Performance entries will be emitted with the container updates.

Which entry will be interesting? The API lets you choose what best fits your needs! Some ideas:

  • The most important entry could be the last one: the one that increased the painted area for the last time. Something similar to LCP.
  • Or maybe the last one that contributed a significant size increase?
  • Or the last one before a user interaction?

A native implementation for Chromium #

In the initial steps of the specification, Jason Williams wrote a polyfill that worked on top of Element Timing. This was very useful to understand and polish the kind of information the specification could provide. However, this had its own performance impact.

Deprecation Notice:

The polyfill is now deprecated and no longer maintained, as the native API cannot be fully replicated using Element Timing. Please use the native implementation for accurate results.

So I started a native implementation in Chromium. The main idea was working on top of the already existing implementation for Element Timing, and add the remaining bits.

In my next blog post I will go through the implementation details. But, for this post, it is relevant to state that the goals of this native implementation were:

  • Minimizing the overhead. It should be almost zero when elements are not interesting to Container Timing, and very fast and light when paints were relevant.
  • It should reuse as much as possible of the already existing logic for Element Timing.

The native implementation has landed and is available in Chromium144+, but still behind the ContainerTiming feature flag.

You can experiment with this feature locally by passing the following flag to Chromium at startup:

chrome --enable-blink-features=ContainerTiming

Or you can just enable the “Experimental Web Platform features” in chrome://flags.

Upcoming trials #

So now, it is time to collect feedback from the actual web developers.

We have already presented the specification in several conferences (as BlinkOn 20 or Performance.now() 2024). And discussions are ongoing in the Web Performance Working Group.

We just announced the Dev Trial in the blink-dev mailing list! The feature is now officially ready for testing.

What’s next? We are also preparing an Origin Trial, that will allow developers to test the specification in production for a subset of their users.

If you want to provide feedback, we are collecting it in the explainer ticket tracker.

Wrapping up #

With Container Timing, you will be able to measure paintings at the web component level, filling a significant gap in the web performance monitoring landscape.

If you struggled with finding out the ready time of your widgets, just try it! It is available, under the feature flags ContainerTiming, in Chromium Stable today.

And stay tuned! In a follow up post, I will go through the native implementation details in Chromium.

Thanks! #

This has been done as part of the collaboration between Bloomberg and Igalia. Thanks!

Igalia Bloomberg

References #

February 10, 2026 12:00 AM

February 09, 2026

Igalia WebKit Team

WebKit Igalia Periodical #56

Update on what happened in WebKit in the week from February 2 to February 9.

The main event this week was FOSDEM (pun intended), which included presentations related to WebKit; but also we got a batch of stable and development releases, asynchronous scrolling work, OpenGL logging, cleanups, and improving the inspector for the WPE work.

Cross-Port 🐱

Graphics 🖼️

While asynchronous scrolling for mouse wheel events was already supported, scrollbar layers were still being painted on the main thread. This has been changed to paint scrollbars on the scrolling thread instead, which avoids scrollbars to “lag” behind scrolled content.

Fixed flickering caused by the combination of damage tracking and asynchronous scrolling for mouse wheel events.

It is now possible to enable debug logging for OpenGL contexts using the new GLContext log channel, which takes advantage of the message events produced by the widespread KHR_debug extension.

Figuring out the exact location inside WebKit that triggered an OpenGL issue may still be challenging with this aid, and therefore a backtrace will be appended in case of errors to help pinpoint the source, when the log channel is enabled at the “debug” level with GLContext=debug.

Configuring the build with USE_SKIA=OFF to make WebKit use the Cairo graphics library is no longer supported. Using Skia has been the default since late 2024, and after two full years the 2.54.0 release (due in September 2026) will be the first one where the choice is no longer possible.

WebKitGTK 🖥️

The “on demand” hardware acceleration policy has been rarely used lately, and thus support for it has been removed. Note that this affects only the GTK port when built with GTK 3—the option never existed when using GTK 4.

Existing GTK 3 applications that use WEBKIT_HARDWARE_ACCELERATION_POLICY_ON_DEMAND will continue to work and do not need rebuilding: they will be promoted to use the “always enabled” policy starting with WebKitGTK 2.54.0 (due in September 2026).

WPE WebKit 📟

The Web Inspector has received support for saving data to local files, allowing things such as saving page resources or exporting the network session to a HAR archive.

Note that using the Web Inspector locally is supported when using the WPEPlatform API, and the keyboard shortcut Ctrl+Shift+I may be used to bring it up.

Releases 📦️

WebKitGTK 2.50.5 and WPE WebKit 2.50.5 have been released. These are stable maintenance releases that improves stability, correct bugs, and fixes small rendering issues.

The second release candidates for the upcoming stable branch, WebKitGTK 2.51.91 and WPE WebKit 2.51.91, have been published as well. Those using those to preview the upcoming 2.52.x series are encouraged to provide bug reports in Bugzilla for any issue they may experience.

Community & Events 🤝

We have published a blog post on our work implementing the Temporal proposal in JavaScriptCore, WebKit's JavaScript engine.

This year's edition of FOSDEM took place in Brussels between January 31st and February 1st, and featured a number of sessions related to WebKitGTK and WPE:

The videos for the talks are already available, too.

That’s all for this week!

by Igalia WebKit Team at February 09, 2026 11:21 PM

Andy Wingo

six thoughts on generating c

So I work in compilers, which means that I write programs that translate programs to programs. Sometimes you will want to target a language at a higher level than just, like, assembler, and oftentimes C is that language. Generating C is less fraught than writing C by hand, as the generator can often avoid the undefined-behavior pitfalls that one has to be so careful about when writing C by hand. Still, I have found some patterns that help me get good results.

Today’s note is a quick summary of things that work for me. I won’t be so vain as to call them “best practices”, but they are my practices, and you can have them too if you like.

static inline functions enable data abstraction

When I learned C, in the early days of GStreamer (oh bless its heart it still has the same web page!), we used lots of preprocessor macros. Mostly we got the message over time that many macro uses should have been inline functions; macros are for token-pasting and generating names, not for data access or other implementation.

But what I did not appreciate until much later was that always-inline functions remove any possible performance penalty for data abstractions. For example, in Wastrel, I can describe a bounded range of WebAssembly memory via a memory struct, and an access to that memory in another struct:

struct memory { uintptr_t base; uint64_t size; };
struct access { uint32_t addr; uint32_t len; };

And then if I want a writable pointer to that memory, I can do so:

#define static_inline \
  static inline __attribute__((always_inline))

static_inline void* write_ptr(struct memory m, struct access a) {
  BOUNDS_CHECK(m, a);
  char *base = __builtin_assume_aligned((char *) m.base_addr, 4096);
  return (void *) (base + a.addr);
}

(Wastrel usually omits any code for BOUNDS_CHECK, and just relies on memory being mapped into a PROT_NONE region of an appropriate size. We use a macro there because if the bounds check fails and kills the process, it’s nice to be able to use __FILE__ and __LINE__.)

Regardless of whether explicit bounds checks are enabled, the static_inline attribute ensures that the abstraction cost is entirely burned away; and in the case where bounds checks are elided, we don’t need the size of the memory or the len of the access, so they won’t be allocated at all.

If write_ptr wasn’t static_inline, I would be a little worried that somewhere one of these struct values would get passed through memory. This is mostly a concern with functions that return structs by value; whereas in e.g. AArch64, returning a struct memory would use the same registers that a call to void (*)(struct memory) would use for the argument, the SYS-V x64 ABI only allocates two general-purpose registers to be used for return values. I would mostly prefer to not think about this flavor of bottleneck, and that is what static inline functions do for me.

avoid implicit integer conversions

C has an odd set of default integer conversions, for example promoting uint8_t to signed int, and also has weird boundary conditions for signed integers. When generating C, we should probably sidestep these rules and instead be explicit: define static inline u8_to_u32, s16_to_s32, etc conversion functions, and turn on -Wconversion.

Using static inline cast functions also allows the generated code to assert that operands are of a particular type. Ideally, you end up in a situation where all casts are in your helper functions, and no cast is in generated code.

wrap raw pointers and integers with intent

Whippet is a garbage collector written in C. A garbage collector cuts across all data abstractions: objects are sometimes viewed as absolute addresses, or ranges in a paged space, or offsets from the beginning of an aligned region, and so on. If you represent all of these concepts with size_t or uintptr_t or whatever, you’re going to have a bad time. So Whippet has struct gc_ref, struct gc_edge, and the like: single-member structs whose purpose it is to avoid confusion by partitioning sets of applicable operations. A gc_edge_address call will never apply to a struct gc_ref, and so on for other types and operations.

This is a great pattern for hand-written code, but it’s particularly powerful for compilers: you will often end up compiling a term of a known type or kind and you would like to avoid mistakes in the residualized C.

For example, when compiling WebAssembly, consider struct.set‘s operational semantics: the textual rendering states, “Assert: Due to validation, val is some ref.struct structaddr.” Wouldn’t it be nice if this assertion could translate to C? Well in this case it can: with single-inheritance subtyping (as WebAssembly has), you can make a forest of pointer subtypes:

typedef struct anyref { uintptr_t value; } anyref;
typedef struct eqref { anyref p; } eqref;
typedef struct i31ref { eqref p; } i31ref;
typedef struct arrayref { eqref p; } arrayref;
typedef struct structref { eqref p; } structref;

So for a (type $type_0 (struct (mut f64))), I might generate:

typedef struct type_0ref { structref p; } type_0ref;

Then if I generate a field setter for $type_0, I make it take a type_0ref:

static inline void
type_0_set_field_0(type_0ref obj, double val) {
  ...
}

In this way the types carry through from source to target language. There is a similar type forest for the actual object representations:

typedef struct wasm_any { uintptr_t type_tag; } wasm_any;
typedef struct wasm_struct { wasm_any p; } wasm_struct;
typedef struct type_0 { wasm_struct p; double field_0; } type_0;
...

And we generate little cast routines to go back and forth between type_0ref and type_0* as needed. There is no overhead because all routines are static inline, and we get pointer subtyping for free: if a struct.set $type_0 0 instruction is passed a subtype of $type_0, the compiler can generate an upcast that type-checks.

fear not memcpy

In WebAssembly, accesses to linear memory are not necessarily aligned, so we can’t just cast an address to (say) int32_t* and dereference. Instead we memcpy(&i32, addr, sizeof(int32_t)), and trust the compiler to just emit an unaligned load if it can (and it can). No need for more words here!

for ABI and tail calls, perform manual register allocation

So, GCC finally has __attribute__((musttail)): praise be. However, when compiling WebAssembly, it could be that you end up compiling a function with, like 30 arguments, or 30 return values; I don’t trust a C compiler to reliably shuffle between different stack argument needs at tail calls to or from such a function. It could even refuse to compile a file if it can’t meet its musttail obligations; not a good characteristic for a target language.

Really you would like it if all function parameters were allocated to registers. You can ensure this is the case if, say, you only pass the first n values in registers, and then pass the rest in global variables. You don’t need to pass them on a stack, because you can make the callee load them back to locals as part of the prologue.

What’s fun about this is that it also neatly enables multiple return values when compiling to C: simply go through the set of function types used in your program, allocate enough global variables of the right types to store all return values, and make a function epilogue store any “excess” return values—those beyond the first return value, if any—in global variables, and have callers reload those values right after calls.

what’s not to like

Generating C is a local optimum: you get the industrial-strength instruction selection and register allocation of GCC or Clang, you don’t have to implement many peephole-style optimizations, and you get to link to to possibly-inlinable C runtime routines. It’s hard to improve over this design point in a marginal way.

There are drawbacks, of course. As a Schemer, my largest source of annoyance is that I don’t have control of the stack: I don’t know how much stack a given function will need, nor can I extend the stack of my program in any reasonable way. I can’t iterate the stack to precisely enumerate embedded pointers (but perhaps that’s fine). I certainly can’t slice a stack to capture a delimited continuation.

The other major irritation is about side tables: one would like to be able to implement so-called zero-cost exceptions, but without support from the compiler and toolchain, it’s impossible.

And finally, source-level debugging is gnarly. You would like to be able to embed DWARF information corresponding to the code you residualize; I don’t know how to do that when generating C.

(Why not Rust, you ask? Of course you are asking that. For what it is worth, I have found that lifetimes are a frontend issue; if I had a source language with explicit lifetimes, I would consider producing Rust, as I could machine-check that the output has the same guarantees as the input. Likewise if I were using a Rust standard library. But if you are compiling from a language without fancy lifetimes, I don’t know what you would get from Rust: fewer implicit conversions, yes, but less mature tail call support, longer compile times... it’s a wash, I think.)

Oh well. Nothing is perfect, and it’s best to go into things with your eyes wide open. If you got down to here, I hope these notes help you in your generations. For me, once my generated C type-checked, it worked: very little debugging has been necessary. Hacking is not always like this, but I’ll take it when it comes. Until next time, happy hacking!

by Andy Wingo at February 09, 2026 01:47 PM

February 06, 2026

Andy Wingo

ahead-of-time wasm gc in wastrel

Hello friends! Today, a quick note: the Wastrel ahead-of-time WebAssembly compiler now supports managed memory via garbage collection!

hello, world

The quickest demo I have is that you should check out and build wastrel itself:

git clone https://codeberg.org/andywingo/wastrel
cd wastrel
guix shell
# alternately: sudo apt install guile-3.0 guile-3.0-dev \
#    pkg-config gcc automake autoconf make
autoreconf -vif && ./configure
make -j

Then run a quick check with hello, world:

$ ./pre-inst-env wastrel examples/simple-string.wat
Hello, world!

Now give a check to gcbench, a classic GC micro-benchmark:

$ WASTREL_PRINT_STATS=1 ./pre-inst-env wastrel examples/gcbench.wat
Garbage Collector Test
 Creating long-lived binary tree of depth 16
 Creating a long-lived array of 500000 doubles
Creating 33824 trees of depth 4
	Top-down construction: 10.189 msec
	Bottom-up construction: 8.629 msec
Creating 8256 trees of depth 6
	Top-down construction: 8.075 msec
	Bottom-up construction: 8.754 msec
Creating 2052 trees of depth 8
	Top-down construction: 7.980 msec
	Bottom-up construction: 8.030 msec
Creating 512 trees of depth 10
	Top-down construction: 7.719 msec
	Bottom-up construction: 9.631 msec
Creating 128 trees of depth 12
	Top-down construction: 11.084 msec
	Bottom-up construction: 9.315 msec
Creating 32 trees of depth 14
	Top-down construction: 9.023 msec
	Bottom-up construction: 20.670 msec
Creating 8 trees of depth 16
	Top-down construction: 9.212 msec
	Bottom-up construction: 9.002 msec
Completed 32 major collections (0 minor).
138.673 ms total time (12.603 stopped); 209.372 ms CPU time (83.327 stopped).
0.368 ms median pause time, 0.512 p95, 0.800 max.
Heap size is 26.739 MB (max 26.739 MB); peak live data 5.548 MB.

We set WASTREL_PRINT_STATS=1 to get those last 4 lines. So, this is a microbenchmark: it runs for only 138 ms, and the heap is tiny (26.7 MB). It does collect 30 times, which is something.

is it good?

I know what you are thinking: OK, it’s a microbenchmark, but can it tell us anything about how Wastrel compares to V8? Well, probably so:

$ guix shell node time -- \
   time node js-runtime/run.js -- \
     js-runtime/wtf8.wasm examples/gcbench.wasm
Garbage Collector Test
[... some output elided ...]
total_heap_size: 48082944
[...]
0.23user 0.03system 0:00.20elapsed 128%CPU (0avgtext+0avgdata 87844maxresident)k
0inputs+0outputs (0major+13325minor)pagefaults 0swaps

Which is to say, V8 takes more CPU time (230ms vs 209ms) and more wall-clock time (200ms vs 138ms). Also it uses twice as much managed memory (48 MB vs 26.7 MB), and more than that for the total process (88 MB vs 34 MB, not shown).

improving on v8, really?

Let’s try with quads, which at least has a larger active heap size. This time we’ll compile a binary and then run it:

$ ./pre-inst-env wastrel compile -o quads examples/quads.wat
$ WASTREL_PRINT_STATS=1 guix shell time -- time ./quads 
Making quad tree of depth 10 (1398101 nodes).
	construction: 23.274 msec
Allocating garbage tree of depth 9 (349525 nodes), 60 times, validating live tree each time.
	allocation loop: 826.310 msec
	quads test: 860.018 msec
Completed 26 major collections (0 minor).
848.825 ms total time (85.533 stopped); 1349.199 ms CPU time (585.936 stopped).
3.456 ms median pause time, 3.840 p95, 5.888 max.
Heap size is 133.333 MB (max 133.333 MB); peak live data 82.416 MB.
1.35user 0.01system 0:00.86elapsed 157%CPU (0avgtext+0avgdata 141496maxresident)k
0inputs+0outputs (0major+231minor)pagefaults 0swaps

Compare to V8 via node:

$ guix shell node time -- time node js-runtime/run.js -- js-runtime/wtf8.wasm examples/quads.wasm
Making quad tree of depth 10 (1398101 nodes).
	construction: 64.524 msec
Allocating garbage tree of depth 9 (349525 nodes), 60 times, validating live tree each time.
	allocation loop: 2288.092 msec
	quads test: 2394.361 msec
total_heap_size: 156798976
[...]
3.74user 0.24system 0:02.46elapsed 161%CPU (0avgtext+0avgdata 382992maxresident)k
0inputs+0outputs (0major+87866minor)pagefaults 0swaps

Which is to say, wastrel is almost three times as fast, while using almost three times less memory: 2460ms (v8) vs 849ms (wastrel), and 383MB vs 141 MB.

zowee!

So, yes, the V8 times include the time to compile the wasm module on the fly. No idea what is going on with tiering, either, but I understand that tiering up is a thing these days; this is node v22.14, released about a year ago, for what that’s worth. Also, there is a V8-specific module to do some impedance-matching with regards to strings; in Wastrel they are WTF-8 byte arrays, whereas in Node they are JS strings. But it’s not a string benchmark, so I doubt that’s a significant factor.

I think the performance edge comes in having the program ahead-of-time: you can statically allocate type checks, statically allocate object shapes, and the compiler can see through it all. But I don’t really know yet, as I just got everything working this week.

Wastrel with GC is demo-quality, thus far. If you’re interested in the back-story and the making-of, see my intro to Wastrel article from October, or the FOSDEM talk from last week:

Slides here, if that’s your thing.

More to share on this next week, but for now I just wanted to get the word out. Happy hacking and have a nice weekend!

by Andy Wingo at February 06, 2026 03:48 PM

Igalia Compilers Team

Igalia’s Compilers Team - A 2025 Retrospective

Hey, hey, it’s the beginning of a new year and before we sprint too far into 2026, let’s take a quick breather, zoom out, and celebrate what Igalia’s awesome compilers team got up to in 2025. Over the past year we’ve been deeply involved in shaping and shipping key Web and JavaScript standards, which includes not just participating in committees but also chairing and actively moving the proposals forward. We worked on major JavaScript runtimes and foundational ahead-of-time compilers including LLVM and Mesa, as well as JIT CPU emulation, and smaller language VMs.

Some big highlights of this year included our work on FEX and Mesa that helped Valve with their upcomimg gaming devices - the Steam Frame and the Steam Machine (we talk more about this in a dedicated blog post), our continued involvement in supporting RISC-V in contemporary compilers, and our key role in multiple WebAssembly implementations.

Standards #

In 2025, our standards work focused on parts of JavaScript developers touch every day like time, numbers, modules and more. Across TC39, WHATWG, WinterTC and internationalization ecosystems, we helped move proposals forward while turning specifications into running, interoperable code. So yep, let’s talk about our most significant standards contributions from the year!

Temporal #

It’s been an exciting year for the Temporal proposal, which adds a modern date-and-time API to JavaScript. For starters, MDN published their API documentation for it, which created a huge surge of interest.

On the shipping front: Firefox shipped their implementation of the proposal and it’s now available in Firefox 139. Chrome moved their implementation to beta in late 2025, and released it in early 2026. Meanwhile, we’ve been steadily working on getting Temporal into Safari, with support for correct duration math and the PlainMonthDay and PlainYearMonth types added during 2025/early 2026. You can read more about this in our recent post on implementing Temporal.

Alongside that, we’ve been working on the Intl Era and Month Code proposal, which has expanded in scope beyond era codes and month codes to cover other calendar-specific things that a JS engine with Intl must implement. This allows developers to make use of a number of commonly-used non-Gregorian calendars, including but not limited to the calendar used in Thailand, the Japanese Imperial calendar, and Islamic calendars.

Decimal #

A lot of our recent work around the Decimal proposal has now migrated to a newer similarly number-focused effort called Amount (formerly known as "Measure" and officially renamed in 2025). The proposal reached Stage 1 at the November 2024 TC39 plenary. We also launched a polyfill. Since then, we have iterated on the Amount API and data model a number of times in plenary. So while it started 2025 at stage 1 and remains at stage 1 heading into 2026, the design is noticeably sharper, thanks to a lot of TC39 discussions. We’re lined up to keep it pushing forward next year.

And because numerics work benefits a ton from regular iteration, in late 2024, we also kicked off a biweekly community call ("JS Numerics") for those in TC39 interested in proposals related to numbers, such as Decimal, Amount, intl-keep-trailing-zeros, etc. We still host it, and it’s turned out to be a genuinely productive place to hash things out without waiting for plenary.

Source Maps #

We implemented draft range mappings implementations on a number of systems: WebKit, Justin Ridgewell’s source map decoder, a source map validator, and more.

We also facilitated source map TG4 meetings and assisted with advancing proposals such as the scopes proposal. Throughout the year, we continued serving as editors for the ECMA-426 specification, landing a steady stream of improvements and clarifications.

Modules #

We pushed JavaScript’s module system forward on multiple fronts, especially around reducing the impact of modules on application startup:

  • we advanced the import defer proposal, which allows modules to be be synchronously lazily evaluated, to Stage 3 in TC39. We are working on its implementations in V8 and WebKit, and we implemented it in Babel, webpack (together with other community members) and TypeScript.
  • we presented export defer and pushed it to Stage 2 in TC39: it allows more granular lazy evaluation, as well as built-in browser support for tree-shaking of re-exports.

We are among the most active members of the "Modules Harmony" group, an unofficial group within TC39 that aims at improving the capabilities of ESM to improve native adoption, while making sure that all modules proposals are well-coordinated with each other.

AsyncContext #

And over in the AsyncContext proposal world, we spent 2025 focusing on how the proposal should integrate with various web APIs. The way AsyncContext interacts with the web platform is unusually pervasive, and more challenging to figure out than the core TC39 proposal itself.

In a first for a TC39 proposal, it is not also going through the WHATWG stages process, where it has reached Stage 1. This gives us a clearer path to iterate with direct feedback from browser engines.

Unicode standards #

We have been working on Unicode MessageFormat, which is a Unicode standard for localizable dynamic message strings, designed to make it simple to create natural sounding localized messages.

In 2025, we helped the ICU4C implementation of Unicode MessageFormat align with ongoing specification changes. We also carried out experimental work on the custom function interface to support more extensible formatting formatting capabilities, which is currently under review.

WinterTC #

In December 2024, WinterTC was formed to replace WinterCG as an official ECMA Techincal committee to achieve some level of API interoperability across server-side JavaScript runtimes, especially for APIs that are common with the web.

We started chairing (together folks from Deno), and became involved in admin tasks. Over the course of the year, we:

  • Identified a core set of Web APIs that should be shared across runtimes and standardized it as the Minimum Common Web API specification, which was officially published at the ECMA General Assembly in December.
  • Started identifying a subset of the WPT test suite that covers the Minimum Common Web API, and made some headway towards clarifying which parts of the Fetch specification server-side runtimes should support, and which they shouldn’t.

Additionally, if you’re curious, we gave two talks about WinterTC: one at the Web Engines Hackfest together with Deno folks, the other chair of WinterTC; and one at JSConf.JP.

Node.js #

In Node.js, our work in 2025 spanned interoperability, proxy integration, and adding support for HTTP/HTTPS proxy and shipping integration of System CA certificates across platforms.

On the module side, we delivered interoperability features and bug fixes for require(esm) and helped stabilize it (read more about it in our colleague Joyee’s blog), shipped synchronous and universal loader hooks (now promoted to release candidate), integrated TypeScript into the compile cache, and improved the portability of the cache. Check out Joyee’s talk at JSConf JP if you are interested in learning more about these new module loader features.

We also strengthened System CA certificate integration along with JavaScript APIs for reading and configuring trusted CAs globally, adding built-in HTTP/HTTPS proxy support, and expanding documentation for using Node.js in enterprise environments.

Additionally, we started migration to the new V8 CppHeap model in Node.js and improved its V8 Platform integration.

V8 #

On the V8 side of things, we worked on HeapProfiler::QueryHolders, a companion API to the QueryObjects API.

We worked on extending the HeapStatistics API to include a new field that tracks the total of bytes allocated in an Isolate since its creation. This counter excludes allocations that happen due to GC operations and it’s intended to be used to create memory regression tests. Here’s the CL highlighting these changes.

We also started working on implementation of the import defer proposal on V8. This proposal extends the syntax of ESM imports to allow a mode where the evaluation of an imported module is deferred until its first access. From our work in Node.js, we upstreamed a few improvements and bug fixes in V8’s embedder API and startup snapshot implementation. We also contributed to Node.js’s V8 upgrade and upstreamed patches to address issues discovered in the upgrade.

As part of our collaboration with Cloudflare we added v8::IsolateGroup: a new unit that owns an independent pointer-compression cage. We then also enabled multiple cages per process (“multi-cage”), so thousands of isolates aren’t forced into one < 4 GiB region. Finally, we extended this to multiple sandboxes: one sandbox per isolate group instead of a single process-wide sandbox. In the end this work helped Cloudflare to enable the sandbox in Cloudflare workers.

Babel #

Our team also helps co-maintianing Babel. The build tools area is very active nowdays, and we strongly believe that alongside the innovation happening in the ecosystem companies need to invest on ensuring that the older and widely used tools keep being actively maintained and improving over time.

LLVM #

In LLVM, we helped extend auto-vectorization to take full advantage of the RISC-V vector extension’s many innovative features.

After four years of development by contributors from multiple organizations including Igalia, we finally enabled EVL tail folding for RISC-V as an LLVM default.

This work took advantage of the new VPlan infrastructure, extending it and developing it iteratively in-tree when needed to give us the ability to model a relatively complex vectorization scheme.

We also added full scalable segmented access support and taught the loop vectorizer to make smarter cost model decisions.

Building on top of this, we achieved improvements in RISC-V vectorization. In parallel, we also worked on LLVM scheduling models for the SpacemiT-x60 RISC-V processor, scoring a whopping 16% performance improvement.

Regarding WebAssembly in LLVM we landed a number of commits that improve size and performance of generated code, and added support for a few ISD nodes that enable vectorization for otherwise sequential codegen.

Mesa/IR3 #

We continued work on improving IR3, the Mesa compiler backend for Qualcomm Adreno GPUs. We implemented support for alias instructions novel to the a7xx generation of GPUs, significantly improving register pressure for texture instructions. We also refactored the post-RA scheduler to be able to reuse the legalization logic, significantly improving its accuracy when calculating instruction delays and, consequently, reducing latency.

We also added debug tooling to easily identify the shader that causes problems, among many other optimizations, implementations of new instructions, and bug fixes.

Guile and Whippet #

This year we also made some interesting progress on Whippet, a no-dependencies embeddable garbage collector. We were able to integrate Whippet into the Guile Scheme implementation, replacing Guile’s use of the venerable Boehm-Demers-Weiser library. We hope to merge the integration branch upstream over the next months. We also wrote up a paper describing the innards of some of Whippet’s algorithms.

We think Whippet is interesting whereever a programming language needs a garbage collector: it’s customizable and easy to manage, as it is designed to be "vendored" directly into a user’s source code repository. We are now in the phase of building out examples to allow for proper performance evaluation; after a bespoke Scheme implementation and Guile itself, we also wrote a fresh ahead-of-time compiler for WebAssembly, which in the near future will gain support for the garbage collection WebAssembly extensions, thanks to Whippet. For more info on our progress, check out Andy Wingo’s blog series.

FEX #

For the FEX x86 JIT emulator for ARM64, we worked on X87 Floating-Point Emulation, implemented x87 invalid operation bit handling in F80 mode, fixed IEEE 754 unordered comparison detection, and added f80 stack xchg optimization for fast path.

Besides further fixes for instruction implementations, we also worked on memory and stability improvements, protecting the last page of CodeBuffer, and implementing gradual memory growth. Finally, we also did some infrastructure work by upgrading the codebase to clang-format-19 and adding UBSAN support.

This year’s FEX work focused on x87 floating-point correctness and 32-bit compatibility—both critical for Valve’s Steam Frame, the ARM-powered VR headset they announced in November that uses FEX to run x86 games.

The x87 improvements matter because many games and middleware still use legacy floating-point code. Subtle deviations from Intel’s behavior—wrong exception flags, incorrect comparison semantics—cause crashes or weird behavior. Fixing invalid operation exceptions, IEEE 754 comparisons, and optimizing the x87 stack pass eliminated entire classes of compatibility bugs.

The 32-bit fixes are just as important. A huge chunk of Steam’s catalog is still 32-bit, and even 64-bit games often ship 32-bit launchers. Getting fcntl and addressing modes right means these games just work without users needing to do anything.

In total, this work gave Valve confidence that the Steam Frame could ship with solid library coverage, letting them announce the device on schedule.


Alright, that’s a wrap on our 2025 retrospective! We hope you had as much fun reading it as we had writing it, and building all the things we talked about along the way. We’ll see you next year with another roundup; until then, you can keep up with our latest work on the team blog.

February 06, 2026 12:00 AM

February 05, 2026

Manuel Rego

FOSDEM 2026

Last weekend I was in Brussels attending FOSDEM. A big event with lots of people and lots of things happening in parallel, where it’s impossible to be everywhere.

Browser and web platform 🌐 #

My main participation was in the Browser and web platform devroom, a new devroom (it seems it already happened back in the days, but not in the recent years) where I had the chance to speak about Servo with a talk titled The Servo project and its impact on the web platform ecosystem. My colleagues from Igalia Eri Pazos and Mario Sánchez Prada were also speaking in that devroom about Interop and MathML Core and The Web Platform on Linux devices with WebKit: where are we now? respectively. The room was fully packed a big part of the day, not unexpectedly many people are interested in the web platform.

MathML #

Eri was the first igalian talking in the room, they summarized the work Igalia has been doing for many years in MathML, on the standards side proposing the MathML Core spec, and on the implementation side bringing it back to Chromium and improving it in Gecko and WebKit.

In the talk, Eri went into deep detail about the last additions we have been adding around MathML: math-depth, math-shift, RTL mirroring, font-family: math, etc. This work is part of an agreement with the Sovereign Tech Fund, big thanks for your support.

MathML is more ready than ever for production, someone from arXiv.org in the audience mentioned that they are shipping it on millions of webpages today. Waiting for the day when Wikipedia switches to it by default, it will be a huge milestone.

WebKit on Linux #

Mario talked about the Linux ports of WebKit: WebKitGTK and WPE, both maintained by Igalia.

He explained what they are, the differences between them, reviewed their history and highlighted the big progress on the recent years, with multiple improvements in several areas: WebPlatform API, WebKit Container SDK, switch from Cairo to Skia graphics library, etc.

If you are curious about the status of things regarding them, you shouldn’t miss his talk.

Servo #

My talk started with an introduction to the Servo project and the current status of things. I showed a few demos about how Servo works and some of the things it can do already. After that introduction, I explained how Servo has been contributing to the wider web platform ecosystem.

Like for the rest of talks, slides and video are already available if you want to know all the details. Kudos to the organization for being so quick.

Picture of my talk with the slides about conclusions at the back

Picture of my talk with the slides about conclusions at the back

As an anecdote, the night before the talk a new project based on Servo was published, a browser developed fully with web technologies using Servo underneath. I couldn’t resist the urge to build it, play with it and add it to the presentation. It looks really cool what Servo can do these days.

Screencast of the new Beaver browser based on Servo

In addition, in the same devroom there was another Servo talk, this time by Taym, one of the Servo maintainers, Implementing Streams Spec in Servo web engine, where he explained all the work behind adding Streams support to Servo.

I am also very happy to met many Servo contributors at the event: Delan Azabani (@delan), Eri Pazos (@eerii), Jonathan Schwender (@jschwe), Martin Robinson (@mrobinson), Taym Haddadi (@Taym95), Tim van der Lippe (@TimvdLippe). We had the chance to have some informal conversations about the project, discussing some technical topics and ideas about things we can do in Servo.

The feedback about Servo has been extremely positive, people are really happy with the evolution of the project and excited about the future.

Apart from that, we also had the opportunity to talk to the nice folks from NLnet and the Sovereign Tech Agency who both have ongoings collaborations around Servo. The work these organizations do is really important for the open software development, and more should learn from them and join forces to try to fix the funding issues in FLOSS (more about this later, when talking about Marga’s keynote).

Igalia #

Igalia presence in the open source community is very big, and not unexpectedly we have more talks at FOSDEM this year. This is the full list:

Also our work in different projects was mentioned in several talks and conversations, we’re really happy regarding all the good feedback we got about Igalia contributions.

Keynote #

Marga Manterola was doing one of the keynotes talking about funding open source software: Free as in Burned Out: Who Really Pays for Open Source? . First time Igalia was doing a keynote at FOSDEM, in the year we celebrate our 25th anniversary, and in a historical auditorium where May 68 started in Brussels. 🎉

Picture of Marga Manterola's keynote at FOSDEM 2026

Picture of Marga Manterola's keynote at FOSDEM 2026

The talk was great, Marga explained many of the issues with open source software sustainability and some potential ideas about how to improve the situation. This is a recurring topic in many conversations these days, we should find a way to get this fixed somehow.

There I learnt about the Open Source Pledge, an interesting initiative to get companies donating 2,000 USD per developer to open source software maintainers. 💰

Wrap up #

All in all, it was a nice but very busy weekend in Brussels, weather was ok (a bit cold but not rainy) and waffles were delicious as usual. 🧇😋

Next big event on my calendar is the Web Engines Hackfest in June, more than 50 people have already registered and a bunch of Servo folks will be there too. If you’re interested in the web platform and willing to discuss about different topics, we would be very happy to host you there.

February 05, 2026 12:00 AM

February 04, 2026

Brian Kardell

What if we just

What if we just

What if a better answer to a question I've been struggling with for more than a decade is just... Way simpler? Sharing a potentially half-baked idea for discussion.

Back in 2013 I wrote Dropping the F-Bomb on Web Standards. The core argument was simple: the web works best when developers can invent “slang,” and standards bodies behave more like dictionary editors — watching what people actually say, then paving the cow paths that clearly matter.

It fed into the Extensible Web Manifesto (which followed) and over the years I've continued to push for study of what people are really doing. I have helped add features to the HTTPArchive crawl and built tools to analyze this data.

But it's hard. It's biased. It's incomplete. Even the best crawl misses huge swaths of the web — anything behind logins, paywalls, dashboards, internal tools, or private deployments. And all of them have limits. It requires a ton of follow-up analysis and raises almost as many questions as it answers.

So lately I've been wondering (a bit like Kramer):

What if we just... voluntarily shared this information?

We don't need a formal standard or anyone's permission, we could just... share it, and build tools to share it easily in a well known format at a well known URL.

It could give us insight into the use of custom elements behind logins and paywalls and so on too, and tell us where they come from (a git repo, for example)...

Lots of things that are common happened through community effort and adoption. Normally you get something from it - robots.txt helped your site from being aggressively scraped in problematic ways, ads.txt helped say something about monetization, feed.rss helped syndicate, and so on. What do you get out of sharing this kind of info?

Individually, I'm not sure. But, collectively the benefit is clear: We'd finally have a real, ecosystem‑wide index of custom elements and how they're used. and hopefully a way to shape useful standards on them easily.

As to what that would look like, I'm not sure.

The community defined Custom Element Manifest already has a bit of uptake and tooling - we could just publish that to a well known URL. It might be too much, or too little.. A simpler manifest of just element names and URLs of packages/repositories that supply them would even be nice.

Is it too much? Too little? Maybe.

Is it worth trying? I think so.

What do you think? Is this worth trying somehow?

February 04, 2026 05:00 AM

February 02, 2026

Igalia WebKit Team

WebKit Igalia Periodical #55

Update on what happened in WebKit in the week from January 26 to February 2.

A calm week for sure! The highlight this week is the fix for scrolling not starting when the main thread is blocked.

Cross-Port 🐱

Graphics 🖼️

Fixed the problem of wheel event async scrolling doesn't start while the main thread is blocked. This should make WebKit feel more responsive even on heavier websites.

That’s all for this week!

by Igalia WebKit Team at February 02, 2026 08:11 PM

Brian Kardell

Maintaining the Bridges

Maintaining the Bridges

Thoughts and analogies about infrastructure...

I live in Pittsburgh, Pennsylvania — “The Steel City,” once the beating heart of American steelmaking. In 1902, U.S. Steel’s first full year of operation, it produced 67% of all steel in the United States. By 1943, the company employed more than 340,000 people. We burned so much coal that Pittsburgh earned the nickname “Hell with the lid off.” Streetlights sometimes ran at noon because the sky was that dark.

A photo of Pittsburgh dark with smoke at midday. (You can search more about this it if that interests you, here's one nice piece with a few pictures).

The city’s geography didn’t make things any easier. Pittsburgh is carved by mountains, valleys, and the three rivers — the Allegheny and Monongahela merging to form the Ohio. That topography, combined with the industrial boom, meant we built a lot of bridges. It helps that when your city is literally manufacturing the materials, you get a hometown discount.

A view down river of Pittsburgh's 3 sisters" bridges and several others.

One of them, the Hot Metal Bridge — just a mile or two from my house — once carried ladle cars full of molten iron between the blast furnaces and mills of J&L Steel. During World War II, 15% of America’s steelmaking capacity crossed that bridge, up to 180 tons per hour.

These bridges were originally built by private companies with a clear profit motive: to move coal, ore, steel, or workers. Others were toll bridges, run by private companies the way you’d run a turnpike or ferry.

But more bridges meant more industry, which meant more people, which meant more bridges. You can see where this goes.

Even by the late 1800s we were beginning to publicly fund them. By the 1920s–1930s Allegheny County’s bridge program bought out many of the private bridges and replaced many of them. By the time the New Deal and Interstate era arrived, the private‑toll era was basically over - and since then over 90% of Pittsburgh's public bridges were funded by federal programs (we still have some private industry use bridges).

So what does any of this have to do with software?

Aside from giving me an excuse to talk about my city (which I enjoy), Pittsburgh’s bridges are a useful metaphor for the infrastructure we rely on in tech, in two important ways:

  1. Becoming a public good Private investment built early bridges, just like private companies built much of what we've got now in terms of browser engines, search index, foundational libraries and so on - but eventually they stopped becoming optional. I think we're only now starting to really understand that we need a lot of this to be a public good in the same kind of way. These are the roads and bridges of the modern world.

  2. Building something new is exciting. Maintaining it, not so much. A lot of my city's physical infrastructure is aging and some of it has been neglected. It's somehow way easier to get people to build new things than to take care of the old stuff. The public notices a new bridge! The ribbon-cutting gets a photo op and celebration. The maintenance budgets and crews struggle to even get funding.

In fact, even when things are fairly well funded, it doesn't mean they're kept up to date. While researching to write this piece I realized that a lot of the Wikipedia data about Pittsburgh (and many topics!) is actually really out of date. It's cool to write the article with these cool facts, but it's not so cool to do the work to keep it up... Or, maybe thats just not what you want to do anymore. Or maybe you were incarcerated, or you died, or you went to Mars - idk.

The point is that writing the thing in the first place is only half the battle. If most of your entry on a city was written two decades ago, a lot of what it details about the economics, population, jobs, and so on are probably not very accurate!

It's no different with software. It's cool and fun to build a new thing or add a new feature to an existing thing, but keeping them maintained is annoying. New mechanisms arrive that you might need to adapt to. Underlying code bit rots. All of it needs release teams and Q&A and reviews and fixes and updates at global scales, even if no new features were added. But very few people actually want to do that, and almost nobody wants to pay for it.

More Public Funding

I'd really love for societies around the world to come to the realization that a lot of the online things we've built are, like roads and bridges, now necessary - and figure out how we can publicly fund enough of them that important things without an obvious and direct profit motive can get done. MathML and SVG are two easy examples of this, but there are plenty more. Maybe XSLT is another example. Perhaps if we had good funding for those things, their ongoing survival wouldn't be questioned.

I feel like there is a lot of room here for improvement from the status quo. It doesn't even have to start with governments. Any ways that we expand the pool of funding avilable and diversifying, it helps.

February 02, 2026 05:00 AM

Igalia Compilers Team

Implementing the Temporal proposal in JavaScriptCore

Image source

For the past year, I've been working on implementing the Temporal proposal for date and time handling in JavaScript, in JavaScriptCore (JSC). JavaScriptCore is the JavaScript engine that's part of the WebKit browser engine. When I started, Temporal was partially implemented, with support for the Duration, PlainDate, PlainDateTime, and Instant types. However, many test262 tests related to Temporal didn't pass, and there was no support for PlainMonthDay, PlainYearMonth, or ZonedDateTime objects. Further, there was no support for the relativeTo parameter, and only the "iso8601" calendar was supported.

Duration precision (landed) #

Conceptually, a duration is a 10-tuple of time components, or a record with the fields "years", "months", "weeks", "days", "hours", "seconds", "milliseconds", "microseconds", and "nanoseconds".

One way durations are used is to represent the difference between two dates. For example, to find the length of time from a given date until the end of 2027, I could write the following JS code:

> const duration = (new Temporal.PlainDate(2026, 1, 26)).until(new Temporal.PlainDate(2027, 12, 31), { largestUnit: "years" })
> duration
Temporal.Duration <P1Y11M5D> {
years: 1,
months: 11,
days: 5
}

The until method in this case returns a duration comprising one year, eleven months, and five days. Because durations can represent differences between dates, they can also be negative:

> const duration = (new Temporal.PlainDate(2027, 12, 31)).until(new Temporal.PlainDate(2026, 1, 26), { largestUnit: "years" })
> duration
Temporal.Duration <P1Y11M5D> {
years: -1,
months: -11,
days: -5
}

When converted to nanoseconds, the total of days, hours, minutes, seconds, milliseconds, microseconds, and nanoseconds for a duration may be a number whose absolute value is as large as 109 × 253. This number is too large to represent either as a 32-bit integer or as a 64-bit double-precision value. (If you're wondering about the significance of the number 253, see the MDN documentation on JavaScript's MAX_SAFE_INTEGER.)

To understand why we need to be able to work with such large numbers, consider totaling the number of nanoseconds in a duration. Following on the previous example’s definition of the variable duration:

> duration.total({unit: "nanoseconds", relativeTo: new Temporal.PlainDate(2025, 12, 15)})
60912000000000000

There are 60912000000000000 nanoseconds, or about 6.1e16, in a period of one year, eleven months, and five days. Since we want to allow this computation to be done with any valid start and end date, and valid years in Temporal range from -271821 to 275760, the result can get quite large. (By default, Temporal follows the ISO 8601 standard for calendars, which entails using a proleptic Gregorian calendar. Also note that this example uses a PlainDate, which has no time zone, so computations are not affected by daylight savings time; when computing with the Temporal ZonedDateTime type, the specification ensures that time zone math is done properly.)

To make it easier for implementations to fulfill these requirements, the specification represents durations internally as Internal Duration Records and converts between JavaScript-level duration objects and Internal Duration Records (which I'll call "internal durations") as needed. An internal duration pairs the date component of the duration (the years, months, weeks, and days fields) with a "time duration", which is a single integer that falls within an accepted range, and can be as large as 253 × 109 - 1.

Implementations don't have to use this representation, as long as the results are observably the same as what the specification dictates. However, the pre-existing implementation didn't suffice, so I re-implemented durations in a way that closely follows the approach in the specification.

This work has been landed in JSC.

New date types #

Temporal's date types include PlainDate, PlainDateTime, Instant, ZonedDateTime, PlainMonthDay, and PlainYearMonth. The latter two represent partial dates: either a pair of a month and a day within that month, or a pair of a year and month within that year. Partial dates are a better solution for representing dates where not all of the fields are known (or not all of the fields matter) than full dates with default values for the missing bits.

Temporal's ZonedDateTime type represents a date along with a time zone, which can either be a numeric offset from UTC, or a named time zone.

I implemented PlainMonthDay and PlainYearMonth with all their operations. ZonedDateTime is fully implemented and the first pull request in a series of PRs for it has been submitted.

The relativeTo parameter #

What if you want to convert a number of years to a number of days? Temporal can do that, but there's a catch. Converting years to days depends on what year it is, when using the ISO 8601 calendar (similar to the Gregorian calendar), because the calendar has leap years. Some calendars have leap months as well, so converting years to months would depend on what year it is as well. Likewise, converting months to days doesn't have a consistent answer, because months vary in length.

For that reason, the following code will throw an exception, because there's not enough information to compute the result:

> const duration = Temporal.Duration.from({ years: 1 })
> duration.total({ unit: "days" })
Uncaught RangeError: a starting point is required for years total

The above definition of duration can still be made to work if we pass in a starting point, which we can do using the relativeTo parameter:

> duration.total({ unit: "days", relativeTo: "2025-01-01" })
365
> duration.total({ unit: "days", relativeTo: "2024-01-01" })
366

The string passed in for the relativeTo parameter is automatically converted to either a PlainDate or a ZonedDateTime, depending on which format it conforms to.

I implemented support for the relativeTo parameter on all the operations that have it; once the implementations for all the date types land, I'll be submitting this work as a series of pull requests.

Calendars #

Representing dates with non-ISO8601 calendars is still very much a work in progress. The ICU library can already do the basic date computations, but much glue code is necessary to internally represent dates with non-ISO8601 calendars and call the correct ICU functions to do the computations. This work is still underway. The Temporal specification does not require support for non-ISO8601 calendars, but a separate proposal, Intl Era Month Code, proposes a set of calendars to be supported by conformant implementations.

Testing Temporal #

The JavaScript test suite is called test262 and every new proposal in JavaScript must be accompanied by test262 tests. Not all JS implementations are required to support internationalization, so Temporal tests that involve non-ISO calendars or named time zones (other than the UTC time zone) are organized in a separate intl402 subdirectory in test262.

The test262 suite includes 6,764 tests for Temporal, with 1,791 of these tests added in 2025. Igalia invested hundreds of hours on increasing test coverage over the past year.

Status of work #

All of this work is behind a flag in JSC in Technology Preview, so to try it out, you'll have to pass the --useTemporal=1 flag.

All of the implementation work discussed above (except for non-ISO calendars) is complete, but I've been following an incremental approach to submitting the code for review by the JSC code owners. I've already landed about 40 pull requests over the course of 2025, and expect to be submitting at least 25 more to complete the work on PlainYearMonth, ZonedDateTime, and relativeTo.

Based on all the code that I've implemented, 100% of the non-intl402 test262 tests for Temporal pass, while the current HEAD version of JSC passes less than half the tests.

My colleagues at Igalia and I look forward to a future JavaScript standard that fully integrates Temporal, enabling JavaScript programs to handle dates more robustly and efficiently. Consistent implementation of the proposal across browsers is a key step towards this future. Step by step, we're getting closer to this goal.

We thank Bloomberg for sponsoring this work.

February 02, 2026 12:00 AM

January 30, 2026

Manuel Rego

On my way to FOSDEM where tomorrow I’ll be talking about Servo in the Browser and web platform devroom. See you there!

Banner of my talk at FOSDEM that reads: Igalia @ FOSDEM'26. The Servo project and its impact on the web platform ecosystem. Manuel Rego. Saturday, Jan. 31, 2:00pm

January 30, 2026 12:00 AM

Alice Boxhall

Reference Target: having your encapsulation and eating it too

Three years ago, I wrote a blog post about How Shadow DOM and accessibility are in conflict.

I explained how the encapsulation provided by shadow roots is a double-edged sword, particularly when it comes to accessibility. Being able to programmatically express relationships from one element to another is critical for creating user experiences which don’t rely on visual cues - but elements inside a shadow root aren’t available to be referenced from elements in the light DOM. This encapsulation, however, is what allows component authors to create accessible components which can be safely reused in any context, without necessarily requiring any particular dependencies or extra build steps.

In the year or so following, even more heroic attempts were made to square this circle, and finally one seems likely to stick: Reference Target. In this post I’ll explain how this feature works, why I like it, and what the situation is right now with the spec and implementation (thanks in part to Igalia’s NLNet funding).

A quick introduction #

referenceTarget is a new property on shadow root objects which lets you nominate an element in the shadow root’s subtree which should be the target of any attribute-based reference to the shadow host.

As an example, imagine that you have a <custom-input> component, which has an <input> tucked away in its shadow root. This is a pattern which is ubiqutous in custom element libraries, as it allows the custom element to use composition to enhance the behaviour of a built-in element.

<label for="track">Track name:</label>
<custom-input id="track">
#shadowRoot
| <input id="inner-input">
</custom-input>

We can set the shadow root’s referenceTarget to allow the <label> to correctly label the inner <input>:

// in the constructor for the custom-input:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.referenceTarget = 'inner-input';
shadowRoot.innerHTML = '<input id="inner-input">`;

This lets the label refer to the <custom-input> just like it would refer to an <input>; the <custom-input> transparently proxies the reference through to the encapsulated <input>.

In this example, we’ve set the referenceTarget property directly on the ShadowRoot object, but it can also be set declaratively when using the <template> element to create the shadow root:

<label for="track">Track name:</label>
<custom-input id="track">
<template shadowRootMode="open"
shadowRootReferenceTarget="inner-input">

<input id="inner-input">
</template>
</custom-input>

This works equally well for any attribute which refers to other elements like this - even if you set it via a reflected property like commandForElement:

<button id="settings-trigger">Site settings</button>
<custom-dialog id="settings-dialog">
#shadowRoot referenceTarget="inner-dialog"
| <dialog id="inner-dialog">
| <button id="close" aria-label="close"
| commandFor="inner-dialog"
| command="request-close">
</button>
| <slot></slot>
| </dialog>
<fieldset>
<legend>Colour scheme:</legend>
<label for="dark">
<input type="radio" id="dark" name="appearance" value="dark" checked>
Dark
</label>
<!-- TODO: more colour schemes -->
</fieldset>
</custom-dialog>
// Someone probably has a good reason why they'd do it this way, right?

const settingsButton = document.getElementById('settings-trigger');
settingsButton.command = 'show-modal';
settingsButton.commandForElement = document.getElementById('settings-dialog');

This lets the <custom-dialog> behave exactly like a <dialog> for the purposes of the command and commandForElement properties.

Why I like it #

In my earlier blog post I explained that I was concerned that the Cross-root ARIA delegation and reflection proposals introduced a bottleneck problem. This problem arose because it was only possible to refer to one element per attribute, rather than allowing arbitrary cross-shadow root references.

This proposal absolutely doesn’t solve that problem, but it reframes the overall problem such that I don’t think it matters any more.

The key difference between reference target and the earlier proposals is that reference target is a catch-all for references to the shadow host, rather than requiring each attribute to be forwarded separately. This solves a specific problem, which I alluded to above: how can custom element authors encapsulate the behaviour of a given built-in HTML element while also allowing other elements to refer to the custom element as if it was the built-in element?

I believe this more narrow problem definition accounts for a significant proportion - not all, but many - cases where references need to be able to cross into shadow roots. And it makes the API make much more sense to me - if you’re using the for attribute to refer to a <custom-input>, you’re not meant to need to know that you’re actually referring to an enclosed <input>, you just want the <custom-input> to be labelled. This API makes the enclosed <input> an implementation detail. And since a shadow root can only have one host, it makes sense that it can only have one reference target.

Adjacent, as-yet unsolved problems #

Arbitrary cross-shadow root references #

As mentioned above, one adjacent problem is the problem of element references which do need to refer to specific elements within a shadow root, rather than a stand-in for the shadow host.

The explainer gives two examples of this: aria-activedescendant on a combobox element which needs to refer to an option inside of a shadow root, and ARIA attributes like aria-labelledby, aria-describedby and aria-errormessage which may need a computed name for the component which excludes some parts.

I think we need to be careful about generalising this problem, though. As I describe later in the explainer, I think we might be able to get better solutions by solving more specific problems - as we have with reference target.

If you have another example of where you need to refer to specific elements within a shadow root, you can leave a comment on this issue collecting use cases.

Attribute forwarding #

While reference target allows other elements to refer to the encapsulated element, custom element authors may also want to allow developers using their component to use standard HTML and ARIA attributes on the host element and have those apply to the encapsulated element.

For example, you might like to support popoverTarget on your <custom-button> element:

<custom-button popoverTarget="languages">Language</custom-button>
<custom-menu id="languages" popover>
<custom-menuitem>Nederlands</custom-menuitem>
<custom-menuitem>Frysk</custom-menuitem>
<custom-menuitem>Vlaams</custom-menuitem>
</custom-menu>

There is an issue for the attribute forwarding idea; leave a comment there if this is an idea you’d like to see pursued.

Form association #

Custom elements can be specified as form-associated, but there’s no way to associate an encapsulated form-associated built-in element (such as <input>) with an enclosing <form>.

For example, the <custom-input> above could be nested in a <form> element, but the enclosed <input> wouldn’t be associated with the <form> - instead, you’d have to use setFormValue() on the custom element and copy the value of the <input>.

Spec and implementation status #

In brief: the spec changes seem to be in good shape, Chromium has the most feature-complete implementation and there are significantly less-baked implementations in WebKit and Firefox.

Spec changes #

There are open pull requests on the HTML and DOM specs. Since these PRs are still being reviewed, the concepts and terminology below might change, but this is what we have right now. These changes have already had a few rounds of reviews, thanks to Anne van Kesteren, Olli Pettay and Keith Cirkel.

The DOM change:

The HTML change is where the actual effect of the reference target is defined.

Element reference attribute type #

One key change in the HTML spec is the addition of an attribute type for “element reference” attributes. This formalises in HTML what has previously been referred to as an ID reference or IDREF. This term isn’t currently used in HTML, and since the addition of reflected IDL Element attributes, IDs aren’t strictly necessary, either.

Before this change, whenever an attribute in the HTML spec was required to match another element based on its ID, this was written out explicitly where the attribute was defined. For example, the definition of the <label> element’s for attribute currently reads:

The for attribute may be specified to indicate a form control with which the caption is to be associated. If the attribute is specified, the attribute’s value must be the ID of a labelable element in the same tree as the label element. If the attribute is specified and there is an element in the tree whose ID is equal to the value of the for attribute, and the first such element in tree order is a labelable element, then that element is the label element’s labeled control.

Since reference target affects how this type of reference works, and is intended to apply for every attribute which refers to another element, it was simpler to have one central definition.

Reference target resolution #

For a reference target to actually do something, we need to define what effect it has. This is defined, quite straightforwardly, in the steps to resolve the reference target:

  1. If element is not a shadow host, or element’s shadow root’s reference target is null, then return element.
  2. Let referenceTargetValue be the value of element’s shadow root’s reference target.
  3. Let candidate be the first element in element’s shadow root whose ID matches referenceTargetValue.
  4. If no such element exists, return null.
  5. Return the result of resolving the reference target on candidate.

These steps are recursive: if a shadow root’s reference target has its own shadow root, and that shadow root has a reference target, we keep descending into the nested shadow root.

One slightly subtle design choice here is that if a shadow root has a reference target which doesn’t refer to any element - for example, an empty string, or a value which doesn’t match the ID of any element in its subtree - the resolved reference target is null, not the shadow host.

For example, if you tried to use popoverTarget to refer to a shadow host which had a popover attribute, but had an invalid reference target on its shadow root, the popoverTarget attribute won’t actually target anything:

<button id="more-actions" popoverTarget="actions-popover" aria-label="more actions"></button>

<!-- Even though this has a popover attribute, the button won't toggle it! -->
<custom-popover id="actions-popover" popover>
<template shadowRootMode="open"
shadowRootReferenceTarget="0xDEADBEEF">

<div id="help-im-trapped-in-a-shadow-root" popover>
<slot></slot>
</div>
</template>
</custom-dialog>

Resolved and unresolved attr target elements #

Like many spec concepts, this one is a real mouthful.

This lets us be very clear about whether reference target resolution has happened when we’re talking about what element an attribute refers to.

If we’re reflecting an attribute to its IDL counterpart, we now use the unresolved attr target element. For example, if we had the DOM defined in the previous example, and we wanted to get the popoverTargetElement for the "settings-trigger" button:

const moreActions = document.getElementById("more-actions");

// This will log the <custom-popover> element (!)
console.log(moreActions.popoverTargetElement);

(In spec terms: the <custom-popover> element is the unresolved popoverTarget target element for the <button>.)

This might also be a bit surprising; we spent quite a bit of time going back and forth on this, since we thought developers might want to know that the popoverTarget isn’t actually targeting anything. However, using the unresolved target lets us have a very close parallel between setting and getting the popoverTargetElement, as well as preserving the shadow root’s encapsulation.

The resolved attr target element, meanwhile, is what will be used when actually doing something with the attribute - such as triggering a popover, or computing a label’s labeled control, or determining an element’s accessible description.

In the above example, the resolved popoverTarget target element for the button is null. And, going back to the examples we’ve seen earlier:

  • the resolved commandFor target element for the Settings button is the inner <dialog> - clicking the button will open the <dialog>.
  • the resolved for target element for the <label> is the inner <input> - clicking the label will focus the input, and the input’s computed accessible name will be “Track name”.

“Referring to” #

For convenience, we define the concept of an attribute “referring to” an element:

A single-element reference attribute attr on an element X refers to an element Y as its target if Y is the resolved attr target element for X.

So, for example, the commandFor attribute on the Settings button refers to the inner <dialog> element.

Sets of element references #

All of the above used single element references as examples, but there are attributes which can refer to more than one element. For example, almost all of the ARIA attributes which refer to other elements refer to multiple elements in an ordered list - one such is aria-errormessage, which can refer to one or more elements which should be exposed as specifically as an error message for an element which is marked as invalid.

We define a set of element references attribute type, as well as a couple of subtypes which impose constraints such as ordering or uniqueness, as well as what it means for one of these attributes to refer to another element, and how to get the resolved and unresolved attr target elements for these attributes.

While these are slightly more complex than the single element versions, they follow the same basic logic. The only marginally significant difference is that since they produce lists of elements, if a shadow root’s reference target is invalid, no element is added to the list for that unresolved attr target, instead of returning null.

Using these concepts in the rest of the spec #

Now that we’ve defined these spec concepts, we have to update each place in the spec where we previously used the “whose ID is equal to the value of the blahblah attribute” wording.

Returning to our good friend popoverTarget, we can see a relatively straightforward example.

The definition of the popoverTarget attribute now reads:

If specified, the popovertarget attribute value must be a valid single-element reference attribute referring to an element with the popover attribute.

And now, get the popover target element determines popoverElement like this:

  1. Let popoverElement be node’s resolved popovertarget target element.

<label> association is a bit more complex, since we wanted descendants of the <label> to be correctly labelled when using reference target:

To determine a label element label’s labeled control, run these steps:

  1. If label’s for attribute is specified, then:
    1. If the resolved for target element is not null, and the resolved for target element is a labelable element, return that element.
    2. Otherwise, return null.
  2. For each descendant descendant of label in tree order:
    1. Let candidate be the result of resolving the reference target on descendant.
    2. If candidate is a labelable element, return candidate.
  3. Return null.

There is also a PR open on the ARIA spec to introduce this terminology there.

Implementation status #

Chromium has the most complete implementation, though it may not quite be up to date with the latest spec changes. Any developers wanting to try it out should get the latest build of Chrome or Edge and flip on the Experimental Web Platform Features flag. If you do try it out, I’d love to hear any feedback you might have!

WebKit and Firefox (tracking bug) each have a prototype implementation, available behind respective feature flags (ShadowRootReferenceTargetEnabled for WebKit and dom.shadowdom.referenceTarget.enabled for Firefox), which should pass at least most of the existing WPT tests - however, the WPT tests are insufficient to test all of the functionality, and the functionality which couldn’t be tested via WPTs hasn’t been implemented yet in these engines. The Chromium implementation included adding many Chromium-specific tests for the behaviour which can’t be tested via WPTs, as well as implementing that behaviour.

Currently, WPT tests can only test the computed accessible name and computed accessible role for an element, as well as testing DOM methods and user actions like clicking. However, reference target impacts the accessibility tree in many ways - not only via ARIA attributes, but via attributes like popoverTarget being exposed in the accessibility tree as an accessible relation.

And, importantly, changes to the accessibility tree can require certain notifications to be fired to assistive technology APIs - and reference target introduces several new ways to change the accessibility tree. Adding, changing, or removing a shadow root’s referenceTarget may cause changes in the resolved target elements for attributes, causing accessibility tree changes and potentially requiring notifications. Likewise, inserting an element with an ID which matches a shadow root’s referenceTarget could also cause a shadow host’s resolved reference target to change, also potentially causing the accessibility tree to change.

There are two complementary projects currently underway which will allow us to write much richer tests for accessibility tree functionality in browsers:

Once we can write WPT tests which actually test the full spectrum of expected behaviour for reference target, we’ll be able to actually make it an official interop focus area.

The prototype implementation work in WebKit and Firefox, as well as the spec work done by Igalia, was generously funded by a grant from NLNet Foundation, while the implementation work in Chromium and much of the remainder of the spec work was done by Microsoft engineers on the Edge team.

January 30, 2026 12:00 AM

January 26, 2026

Igalia WebKit Team

WebKit Igalia Periodical #54

Update on what happened in WebKit in the week from January 19 to January 26.

The main event this week has been the creation of the branch for the upcoming stable series, accompanied by the first release candidate before 2.52.0. But there's more: the WPE port gains hyphenation support and the ability to notify of graphics buffer changes; both ports get graphics fixes and a couple of new Web features, and WPE-Android also gets a new stable release.

Cross-Port 🐱

Implemented support for the :open pseudo-class on dialog and details elements. This is currently behind the OpenPseudoClass feature flag.

Implemented the source property for ToggleEvent. This can be used to run code dependent on the triggering element in response to a popover or dialog toggle.

Graphics 🖼️

Fixed the rendering glitches with wheel event asynchronous scrolling, which occurred when the page was scrolled to areas not covered by tiles while the main thread was blocked.

WPE WebKit 📟

Support for hyphenation has been added to WPE. This requires libhyphen and can be disabled at build-time with the USE_LIBHYPHEN=OFF CMake option.

WPE Platform API 🧩

New, modern platform API that supersedes usage of libwpe and WPE backends.

WPEPlatform gained support to notify changes in the configuration of graphics buffers allocated to render the contents of a web view, either by handling the WPEView::buffers-changed signal or by overriding the WPEViewClass.buffers_changed virtual function. This feature is mainly useful for platform implementations which may need to perform additional setup in advance, before updated web view contents are provided in the buffers configured by WebKit.

Releases 📦️

WPE-Android 0.3.0 has been released, and prebuilt packages are available at the Maven Central repository. The main change in this this version is the update to WPE WebKit 2.50.4, which is the most recent stable release.

A new branch has been created for the upcoming 2.52.x stable release series of the GTK and WPE WebKit ports. The first release candidates from this branch, WebKitGTK 2.51.90 and WPE WebKit 2.51.90 are now available. Testing and issue reports in Bugzilla are welcome to help with stabilization before the first stable release, which is planned for mid-March.

That’s all for this week!

by Igalia WebKit Team at January 26, 2026 09:00 PM

Enrique Ocaña

Igalia Multimedia contributions in 2025

Now that 2025 is over, it’s time to look back and feel proud of the path we’ve walked. Last year has been really exciting in terms of contributions to GStreamer and WebKit for the Igalia Multimedia team.

With more than 459 contributions along the year, we’ve been one of the top contributors to the GStreamer project, in areas like Vulkan Video, GstValidate, VA, GStreamer Editing Services, WebRTC or H.266 support.

Pie chart of Igalia's contributions to different areas of the GStreamer project:
other (30%)
vulkan (24%)
validate (7%)
va (6%)
ges (4%)
webrtc (3%)
h266parse (3%)
python (3%)
dots-viewer (3%)
tests (2%)
docs (2%)
devtools (2%)
webrtcbin (1%)
tracers (1%)
qtdemux (1%)
gst (1%)
ci (1%)
y4menc (1%)
videorate (1%)
gl (1%)
alsa (1%)
Igalia’s contributions to the GStreamer project

In Vulkan Video we’ve worked on the VP9 video decoder, and cooperated with other contributors to push the AV1 decoder as well. There’s now an H.264 base class for video encoding that is designed to support general hardware-accelerated processing.

GStreaming Editing Services, the framework to build video editing applications, has gained time remapping support, which now allows to include fast/slow motion effects in the videos. Video transformations (scaling, cropping, rounded corners, etc) are now hardware-accelerated thanks to the addition of new Skia-based GStreamer elements and integration with OpenGL. Buffer pool tuning and pipeline improvements have helped to optimize memory usage and performance, enabling the edition of 4K video at 60 frames per second. Much of this work to improve and ensure quality in GStreamer Editing Services has also brought improvements in the GstValidate testing framework, which will be useful for other parts of GStreamer.

Regarding H.266 (VVC), full playback support (with decoders such as vvdec and avdec_h266, demuxers and muxers for Matroska, MP4 and TS, and parsers for the vvc1 and vvi1 formats) is now available in GStreamer 1.26 thanks to Igalia’s work. This allows user applications such as the WebKitGTK web browser to leverage the hardware accelerated decoding provided by VAAPI to play H.266 video using GStreamer.

Igalia has also been one of the top contributors to GStreamer Rust, with 43 contributions. Most of the commits there have been related to Vulkan Video.

Pie chart of Igalia's contributions to different areas of the GStreamer Rust project:
vulkan (28%)
other (26%)
gstreamer (12%)
ci (12%)
tracer (7%)
validate (5%)
ges (7%)
examples (5%)
Igalia’s contributions to the GStreamer Rust project

In addition to GStreamer, the team also has a strong presence in WebKit, where we leverage our GStreamer knowledge to implement many features of the web engine related to multimedia. From the 1739 contributions to the WebKit project done last year by Igalia, the Multimedia team has made 323 of them. Nearly one third of those have been related to generic multimedia playback, and the rest have been on areas such as WebRTC, MediaStream, MSE, WebAudio, a new Quirks system to provide adaptations for specific hardware multimedia platforms at runtime, WebCodecs or MediaRecorder.

Pie chart of Igalia's contributions to different areas of the WebKit project:
Generic Gstreamer work (33%)
WebRTC (20%)
Regression bugfixing (9%)
Other (7%)
MSE (6%)
BuildStream SDK (4%)
MediaStream (3%)
WPE platform (3%)
WebAudio (3%)
WebKitGTK platform (2%)
Quirks (2%)
MediaRecorder (2%)
EME (2%)
Glib (1%)
WTF (1%)
WebCodecs (1%)
GPUProcess (1%)
Streams (1%)
Igalia Multimedia Team’s contributions to different areas of the WebKit project

We’re happy about what we’ve achieved along the year and look forward to maintaining this success and bringing even more exciting features and contributions in 2026.

by eocanha at January 26, 2026 09:34 AM