Fil-C on Nix

Filnix wiki

nanofil/docs/zsys.md

Contracts at the zsys_ boundary

An exploration, not a campaign. Today the formal campaign trusts glibc through a contract per libc function: Campaign 4 writes them in ExternalCalls.v, Libc.v and their siblings (libc-contracts.md), and Campaign 3 uses them through wp_external (logic.md). That continues as it is, for Hello and the campaigns in flight.

This page looks one layer down. Fil-C's user glibc is Fil-C code: it compiles with Fil-C and makes no system call itself. It calls libpizlo, the runtime, whose zsys_ functions are the pizlonated system calls, next to a few dozen other entry points that are not system calls. If nanofil compiles user glibc to IR, glibc becomes module code that the logic proves like Hello's own, and only libpizlo's entry points need contracts. The page counts that primitive set, measures what Hello reaches through glibc, sketches how such contracts fit Machine.v, ExternalMachine.v and the Iris logic, and reports how much of user glibc nanofil compiles today.

Everything here is read against the staging pin, mbrock/fil-c at 18b6663a2785, as runtime-axioms.md is:

nix build -o /tmp/filc-ref .#lib.x86_64-linux.filc-staging-ref-src
nix build -o /tmp/libc --impure --expr '
  let f = builtins.getFlake (toString ./.);
      pkgs = import f.inputs.nixpkgs { system = "x86_64-linux"; };
  in (import ./lib/sources.nix {
        inherit pkgs; variant = "staging"; }).user-glibc-src'

User glibc is projects/user-glibc-2.44 in the second tree. The prototypes are filc/include/pizlonated_syscalls.h in the first, and the implementations are libpas/src/libpas/filc_runtime.c (native) and filc/src/runtime.c (Fil-C).

The primitive set

pizlonated_syscalls.h declares 337 zsys_ functions. They are not all alike:

Kind Count Examples
native wrappers of one system call 312 zsys_read, zsys_openat, zsys_mmap
loader calls 4 zsys_dlopen, zsys_dlsym
Fil-C code in libpizlo 21 zsys_close, zsys_fork, zsys_syscall

A native wrapper has one shape. It checks the capabilities of its pointer arguments (filc_check_write(buf, size) in zsys_read, a Fil-C panic if the check fails), leaves the Fil-C world (filc_exit, so the collector may run), makes the system call, comes back (filc_enter) and, on failure, reports errno through filc_set_errno. That last step calls back into user glibc: libc's startup registers its errno_handler with zregister_sys_errno_handler (csu/libc-start.c), and libpizlo calls it with the error number.

Some are system calls only in name, and their contracts would say more than the kernel's manual page does:

Beyond zsys_, stdfil.h and pizlonated_runtime.h declare about 150 more libpizlo functions. User glibc's libc.a calls 45 of them: threads (zthread_create2, zthread_exit), zlongjmp and the unwinder (_Unwind_ForcedUnwind), fibers for ucontext, the collector (zgc_realloc, zgc_aligned_alloc), checked copies (zmemmove, zmemset), the dynamic call zcall, pointer tables, the errno and dlerror handler registration, x87 control words and cpuid, and diagnostics (zerror, zprintf). libc.a names 272 of the 337 zsys_ functions, plus zsys_brk, which this header no longer declares.

Under all of these lies the runtime the compiler calls: checks, allocation, barriers, pollchecks (filc_*, about 45 names in libc.a). nanofil's checked IR and Campaign 2's axioms already model that layer; it is not part of this boundary.

What Hello reaches

Hello calls 33 libc functions (hello_externals). The tool survey/glibc/reach.py builds a function graph from libc.a's relocations and follows it from those 33 names down to the libpizlo names no member of libc.a defines.

It over-approximates. A function whose address is stored in anything reached counts as called: libio's vtables put every stream kind's methods behind fclose. It reads the static libc.a, not libc.so, and it does not follow dlopen.

468 glibc symbols, in 245 of libc.a's 1,960 members, reach 21 zsys_ functions and 12 other libpizlo names:

Per call, the picture splits in two:

Hello's calls glibc symbols libpizlo names
strlen, strcmp, strncmp, strrchr, memcmp, memchr, mbsinit, __ctype_b_loc, __ctype_get_mb_cur_max, __errno_location, __fpending, malloc, free, iswprint 1 to 3 none
memset, memcpy, realloc 1 or 2 zmemset, zmemmove, zgc_realloc
_exit 1 zsys_exit_hard
abort 1 zerror
ferror 4 futex wait and wake
atexit 33 8, futex and mmap
fclose, fputs, printf, fprintf, wprintf, mbrtowc, mbrtoc32, error, exit, getopt_long, setlocale 356 to 415 27 to 29

The first fourteen are plain Fil-C code that calls nothing in libpizlo. malloc and free are the runtime's allocation and free: user glibc's malloc is zgc_alloc, which the compiler turns into the runtime's allocation. The last eleven share one core of about 357 symbols. Shortest paths (reach.py --why) show where it comes from:

Hello's proof would show most of this core unreachable, as it does abort and quotearg today: no popen stream exists, no other thread cancels, and the selected locale profile reads no file. The over-approximation is a list of the invariants that proof needs, not of the contracts.

How the contracts would fit

Machine and ExternalMachine

No new dispatch is needed. external_site in ExternalMachine.v already takes any direct call of a name the module does not define, that is not global data and not free, to the registry. Link glibc's IR into the module, as link.pl links Hello's objects, and printf becomes a function find_fn finds. What is left unresolved is exactly libpizlo's names, so the registry maps zsys_read and its kin to contracts.

The contract record of ExternalCalls.v fits a native wrapper field by field. For zsys_read(fd, buf, size):

Field zsys_read
contract_abi an int, a pointer and a size; returns a long
contract_pre buf writable for size bytes, the check filc_check_write makes; fd not libpizlo's log descriptor
contract_writes the span buf, size
contract_prefix, contract_post at most size bytes of the descriptor's input in buf, the count returned; or -1 and an error number
contract_panics the capability check, outside the premise

zmemmove and zmemset are the checked copies whose contracts already exist: copy_contract and memset_contract describe them as well as glibc's memcpy and memset, which here only forward to them. zsys_mmap is an allocation step, a fresh object made through the request the contract names, like malloc's. zgc_realloc is Campaign 4's realloc contract.

The world gets smaller

The world a contract speaks of changes from libc's state to the operating system's. Today libc_world holds the locale, stdio's buffers and registrations, the stream inventory and getopt's state, and an observation is StreamBytes, bytes stdio accepted. At the zsys_ layer all of that is memory of the module: the FILE objects, the locale tables, optind, the exit handler list. The logic owns it through points-to facts and module invariants, as Hello's proof owns closeout's static. The world keeps only what no Fil-C code holds: descriptors and their offsets, file contents and input, the working directory, and the observations, bytes written to a descriptor and the process exit.

Three things the boundary does not have yet

  1. errno is a callback. A failing wrapper calls libc's errno_handler, Fil-C code, from inside the primitive. An external call is one atomic step today. The callback frames Campaign 4 built for exit handlers (Linked.v, milestone 6c) are the precedent; a lighter way is to prove errno_handler's triple once and let the contracts name its effect, a store into the thread's errno cell.
  2. The syscall stubs call through zcall. Each syscalls.list entry becomes a function whose whole body returns what zcall of the zsys_ function on zargs returns: it passes its own argument buffer, untyped, to the primitive. zcall is a libpizlo function, but zargs and zreturn are FilPizlonator intrinsics that speak of the frame's argument and return buffers. nanofil now lowers a call of zargs to the entry snapshot (va_args at offset 0) and zreturn to the IR return class data (decision 12), which Machine.v does not have yet, so a stub is outside the model's subset. The choices are to model data returns in Machine.v, to give each stub a contract generated from its syscalls.list signature, or to generate typed stubs in make-syscalls.sh for both compilers.
  3. Threads, unwinding and the loader are reachable only through paths the single-thread premise rules out: futex waits with no other thread, cancellation, dlopen of gconv modules. They can stay without contracts, so reaching one is a failure the proof must rule out, as abort is now. A futex wake with no waiter and a wait on a value that changed can have small contracts; a wait that would block is a stall, which the logic already treats as no failure.

The Iris logic

wp_external in Logic.v stays the rule for a call of a primitive. glibc's functions get triples proven over their IR, as mbszero_spec and rpl_mbsrtowcs_spec are over Hello's.

The contracts Campaign 4 has written do not go to waste: they become the specifications those proofs establish. A lemma of the form "the module's strlen implements strlen_contract" would let a proof use wp_external at strlen and have the call discharged by glibc's own code. That lemma can be proved one function at a time, starting with the fourteen that reach no primitive, without changing the registry Hello's proof uses. It is the incremental path from today's boundary to this one.

What stays trusted

What leaves the trusted base is the semantics of the 33 (or 56) functions Campaign 4 writes by hand: formatting, conversion, getopt, stdio buffering, the shutdown lifecycle. What grows is the proof: up to 468 glibc symbols for Hello, among them all of vfprintf, against 21 functions of Hello's own.

The experiment: user glibc in nanofil IR

survey/glibc/capture.nix rebuilds the staging toolchain's own user glibc with one change: a CC wrapper that keeps each C translation unit's preprocessed source and its command. survey/glibc/run.py then runs nanofil cc-ir on each shared-library unit (.os, 3,114 of them) and records the result. It passes each unit the environment and options nanofil-cc would derive from its command (glibc's -fgnu89-inline is NANOFIL_STD=gnu89), and the capture preprocesses as nanofil-cc does, without __SIZEOF_INT128__ and __BITINT_MAXWIDTH__. Neither is part of any check. The capture builds in about six minutes and the run takes about five at 6 jobs:

nix build -f nanofil/survey/glibc/capture.nix -o /tmp/glibc-pp
nanofil/bin/nanofil --save-state /tmp/nanofil.state
export NANOFIL_STATE=/tmp/nanofil.state
python3 nanofil/survey/glibc/run.py /tmp/glibc-pp /tmp/glibc-nf
uv run --with pyelftools python3 nanofil/survey/glibc/reach.py \
  "$(nix build --print-out-paths \
      .#filcc-staging.components.filc-glibc)/lib/libc.a" \
  strlen printf setlocale --units /tmp/glibc-nf/units.tsv

The units fail in layers, since nanofil reports one error per unit. Two opt-in rewrites of the captured text let earlier runs see past the largest layers, and neither is needed now: --drop-filc-asm deletes the .filc_* module asm statements, and --int128-padding reads three unused __int128_t padding members as aligned bytes of the same size.

Results, 2026-11-10

nanofil As captured --int128-padding
first survey 255 2,365 with --drop-filc-asm
three small fixes 313 2,477 with --drop-filc-asm
.filc_* directives, gnu89 2,111 (68%) 2,749 (88%)
sign builtins, float asm, forward enums 2,206 (71%) 2,866 (92%)
declarator attributes, __int128_t, no _BitInt, alias coalescing 2,900 (93%)
address_space refused only where used 2,902 (93%)
packed members 2,975 (96%)
sqrt and trunc 2,979 (96%)
aliases of thread-local data 2,982 (96%)
__alignof__ of an expression 2,986 (96%)
__label__, #pragma weak in a body, rdtsc, cvtpd2ps 2,991 (96%)
_Complex float and double 3,041 (98%)

The last three rows are a new capture, preprocessed without __BITINT_MAXWIDTH__, so math_uint128.h takes its portable path. No unit timed out (120 s); most take under a second.

With both rewrites, before the .filc_* directives, by library:

Library Units To IR
libc 2,057 1,911
libm 672 201
iconvdata 256 256
libthread_db 41 40
libnsl 38 38
the other 12 50 31

Of the 245 libc.a members that define a function Hello reaches, 244 compile (232 before the declarator-attribute row). Only atexit is missing: it is built into libc_nonshared.a, not captured as a shared unit.

What stops the rest

First errors (one error per unit, so a fix can uncover the next layer):

Cause Units Where
_Complex long double returns 20 libm's l complex functions
a product of two complex values 2 cpow, cpowf
x87 and MXCSR 51 fnclex, frndint, __builtin_ia32_ldmxcsr in libm's fenv

Fixes made along the way

Each frontend fix has a test that fails without it:

Every generated syscall stub now compiles to IR that links. Its body, a zreturn of the zcall of its zsys_ function on zargs, names FilPizlonator's intrinsics zargs and zreturn (146 units), which nanofil lowers to the entry snapshot and a data return (decision 12).

Running nanofil's glibc

Compiling is not running, so survey/glibc/mixed.nix builds the staging toolchain's own user glibc with nanofil (-O) compiling a chosen list of units, survey/glibc/run/units.txt, and filcc the rest. glibc's own make links that libc.so. GNU Hello and five small programs in survey/glibc/run/ (strings and ctype, stdio, printf of double and long double floats, malloc, and exit status, abort and _Exit) are built once with filcc and run twice: against the stock glibc, and with LD_LIBRARY_PATH naming the mixed one, which the loader maps (LD_DEBUG=libs checks that). By hand, not CI:

nix build --impure -f nanofil/survey/glibc/mixed.nix check -o /tmp/glibc-mixed-check

The list is the 305 units that define a function Hello or the programs reach (reach.py over Hello's 33 names and the 74 more the programs call), all of which compile to IR. It holds all 244 units Hello reaches, among them vfprintf-internal, printf_fp, malloc, fileops, strtod_l, getopt, the string functions and the syscall stubs open64, open64_nocancel, munmap and sysinfo. The libc is shared, so these are the .os builds of the same sources as Hello's libc.a members.

Result, 2026-11-10: nanofil compiled all 305, and all 25 invocations agree on stdout and exit status. (The first run, before zargs and zreturn, swapped 299 units and agreed on 24.) A sixth program, sys, calls open, fcntl, dup, sysinfo, mmap and munmap through nanofil's stubs, failures and errno included. Hello ran with 12 argument sets, including bad options, a UTF-8 greeting and --help; the floats program covered %f %e %g %a from subnormals to 1e+4000; malloc made 32.7 million allocations. The one difference is in stderr: abort's Fil-C panic backtrace shows the frame of __GI_abort as <somewhere> instead of abort.c:78:3, because nanofil emits no line information.

Two gaps showed only at link time, which run.py does not reach:

Decisions this raises

  1. Teach nanofil the .filc_* directives. Done (above).
  2. __int128. Settled without a layout: nanofil still refuses __int128 where it is used, and glibc compiles around it.
  3. packed on a member. Done (above).
  4. What the proofs trust about glibc's compiler (above). Decided for now: it is glibc-ir.md's assumption G1, written down and decided again before Hello uses glibc's IR.
  5. The boundary itself. Recommended: keep Campaign 4's per-function contracts for Hello, and treat this page's direction as a later campaign whose first step is "module strlen implements strlen_contract" for the leaf functions that reach no primitive.
  6. An alias of thread-local data. Done (above).
  7. IR operations for sqrt and trunc. Done (above): sqrt is an IR operation, and trunc is built in the frontend.
  8. x87 and MXCSR (51 libm units, the floating-point environment). FilPizlonator accepts a list of x87 control instructions and treats stmxcsr and ldmxcsr as checked memory accesses. Recommended: later; the interpreter has no floating-point environment to model. Decided: deferred.
  9. _Complex. Done for float and double (above); what is left is 10 and 11.
  10. _Complex long double returns (20 libm units, the l variants). FilPizlonator returns one through the call buffer, its parts at offsets 128 and 144, 16 bytes each with their aux words zeroed, where a struct of two long doubles takes a hidden result pointer. The IR's returns are at most two words (tuple([K1, K2])), so this is an IR change, a two-x return the Fil-C backend writes to both slots, with the interpreter and Rocq's validator. Its arguments, through memory, already match. Decided: deferred; the IR change goes to Campaign 3 once libm's l complex functions matter.
  11. Multiplying or dividing two complex values (cpow, cpowf). clang calls compiler-rt's __muldc3, __mulsc3, __divdc3 and __divsc3, which in Fil-C are native code (libyolort.a) that IR cannot call. Compiling compiler-rt's muldc3.c and its kin with nanofil into ordinary IR the frontend calls would serve them. Decided: the two units stay out, with the refusal.
  12. zargs and zreturn (146 units: every generated syscall stub and variadic wrappers such as open64, fcntl64 and ioctl). FilPizlonator compiles them to the frame's argument and return buffers, and libpizlo does not export them, so nanofil's calls to them do not link. Done, with Campaign 3's agreement: a call of zargs is let(T, p, va_args, [const(0)]), the entry snapshot that va_start's cursor also points into, and a function that calls zreturn has the new return class data (Fil-C only, ir.pl): it returns by ret(P) the 8-byte words from P to its object's end with their capabilities, as libpizlo's filc_prepare_to_return_with_data, none for a null P, and it has only the generic entry. A call reads its result words from those, and traps if it reads more than were returned (tests/c/prog/zreturn.c, zreturn-none.c, unit test data_return). Rocq's ret_type has no data, so such functions decode as outside its subset. filcc itself fails its verifier on zreturn of a constant null pointer; nanofil does not.