Fil-C on Nix

Filnix wiki

nixos-userland.md

A Fil-C userland for NixOS: the record

nixosModules.default (lib/filc-system.nix, with nixos/default.nix) makes a NixOS system's pkgs the Fil-C package set, the same way it does for finix. systemd, D-Bus, util-linux, Linux-PAM, coreutils, OpenSSH and every service a module starts become Fil-C builds without per-service overrides. The kernel, the bootloader and the packages in lib/native-exceptions.nix stay native; docs/nixos.md says how to use it and has the table of exceptions.

This page is the running record of the work: what had to change to get a NixOS to boot this way, and what is still open.

Status (2026-11-05): the VM test tests/nixos/userland.nix (nix build .#nixosTests.userland) passes. The machine boots through a Fil-C initrd and stage 2, alice logs in on tty1 through the Fil-C PAM, PID 1, journald, udevd, logind, dbus-daemon, sshd and the login shell all map libpizlo, no unit fails, and filc-ps finds no native process outside the exceptions.

Why the exceptions are what they are

The Rust entries are there because the package set refuses to evaluate Rust and Go packages (lib/block-rust-go.nix), and NixOS 26.05 has rewritten several of its own boot and activation tools in Rust: nixos-init, makeInitrdNGTool, switch-to-configuration-ng, and fish 4, which users-groups.nix compares every user's shell with.

The native packages come from a separate, plain import of filnix's Nixpkgs (filc.nativePackages to choose another). Not pkgsFilc.pkgsBuildBuild: the variants it derives, such as pkgsStatic, inherit the Fil-C crossSystem's replaceCrossStdenv, so the "static native" setuid wrappers were built by filcc and failed to link (ld: --no-dynamic-linker: unknown option). Not buildPackages either: its target platform is Fil-C, which matters for any package that is itself a compiler (Emacs' native compilation pulled in a Fil-C-targeted libgccjit that way).

Obstacles and decisions

systemd: 260, not upstream Fil-C's 256.4

The Fil-C systemd port was 256.4, upstream Fil-C's version (ports/patch/systemd-256.4.patch), while Nixpkgs and its NixOS modules are at 260.4. That skew stopped the build in three places: the systemd and initrd modules link every upstream unit they know of and failed on 260-only ones (missing …/example/systemd/system/breakpoint-pre-udev.service, 97 units in all); the initrd module copies 260-only programs (systemd-factory-reset); and systemd.package's apply appends postpone-d-bus-queue-dispatch.patch, a 260 fix that does not apply to 256. Stubbing the units and dropping the patch got 256 to build and the native initrd to hand over, but the Fil-C 256 PID 1 then died at once after switch-root, silently: systemd points its stdio at /dev/null before the runtime prints its panic.

So the port now builds Nixpkgs' own 260.4, with patches/systemd-260-filc.patch: upstream Fil-C's changes rebased (version script, signal reset, bus error maps without ELF sections), plus what 260 needs. Its header lists each change. The ones that matter at run time:

systemdMinimal and systemdLibs derive from the port, so they are 260 too.

Runtime and glibc fixes

These are outside package ports, so each has an Oracle review and a failing regression, and they land together in one reviewed batch (one rebuild of the Fil-C world), carried by the finix thread:

While these were being found, a debug VM put the patched libpizlo and glibc on LD_LIBRARY_PATH (PID 1's ManagerEnvironment in the initrd and stage 2, plus DefaultEnvironment for services), so each fix could be tried without rebuilding the world. With the batch built in (finix-demo ef34345c28b9), the VM test passes without it.

Seeing why PID 1 died

systemd points its stdio at /dev/null early, so a Fil-C panic in PID 1 prints nowhere and the kernel only reports trap int3 … in libpizlo.so and "Attempted to kill init". In the test VM /dev/console is tty0 (the last console=), not the serial port the driver logs, so reopening the console does not help either; the panic has to go to /dev/ttyS0.

NSS

NixOS loads NSS modules (nss-systemd, nss-myhostname) only into nscd and asserts that nscd is on whenever system.nssModules is not empty. Neither nscd works here: the Fil-C glibc is built without nscd, and NixOS's default nsncd is Rust, which as a native process could not load the Fil-C NSS modules anyway. The module turns nscd off and empties system.nssModules, so names resolve from /etc files only: DynamicUser= names and myhostname do not resolve through NSS. systemd itself does not need NSS for DynamicUser=.

Activation's update-users-groups.pl still runs nscd --invalidate and prints "Can't exec "nscd": Function not implemented". The error is harmless, but the errno is wrong: every failed exec in the Fil-C perl reports ENOSYS, with or without a PATH search, while the kernel returns ENOENT (strace) and a Fil-C C program's execv/execvp gets ENOENT. The runtime's execve wrapper keeps the kernel's errno, so something on perl's exec path after the failed execve overwrites it. Not yet found.

Two further facts for whoever fixes this: the Fil-C glibc looks NSS modules up as libnss_<name>.so.6662 (its shlib-versions), while systemd installs libnss_systemd.so.2; and dlopen of a bare soname ignores the caller's RUNPATH (filc-findings.md). The likely fix is a Fil-C nscd, which means building glibc's nscd in the Fil-C glibc (a glibc change, so it needs the Oracle review in AGENTS.md).

D-Bus: dbus-daemon, not dbus-broker

NixOS 26.05 defaults to dbus-broker. Its Fil-C build fails its test suite on real memory-safety findings, for example c_rbtree_store writing a pointer with no capability (c-rbtree keeps flag bits in its parent pointers) and a memcpy from a null object in config_parser_init, and the launcher's util_fork_broker fails with Function not implemented. The module sets services.dbus.implementation = "dbus", the reference dbus-daemon, which is ported. Porting dbus-broker (and c-rbtree) is open.

QEMU guest agent

The test framework turns on services.qemuGuest, whose agent comes from the full QEMU build. Its Fil-C dependencies dtc (a test data file in assembly) and libiscsi (-Werror=cast-align) do not build yet. The tests turn the agent off; the driver does not use it.

PrivateUsers=

Nixpkgs' service tests (nixosTests) found that every unit with PrivateUsers= (mosquitto, redis, tor) failed at step USER: "Failed to set up user namespacing: Invalid argument". systemd sets it up with unshare(CLONE_NEWUSER) in the forked child, and the kernel refuses that to a multithreaded process; a Fil-C process always runs the runtime's own threads (PHP's pcntl_unshare_01 fails for the same reason, docs/php-port-notes.md). The module turns PrivateUsers= off for every service with a top-level drop-in, service.d/zz-filc-no-private-users.conf. Making it work would need a single-threaded window in the runtime around unshare, which is open.

Servers that share pointers between processes

Fil-C keeps each pointer's capability in per-process shadow memory. A pointer that one process stores in memory it shares with another process arrives there as a bare address, with no capability, and using it stops the program. Servers built around shared memory that holds pointers cannot be Fil-C, and are refused at evaluation:

Apache is ported: its scoreboard, the shmcb TLS session and socache caches and its mutexes hold integers, strings and offsets, which cross processes fine (nixosTests.apache runs prefork and event under load). Two modules kept pointers there: the port moves mod_auth_digest's client table to apr_rmm offsets, and makes mod_ldap ignore its shared cache (LDAPSharedCacheSize), so each process keeps its own. Client libraries such as libpq stay Fil-C.

First boot on EC2

The first launch of filc.less.rest found two bugs that the VM boot check could not, since it boots without user data:

The release AMI's check found one more: a unit that fails after the switch (ACME, while the name points elsewhere) makes switch-to-configuration exit with 4, and the service then left its done marker unwritten and would switch again on every boot. Status 4 means the new system is active, so the service now logs the failed units and writes the marker; the test's target has a unit that fails.

Service tests

Fixes and exceptions the nixosTests matrix needed:

Package fixes

These came up once PID 1 reached multi-user.target: