Determinate Nix under Fil-C
Determinate
Nix is Determinate Systems' fork of Nix. Its extra features include
parallel evaluation, lazy trees, builtins.parallel, Wasm in
the language and an async (Boost.Asio) store layer. This port builds
v3.22.5 (based on Nix 2.35.2) with Fil-C and runs its test suites. It
also runs parallel evaluation hard enough that races, use-after-frees
and out-of-bounds accesses in the fork would show up as Fil-C safety
panics.
nix build -L .#legacyPackages.x86_64-linux.pkgsFilc.determinate-nix
# the functional suite again, with eval-cores = 8 for every evaluation:
nix build -L .#legacyPackages.x86_64-linux.pkgsFilc.determinateNixComponents.nix-functional-tests-parallelResults
Builds and passes its suites.
determinate-nix(nix-everything) builds, so all of these ran and passed:Suite Result nix-util-tests 787 pass, also under enosyswithout openat2/fchmodat2nix-store-tests 722 pass nix-fetchers-tests 31 pass nix-flake-tests 23 pass nix-expr-tests 366 pass functional tests 184 ok, 0 fail, 38 skipped functional tests with eval-cores = 8andparallel-eval184 ok, 0 fail, 38 skipped The skips are the suite's own (no daemon, no network, and so on), plus the tests that need this Nix to create namespaces, plus three that hit the Fil-C unwinding bug. These numbers are from
orb/determinate-nix(main's toolchain). Onorb/determinate-nix-batch(batch's toolchain with mbrock/fil-c fa8c296, Asio fix in the global Boost), the results are:- nix-util-tests: 791 pass. The decompression tests are no longer
skipped.
ChunkedVector.ConcurrentAddneeded patch 0006 until batch pinned mbrock/fil-c 18b27e5. Without 0006 on 18b27e5 (5a1b255), the unit tests pass (util 791, store 722, fetchers 31, flake 23, expr 366) andChunkedVector.ConcurrentAddpassed 30 of 30 runs, where the previous runtime's build trapped 30 of 30. - store 722, fetchers 31, flake 23 and expr 366 pass.
- Functional tests: 188 ok, 0 fail, 35 skipped, both normally and with
eval-cores = 8. That now includes
plugins,read-only-store,binary-cacheandmultiple-outputs-substitute-failure. - race3.nix still traps (the Determinate Nix bug below).
- nix-util-tests: 791 pass. The decompression tests are no longer
skipped.
Parallel evaluation gives the same results as native Nix. With eval-cores = 8 under Fil-C, the
.drvpaths match native upstream Nix 2.35.2 in each of these:- all 25,017 top-level Nixpkgs attributes (22,484 derivations)
- four NixOS systems (minimal, server, Xfce desktop, Docker host),
forced in parallel by
nix eval --json builtins.parallelover 2,155 packages
There were no safety panics. Native Determinate Nix, built from the same scope, also matches with eval-cores 1 and 8.
One real race in parallel evaluation, caught by Fil-C as an out-of-bounds write and confirmed with ThreadSanitizer and natively (bug 1 below). Also two minor issues (bugs 2 and 3).
Porting problems in Fil-C itself:
dlsym(RTLD_NEXT), the width ofsyscall()'s result, shadow memory for hugemmapreservations, and nested exceptions during unwinding. The last one is fixed in mbrock/fil-c fa8c296.
Packaging
Nixpkgs has no Determinate Nix package, so ports/determinate-nix.nix
instantiates the fork's own packaging/components.nix. That
is the component scope that Nixpkgs vendors for upstream Nix, so the
same structure carries over. The scope sits on Nixpkgs'
nixDependencies from pkgsFilc, and the source
is the release tarball. The Fil-C overrides that the upstream Nix 2.34
port needs are now shared as nixFilcOverrides in ports/overlay.nix:
no LTO, no sigaltstack handler, fork instead of vfork, no namespaces,
seccomp off with filter-syscalls defaulting to false, and
libc's nss_dns by path. The functional tests run the Fil-C
nix (including the plugins suite), with the sandbox tests
off. The upstream nix derivation is unchanged by the
refactoring (same .drv).
What Determinate Nix needs on top of that:
- Its libgit2. libfetchers calls
git_config_backend_from_values, from the fork's libgit2 2.0 pre-release. The fork'sdependencies.nixsupplies it, applied over the libgit2 port. - No Wasm, Sentry or mimalloc. wasmtime is Rust with a JIT, and sentry-native brings crashpad. mimalloc would replace Fil-C's allocator.
- No unity build. Each library would otherwise be one translation unit, which serializes the slow Fil-C compile.
util-linuxfrom the build platform for theenosyswrapper and the functional tests. The scope is not spliced, so these would otherwise be Fil-C builds (andenosysinstalls a seccomp filter).
Porting changes (Fil-C, not Determinate Nix bugs)
The source patches are in patches/determinate-nix/. Only 0005 changes behaviour outside Fil-C.
Pointer-typed packed
Valuewords (0001). The upstream port turns off the bit-packedValuelayout, because it keeps tagged pointers inuint64_twords and Fil-C drops capabilities from integer-typed memory. Determinate Nix cannot do that: parallel evaluation lives in that layout (the atomicp0word with its thunk → pending → awaited states,waitOnThunk,notifyWaiters), and the generic layout has none of it. The patch makesPackedPointera smallFilcPackedWordstruct under__FILC__. The struct holds achar *that behaves like the integer: tag bits are set and cleared with pointer arithmetic, and integers and doubles are stored as capability-less addresses.std::atomicof a struct holding one pointer is lock-free under Fil-C and keeps the capability, so the memory-ordering protocol is unchanged. The mask becomes a plainuint64_tconstant. Nothing changes without__FILC__. The same patch should let the upstream port use the packed layout too.No
__cxa_throwinterposer (0002). Determinate Nix interposes__cxa_throw, to abort onstd::logic_error, and finds the real one withdlsym(RTLD_NEXT, ...). Fil-C'sdlsymtraps onRTLD_NEXT, so the first exception of any kind stopped the program.syscall()results truncated toint(0003). Fil-C'ssyscall()returns -1 as 4294967295.openat2andfchmodat2failures then looked like successes:fchmodatTryNoFollowreturned normally for missing files and symlinks, and a failedopenat2would have become file descriptor 4294967295.No huge reservations (0004). The fork reserves an 8 GiB mapping for each
Exprsbump allocator and 1 GiB for each symbol table. Fil-C materializes and zeroes the capability shadow of the whole mapping on the first pointer store, so everyEvalStatecost gigabytes of RSS. The expression tests reached 11 GiB and were OOM-killed; natively they peak at 117 MiB. Under Fil-C the bump allocator uses its upstream resource (the reservation is only an optimization), and the symbol arena is 128 MiB. All of Nixpkgs needs about 12 MiB of symbols.Boost.Asio's
io_contextexecutor (patches/boost-asio-io-context-executor-pointer.patch, now in the global boost187 port with a check). It keepsio_context* | bitsin auintptr_t. The asynccomputeClosure(and the rest of the Asio store code) trapped inuse_service.Test adjustments. Unlike upstream, the fork refuses to build in a diverted store without the sandbox, where upstream quietly turns the sandbox off. So besides the shared switch that turns off sandbox tests,
unprivilegedUserNamespacesSupportedreports false and the unguarded diverted-store tail ofshell.shis dropped. The exception-lifetime skips from before batch pinned mbrock/fil-c fa8c296 are gone.Failed pointer CAS without a capability (fixed in the runtime). Under contention, Fil-C's failed
compare_exchange_strongon a pointer could write back the winner's address without its capability, soChunkedVector.ConcurrentAddsometimes trapped. Patch 0006 reloaded the pointer until mbrock/fil-c 18b27e5 fixed the runtime.
The Fil-C-level findings (RTLD_NEXT,
syscall(), Asio, pointer CAS) are also in filc-findings.md.
Bugs found in Determinate Nix
1. Parallel evaluation mutates one shared exception from several threads
The standalone report for Determinate Systems is determinate-nix-addtrace-race.md.
This is a real bug: a data race in the fork's parallel
evaluator that becomes heap corruption. When a thunk fails,
force() stores std::current_exception() in a
Value::Failed
(src/libexpr/include/nix/expr/eval-inline.hh). Everyone who
forces that value later gets the same exception object back
from std::rethrow_exception. On the way up, catch sites
decorate it in place: forceInt/evalBool's
errorCtx, callFunction's "while calling the
'…' builtin", ExprSelect, and so on all call
BaseError::addTrace, which does
err.traces.push_front(...) on the shared object. With
eval-cores > 1, worker threads that reach the same failed thunk run
those push_fronts concurrently on one
std::list, without synchronization.
CloneableError::throwClone ("Useful when the exception can
get modified when appending traces") exists for exactly this, but
nothing calls it.
Reproducer (tests/determinate-nix/race3.nix):
{ n ? 20000 }:
let
bad = builtins.throw "shared failure";
xs = builtins.genList (i: builtins.tryEval (builtins.add bad i)) n;
failures = builtins.length (builtins.filter (r: !r.success) xs);
in
builtins.parallel xs (builtins.seq failures (builtins.sub 0 bad))nix eval --extra-experimental-features parallel-eval --option eval-cores 8 \
--impure --expr 'import ./race3.nix {}'Under Fil-C it stops 3 runs out of 3 with a worker thread writing out of bounds inside
std::list::push_front:filc safety error: cannot write pointer with ptr >= upper. (libnixutil.so) list:969:26: std::list<nix::Trace>::__link_nodes_at_front (inlined) (libnixutil.so) list:1247:3: std::list<nix::Trace>::push_front(nix::Trace&&) (inlined) (libnixutil.so) ../error.cc:32:16: nix::BaseError::addTrace(...) (libnixexpr.so) ../eval.cc:898:7: nix::EvalState::addErrorTrace<...>(nix::Error&, nix::PosIdx, ...) (libnixexpr.so) ../eval.cc:1746:25: nix::EvalState::callFunction(...) (libnixexpr.so) ../eval.cc:1853:11: nix::ExprCall::eval(...) (nix) eval-inline.hh:142:23: nix::ValueStorage<8ul, void>::force(...) (libnixexpr.so) ../primops.cc:1207:15: nix::prim_tryEval(...) ... (libnixexpr.so) ../parallel-eval.cc:297:89: nix::prim_parallel(...)::$_0::operator()() const (libnixexpr.so) ../parallel-eval.cc:114:13: nix::Executor::worker()Other runs report
ptr < lowerat the same place. The same panic happens with no experimental feature at all:nix eval --json --option eval-cores 8of an attribute set whose attributes share one failing thunk (race.nix). Therevalue-to-json'sparallelForceDeephands the attributes to the workers.Natively there is no crash, but updates get lost. With
--show-trace, the final error prints "(N duplicate frames omitted)", one frame for every thread that ever decorated the shared exception. eval-cores = 1 gives 19999 every time; eval-cores = 8 gave 19823, 19792, 19744, 19628 and 19790 in five runs. So 150 to 370push_fronts were lost to the race each time.ThreadSanitizer (a native TSan build of the same source,
withTSan) reports it directly: write/write and read/write races inBaseError::addTracefromExecutor::workerthreads, on a heap block allocated by__cxa_allocate_exceptioninprimop_throw. It happens for race3.nix and for thenix eval --jsonvariant. tests/determinate-nix/tsan-addtrace.txt has one full report.
Nixpkgs has plenty of shared failing thunks: aliases that throw,
meta checks, broken packages reached along several paths.
So with eval-cores > 1 this can corrupt the heap in ordinary
evaluations that hit errors, not only in contrived ones.
The single-threaded half of the problem is shared with upstream Nix
2.35 (it has Failed values too). Every later forcing of a
failed thunk prepends its frames to the one exception, so an error
reports frames from unrelated earlier forcings. In pollute.nix,
the error from builtins.sub bad 2 shows "while calling the
'add' builtin" from an earlier
tryEval (builtins.add bad 1), in both Nix 2.35.2 and
Determinate Nix.
Proposed fix (patches/determinate-nix/proposed-fix-rethrow-failed-value-copies.patch,
not applied in the port): never let anyone modify the stored exception.
force() stores a clone (throwClone) in
Value::Failed, because the failing thread's own catch sites
keep decorating the exception in flight. Every later forcing then
rethrows a fresh clone of the stored one:
if (InternalType(p0_ & 0xff) == tFailed) {
try {
std::rethrow_exception((std::bit_cast<Failed *>(p1))->ex);
} catch (BaseError & e) {
e.throwClone();
}
}Checked with native builds of the patched source:
- race3.nix gives the same six-frame trace with eval-cores 1 and 8, with no "duplicate frames".
- pollute.nix no longer shows the unrelated 'add' frame.
- A TSan build of the patched source reports no
addTraceraces for race3.nix or race.nix (two runs each). Only thetrylevelrace below is left.
The native functional suite passes with the patch, after one expected
output is updated. That output is
eval-fail-memoised-error-trace-not-mutated, whose comment
promises a fresh exception on every forcing but whose
.err.exp recorded the mutation.
2.
EvalState::trylevel is a plain int shared by
all threads
TSan also reports prim_tryEval's
MaintainCount trylevel(state.trylevel): all threads
increment and decrement it without synchronization. Only the debugger
reads it, and the debugger turns parallel evaluation off, so the effect
is harmless today. It is still undefined behaviour and pollutes TSan
output. An atomic or thread-local counter would fix it.
3. The symbol table's arena is never unmapped
ContiguousArena
(src/libexpr/symbol-table.cc) maps 1 GiB per
SymbolTable and has no destructor, so every destroyed
EvalState leaks its symbol pages, plus 1 GiB of address
space. That matters for long-lived processes and test binaries that
create many EvalStates. Patch 0005 adds the
munmap. Found while investigating the Fil-C memory blow-up
in porting change 4; not a safety issue.
What was checked and came out clean
Under Fil-C, every one of these would have trapped on a use-after-free, out-of-bounds access or capability loss in the parallel evaluator. None did:
- The thunk state machine (thunk → pending → awaited,
waitOnThunk,notifyWaiters, the waiter domains),Executorworkers,parallelForceDeepinvalue-to-json,builtins.parallel, and the concurrent symbol table andboost::concurrent_flat_maps. They ran through the whole Nixpkgs and NixOS evaluations above, at 8 threads, with results identical to native Nix. - The whole functional suite with eval-cores = 8. It differs from
eval-cores = 1 only as expected: stack-overflow errors are reported one
frame deeper (
eval-fail-toJSON-stack-overflow,eval-fail-derivation-structuredAttrs-stack-overflow), and the debugger warns that it disables multi-threading (repl/debugger-*). The parallel variant drops those cases.
Observed but not a bug: ValueStorage::isTrivial() reads
p1 before p0. That can pair a finished value's
payload with a still-pending tag and dynamic_cast it as an
Expr *. Its only caller is flake input parsing, which is
single-threaded.
Memory: Fil-C Determinate Nix needs about 2.5 to 3 times native RSS. Four NixOS systems peak at 8.2 GB against 2.5 GB natively. The Nixpkgs sweep was therefore run in 16 chunks of about 1,560 attributes each, peaking at 7 to 10 GB per process (tests/determinate-nix/drvs-chunk.nix).