Fil-C on Nix

Filnix wiki

guile.md

Guile 3.0 on Fil-C

pkgsFilc.guile_3_0 is GNU Guile 3.0.11 built with Fil-C, on the libgc API that pkgsFilc.boehmgc implements on FUGC (docs/boehm-on-fugc.md). The port is ports/patch/guile-3.0.11.patch (about 40 files in libguile/), plus the build settings in ports.nix. The Scheme side is unchanged: the build platform's Guile compiles the modules, and the .go files are the same bytecode as native ones.

What breaks, and why

Guile's SCM type is already a pointer (SCM_DEBUG_TYPING_STRICTNESS == 1), and heap cells are read and written as SCM (SCM_GC_CELL_OBJECT). Tagging is done by integer arithmetic on SCM_UNPACKed values. Under Fil-C that is fine: as docs/filc-findings.md ("Pointer tagging works; integer-typed storage loses capabilities") shows, what matters is the type of the storage, not the tag bits. A pointer stored in a scm_t_bits (uintptr_t) word, passed as a scm_t_bits argument or returned as one loses its capability, and the next access traps. So the representation stays; what the patch changes is every place that keeps a pointer in an integer location. It finds them by running Guile and its test suite until nothing traps, and by grepping for scm_t_bits *, uintptr_t returns and atomic_uintptr_t.

Where Problem Change
struct.h SCM_STRUCT_DATA_REF/SET access slots through scm_t_bits * Access the slot as SCM, converting with SCM_UNPACK/SCM_PACK. Flag updates become read-modify-write through the setter
modules.h Module fields read with SCM_STRUCT_DATA(m)[i] SCM_STRUCT_SLOT_REF
struct.c, expand.[ch], procs.c scm_c_make_struct (…, scm_t_bits init, ...) passes SCM values as integer varargs; scm_c_make_structv takes scm_t_bits[] Both take SCM (SCM init, ... and SCM init[]). scm_i_alloc_struct takes the vtable as SCM. This is a source-incompatible API change, caught at compile time
dynstack.c Dynstack entries are scm_t_bits words holding SCM values, C function and data pointers, jmp_buf pointers Entry words are read and written through SCM * or void ** (WORD, PTR_WORD); headers stay integers
cache-internal.h, fluids.c, intrinsics.c The fluid cache stores keys and values as scm_t_bits SCM fields
options.h, options.c, print.c, private-options.h SCM-valued options (print highlight prefix, keyword style) in a scm_t_bits field The field is a union with an SCM scm_val member used for SCM options
atomics-internal.h scm_atomic_*_scm/_pointer go through atomic_uintptr_t __atomic_* builtins on the pointer types
ports.[ch] scm_c_make_port (…, scm_t_bits stream) usually gets a pointer as an integer argument The exported functions are now scm_i_c_make_port[_with_encoding] taking SCM stream. scm_c_make_port and scm_c_make_port_with_encoding are macros with the old arguments that convert with SCM_PACK ((scm_t_bits) (stream)) in the caller's expression, which keeps the capability even at -O0
smob.[ch] scm_i_new_smob/scm_i_new_double_smob take data words as scm_t_bits Take SCM. SCM_NEWSMOB* and SCM_RETURN_NEWSMOB* convert in the caller's expression and call them directly (mailutils builds its Guile module at -O0, where going through the inline scm_new_smob lost the pointer)
inline.h scm_cell, scm_words, scm_double_cell and friends take words as scm_t_bits Under __FILC__, SCM_C_EXTERN_INLINE adds always_inline, so the conversion is always visible (at -O2; see below)
vm-engine.c u64/s64 stack slots can hold code or data pointers (load-label, word-ref, pointer-ref); they were stored as integers SP_REF_U64/SP_SET_U64 (and s64, and slot moves) store and load through the slot's void * member
gsubr.c primitive_call_ip returned the call IP as uintptr_t Returns a pointer
foreign-object.c scm_foreign_object_ref returned (void *) of a scm_t_bits result Loads the slot as a pointer after the checks. scm_foreign_object_set_x stores one
vm.c After growing the VM stack, fp and sp were relocated by adding an offset, so they kept the old (unmapped) stack's capability Recompute them from the new stack's top
foreign.c, loader.c, vm.c (T *) ROUND_UP ((uintptr_t) p, a) in loops and across variables Add the rounding difference to p

Addresses that Scheme holds as integers

Scheme code sometimes turns an address back into an object: (pointer->scm (make-pointer addr)) in (system vm debug) and the disassembler (to read constants from loaded .go images), primitive-code-name on a code address, and FFI code that keeps (pointer-address p) and later calls (make-pointer addr). An integer has no capability, so make-pointer now derives one (scm_i_pointer_from_address in loader.c):

Otherwise the pointer has no capability and using it traps. primitive-code-name rebases its address onto the subr code arena.

FFI libraries also build C structs in bytevectors: guile-zlib stores (pointer-address buf) into a z_stream's next_in with bytevector-uint-set!, and zlib reads it as a pointer. So 64-bit native stores to raw memory from Scheme (bytevector-u64-native-set!, bytevector-uint-set! of size 8, and the VM's u64-set!/s64-set!) store an aligned value that make-pointer would resolve as a pointer, and the matching loads record a word that holds a pointer in that table and return its address (scm_i_store_word64/scm_i_load_word64 in loader.c). Only values in the user-space address range are looked up. With this, guile-zlib's 141 tests pass. For the same reason, a 64-bit integer argument of a foreign call is passed through the same store (Guix calls prctl (PR_SET_NAME, (pointer-address p), ...) with the address as an unsigned-long).

Weak references

libgc keeps weak references in memory it does not trace. Guile's weak sets (the symbol table, the port table), weak tables (object properties, source properties, make-weak-key-hash-table, fluid value tables) and weak vectors put keys in "pointerless" memory with disappearing links, or use a typed allocation whose bitmap omits the weak fields. FUGC ignores bitmaps and scans every pointer-typed word, so these would have been strong, and since the fields were scm_t_bits, reading them trapped.

The port keeps the disappearing links and changes how weak slots hold their referent (bdw-gc.h):

Measured with 10,000 weak-key entries of which 100 stay reachable: 100 remain after (gc), as with native Guile. Guardians and weak vectors behave the same as native too.

Continuations

call/cc in Guile copies the C stack between the continuation barrier and the capture point, and reinstating a continuation copies it back and longjmps. Fil-C does not let a program read or rewrite its stack, and longjmp only works while the setjmp frame is live. Under Fil-C a full continuation captures only the VM state (the VM stack is ordinary memory). Each scm_call_n records its jmp_buf in a list of active VM entries, and invoking a continuation checks that the entry that captured it is still active. Then it longjmps to that entry directly. This covers continuations captured and invoked in Scheme code, including generators and re-entry, which is what the test suite exercises. Invoking a continuation after the C frame that captured it has returned (for example one captured inside scm_c_eval_string and called from C later, as in test-unwind) raises "cannot reinstate a continuation whose C stack has been unwound" instead of crashing. Delimited continuations (prompts, abort-to-prompt, fibers-style suspension) never copied the C stack and work unchanged.

Smaller fixes

Remaining gaps

Testing

The Scheme test suite (test-suite/tests/*.test, run file by file with the uninstalled libguile/guile and the build platform's .go files, the same bytecode) against native Guile 3.0.11 in the same harness:

Fil-C native
PASS 40,168 40,159
FAIL 91 12
ERROR 2 3

Of the extra failures, 80 are documentation checks ("documented?", object-documentation), an artefact of the uninstalled harness: the build looks for guile-procedures.txt under its configure prefix. With those removed, the lists of failing tests are the same for both, except:

coverage.test, statprof.test, popen.test and parts of posix.test fail for both in this harness.

test-suite/standalone (C programs and scripts, run by hand): 38 pass, including test-ffi (libffi calls and callbacks), test-foreign-object-c/-scm, test-asmobs, test-extensions, the pthread and scm_with_guile tests and test-smob-mark-race. One is skipped (test-command-line-encoding, needs a locale). Five fail: test-smob-mark, test-unwind, test-out-of-memory and test-stack-overflow are the gaps above, and test-guile-snarf needs guile-snarf on PATH.

The package's installCheckPhase runs a smoke test of the installed interpreter (modules, match, format, bignums, GC, call/cc). Like Nixpkgs, the build does not run make check.

To reproduce: nix print-dev-env .#legacyPackages.x86_64-linux.pkgsFilc.guile_3_0, then unpackPhase, patchPhase, configurePhase, make -C lib and make -C libguile, and run libguile/guile with GUILE_LOAD_PATH and GUILE_LOAD_COMPILED_PATH pointing at a native Guile 3.0.11's share/guile/3.0 and lib/guile/3.0/ccache, and GUILE_AUTO_COMPILE=0.

Consumers

Built with nix build .#legacyPackages.x86_64-linux.pkgsFilc.<name> against the Fil-C Guile:

Verification status of the consumer work (branch orb/guile-consumers, cherry-picked onto batch without rebuilding there): everything above was built and tested on the orb/guile base (Fil-C pin 84cf67d), in this order. The Guile libraries, LilyPond and Guix were built with the Guile patch before its last change (64-bit FFI integer arguments carry pointers); that change was verified only with a development build of libguile (the whole test suite, foreign.test 81/81, a prctl test), not by a Nix build. On batch, all of these packages evaluate but none has been built.

Not tried yet: the other guile-* libraries. Most guile-* libraries are pure Scheme and should build like guile-json. Libraries with C parts that keep pointers in scm_t_bits storage will need the same kind of changes as libguile (see "Remaining gaps").