Skip to content

Kernel support - #1

Closed
Lyndeno wants to merge 36 commits into
tomberek:masterfrom
Lyndeno:kernel-support
Closed

Lyndeno wants to merge 36 commits into
tomberek:masterfrom
Lyndeno:kernel-support

Conversation

@Lyndeno

@Lyndeno Lyndeno commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Build the full stock NixOS kernel using dynamic derivations and nixgg. Needed to add shims for partial links, objcopy, objtool, and rustc. Also added a vmtest that boots with this kernel, ensuring we didn't accidentally vibe the functionality away.

Mostly vibes. Needs cleanup

Claude Brainslop Dump

Teach nixgg enough to build a Linux kernel, and prove it boots.

Everything here came out of making one real workload work end to end. The
kernel is not the point — it is the forcing function: 29,268 derivations
in one build, and every gap it exposed was a gap for other projects too.

What it adds

Four new shims. ld -r partial links, two-operand objcopy, an
in-place object rewriter (objtool), and rustc crate compiles. A
kernel needs all four; without any one of them the build silently stops
being accelerated rather than failing.

Thin archives as inputs. A thin archive (ar T) stores member
paths, not bytes, so storing one whole loses everything it points at —
silently, surfacing much later as undefined references at the final link.
Foreign thin archives are now expanded and their members depended on.
This complements the members-sidecar mechanism on master, which covers
archives nixgg itself produces; the two halves are disjoint.

Caller-declared passthrough. The subtrees that read object bytes
inline (realmode, libstub, vDSO, purgatory, scripts/mod,
test_fortify) are Linux-, x86- and version-specific. They used to be
compiled into internal/mode; they are now a passthroughPaths
parameter, so general-purpose code stops carrying kernel trivia.

Shared staging. Staging each TU as a symlink farm into per-file store
objects instead of copying. Header closures overlap heavily across TUs —
a kernel otherwise stages ~150 GB of duplicated headers.

Four splitStdenv correctness fixes, each found by a build that
produced no error worth reading: honour the package's own
__structuredAttrs rather than forcing it off; submit the build tree
from a real phase rather than a postBuild hook (hand-written
buildPhases that never call runHook submitted nothing and died on
Nix's opaque "failed to submit output path"); carry phase 1's exported
shell environment into the final stage; and keep the shims reachable but
inert there.

Plus smaller shim fixes — -I ordering, .incbin sources, dependency
files, empty archives, store-object name sanitisation — and four
fixtures: the kernel, its boot test, an out-of-tree kernel module, and
two Rust crates.

Verification

Cold kernel build: 2h54m, 29,268 derivations (~19.1k compiles, ~7.3k
objtool, ~2.1k partial links, ~800 archives, 29 objcopy, 3 rustc).

The boot test is what makes this evidence rather than a claim. It runs
the kernel under QEMU and asserts it reaches multi-user.target, that
uname -r matches modDirVersion, and that modprobe looplsmod
losetup -f all work. That last one is the assertion that earns its
cost: the thin-archive bug produced a kernel that passed every structural
check while missing whole subtrees. "It built" is not evidence.

Both fixtures are opt-in and run by hand — not wired into flake.nix,
nix flake check, or the test scripts, which are meant to finish in
minutes. See tests/kernel/README.md.

Also green: drv-equivalence (all fixtures), both cutoff tests,
shared-closure, kernel-passthrough, and the smoke set.

Notes

  • Two smoke failures are reproducible on master and unrelated to this
    branch: nix run .#ffmpeg (the harness invokes it bare, which exits
    non-zero) and llvm (a real regression — llvm-min-tblgen fails to
    link with undefined FoldingSetBase symbols; the failing derivation
    hash is byte-identical from both trees). internal/expr's two batch
    tests assume PATH=/usr/bin, which has no mkdir on NixOS.
  • tests/kernel/README.md's derivation count is updated to match the
    measurement above.

@Lyndeno

Lyndeno commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

I need to badly clean up the commit history

@Lyndeno

Lyndeno commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebasing now

Lyndeno and others added 28 commits September 9, 2026 19:44
It was forced off in both phases. A package that sets it writes its
phases against it, using bash array syntax — `make "${makeFlags[@]}"`,
`installFlags+=(...)` — which does not exist when structuredAttrs
is off. Forcing it false turned those into silently wrong strings
rather than an error.

It was forced off for a reason: with structuredAttrs,
make-derivation.nix routes bare top-level attrs into .attrs.json
and only `env` reaches the derivation as environment variables, so
the build stage's `out = "/nonexistent"` placeholder stopped being
set. Put the placeholder wherever the chosen mode actually reads it
instead, and follow the package.

The default matches make-derivation.nix's own, so a package that never
mentions __structuredAttrs still inherits the nixpkgs-wide setting.

with '#' will be ignored, and an empty message aborts the commit.  #
progress; onto 9c3a692 # Last command done (1 command done): #	  reword
256d876 # splitStdenv: honour the package's own __structuredAttrs #
Next commands to do (37 remaining commands): #	  reword 989d42d #
splitStdenv: submit the build tree from a phase, not a postBuild
hook #	  reword ab12ac7 # splitStdenv: carry the build stage's
shell environment into the final stage # You are currently editing
a commit while rebasing branch 'kernel-support' on '9c3a692'.  # #
Changes to be committed: #	 modified:   nix/splitStdenv.nix #
postBuild only runs if the package's buildPhase calls `runHook
postBuild`, and plenty of hand-written buildPhases do not — nixpkgs'
own linux-config among them. Those packages built fine, submitted
nothing, and failed with Nix's opaque "failed to submit output path
for 'out'", with no hint that a hook had been skipped.

setup.sh splices postPhases onto the end of the phase list
unconditionally, gated by no dont*/do* toggle, so a real phase always
runs. The build stage disables everything after buildPhase, so it
lands immediately after it.

with '#' will be ignored, and an empty message aborts the commit.
in progress; onto 9c3a692 # Last commands done (2 commands done): #
reword 256d876 # splitStdenv: honour the package's own __structuredAttrs
not a postBuild hook # Next commands to do (36 remaining commands): #
reword ab12ac7 # splitStdenv: carry the build stage's shell environment
into the final stage #	  reword cb5050d # splitStdenv: keep the shims
reachable but inert in the final stage # You are currently editing
a commit while rebasing branch 'kernel-support' on '9c3a692'.  # #
Changes to be committed: #	 modified:   nix/splitStdenv.nix #
Splitting one mkDerivation across two derivations splits the SHELL too,
and packages routinely export a variable in an early phase and read
it in a later one. nixpkgs' kernel does exactly this: configurePhase
does `export buildRoot=...` and linux-config's installPhase is `mv
$buildRoot/.config $out`, which in the final stage became `mv /.config`.

The build stage dumps its exports; the final stage replays them to fill
gaps only. That single rule is what makes it safe — the final stage's
own outputs, NIX_BUILD_TOP, PATH and every stdenv-managed variable are
already set, so the build stage's deliberately bogus /nonexistent values
cannot clobber them. What is left is the package's own bookkeeping.

Two categories are skipped rather than gap-filled, because the final
stage legitimately does not define them and must not inherit them
either: PATH and NIXGG_* would drag the shims into a phase meant to
run real tools, and phase-control attributes would be worse. Derivation
attributes are exported like any other variable, so the build stage's
`dontInstall = true` arrives as dontInstall=1 and the final stage
would gap-fill it and then skip its own installPhase — a silent
failure where the builder exits 0 having run nothing.

Extracted from the shared-staging change, which had bundled it.

with '#' will be ignored, and an empty message aborts the commit.
in progress; onto 9c3a692 # Last commands done (3 commands done): #
reword 989d42d # splitStdenv: submit the build tree from a phase, not
a postBuild hook #    reword ab12ac7 # splitStdenv: carry the build
stage's shell environment into the final stage # Next commands to do
(35 remaining commands): #    reword cb5050d # splitStdenv: keep the
shims reachable but inert in the final stage #	  pick bcb8683 # scan:
treat -Wp,-M* as dependency plumbing, not compile flags # You are
currently editing a commit while rebasing branch 'kernel-support'
on '9c3a692'.  # # Changes to be committed: #	    modified:
nix/dynDrvShared.nix #	     modified:	 nix/splitStdenv.nix # #
Untracked files: #	 .kernel-run.sh #
The final stage restored the tree and ran the package's own install
with no NIXGG_* environment at all. That is fine only if nothing in
the final stage invokes a shim, and things do: build systems bake
absolute tool paths at configure time — cmake writes one into its
generated Makefile, and a caller can put one in makeFlags — so those
paths are invoked in the final stage whether or not we planned for it.

Without the environment the shim cannot construct its config and
exits non-zero before doing any work, which the build then reports
as a broken toolchain rather than as anything to do with nixgg.

Export the same environment the build stage uses, with
NIXGG_BYPASS=1. Reachable, so a baked-in shim path still resolves;
inert, because everything the build stage modelled is already built
and restored, so each shim execs the real tool.

with '#' will be ignored, and an empty message aborts the commit.  #
in progress; onto 9c3a692 # Last commands done (4 commands done):
environment into the final stage #    reword cb5050d # splitStdenv:
keep the shims reachable but inert in the final stage # Next commands
to do (34 remaining commands): #    pick bcb8683 # scan: treat -Wp,-M*
as dependency plumbing, not compile flags #    pick 1eae936 # shim:
write the dependency file the caller asked for # You are currently
editing a commit while rebasing branch 'kernel-support' on '9c3a692'.
The scanner discovers a translation unit's headers by running
`gcc -M -MG -MF -` and reading stdout. A caller that passes
`-Wp,-MMD,<file>` redirects dependency output to that file instead, so
the scanner reads nothing, reports zero headers, and stages only the
source.

That fails late and sometimes not at all: a TU including nothing local
still compiles, so the bug hides until some other TU misses a header
inside its derivation. Builds that pass this flag on every compile —
kbuild does — get it on every TU.

The flag is also invalid inside the derivation itself, for the reason
parseCompileArgs already documents for the bare -MD/-MMD forms: the path
is relative to the caller's cwd, and the derivation's cwd is a read-only
store path.

Stripping lives in one place, shared with the compile shim, so the two
cannot disagree about what counts as dependency plumbing.
stagedIFlags forced "-I." to the front, on the reasoning that the staged
root must be on the include path. It must — but putting it first makes
the staged root shadow every other -I directory.

Include order is semantics: the first directory containing the named
file wins. A project with two same-named headers reachable from
different -I directories — a private one beside the source and a public
one under an include/ tree — compiles against whichever the order
selects, so hoisting silently picks the wrong one. The failure is a type
error inside the header, nowhere near the flag that caused it.

Now the caller's directories are emitted in their original order,
including their own "-I." at the position they chose, and a synthetic
"-I." is appended only when they never named the root (a bare
`cc -c foo.c` still needs it). Last, so it cannot shadow anything.

One existing test asserted the old ordering. It encoded the bug, so it
is updated rather than worked around.
Build systems ask the compiler questions by compiling /dev/null:

  $(CC) -Werror $(FLAGS) <option> -c -x c /dev/null -o "$$TMP"

autoconf emits this idiom and so does kbuild, whose cc-option and
as-option key purely on the exit status. That makes any error the shim
raises indistinguishable from "the compiler does not support this
option", and the build then proceeds — quietly — with a degraded flag
set. The eventual failure is a #error or a missing define somewhere
unrelated, with nothing pointing back here.

A non-regular source cannot be modelled anyway: there are no bytes to
content-address, and a character device has no NAR representation, so
adding it to the store fails outright. The only correct response to a
probe is the real compiler's verdict.

Copying staging survived these by accident — reading /dev/null yields
empty bytes, so it staged an empty file — which is why the guard belongs
here rather than in any particular staging strategy.
`.incbin` embeds a file's bytes at assembly time, naming it in a string
the preprocessor never reads. `gcc -M` reports #include dependencies, so
it cannot see that name: the file is absent from the staged tree and the
TU fails in the assembler rather than the compiler, with an error that
names neither the source nor nixgg.

Teaching the scanner about it would not help either — the named file is
usually generated during the build, so it is not a static dependency.

Detection is a substring search on purpose. The directive appears inside
C string literals, in .S files, and behind #ifdefs, so anything short of
running the preprocessor and assembler is an approximation, and the
costs are asymmetric: a false positive is one un-accelerated compile, a
false negative is a build failure far from its cause.
Two gaps in the ar parser, both of which sent the whole invocation to
passthrough — where the real ar meets drvref stubs instead of objects.

`T` (thin archive) was missing from the modifier alphabet, so a single
unrecognised character rejected every `ar cDPrST` invocation. Build
systems that use thin archives use them for everything.

`.a` members were rejected outright, but a nested archive is a
legitimate member: build systems list a subdirectory's archive alongside
its own objects to aggregate a tree.

Allowing `.a` members removed an accident. The positional-argument
modifiers `a`, `b`, `i` and `N` take a count or member name that follows
the modifier string and shifts the archive one slot right; those were
previously rejected only because the archive name failed the
members-must-end-in-.o check. That check is now gone, so the guard is
explicit — those forms are member mutations this shim does not model.
Some build steps rewrite an object in place rather than producing a new
artifact from sources — objtool is the motivating case, invoked on each
object right after it is compiled. Under nixgg the object is a drvref
stub at that moment, so the real tool sees what looks to it like a
corrupt ELF and fails.

Modelled as its own derivation kind: take the producing drv as input,
copy its object, rewrite the copy, and leave a fresh stub behind. The ar
and link shims already resolve stubs, so nothing downstream changes.

The tool binary is often built by the build itself, inside the sandbox,
so it is an ordinary file rather than a store path. It is added to the
store first; because it links only against store paths, reference
scanning records the right closure. The result is memoised per build,
since every object asks for the same binary and a fork+exec per object
would cost more than the compile derivations this is protecting.

Reached only when the caller points the build's own tool variable at the
shim. Build systems that pass absolute tool paths do so precisely to
defeat PATH interposition, so PATH alone would never route here.
Build systems fuse several objects into one with `ld -r` before
archiving or linking them. Under nixgg the inputs are drvref stubs at
that point, so the real linker fails on them.

Deliberately only `-r`. A partial link is object-in, object-out, which
models cleanly as a derivation. A full link needs linker scripts,
library resolution and search paths, and every full link nixgg already
handles goes through the compiler driver rather than ld directly — so
anything that is not `-r` passes through untouched.

The real linker is taken from NIXGG_REAL_LD when set, rather than
guessed from the compiler's directory: a project may need the unwrapped
ld, and the wrapper is not always a drop-in substitute.
A build step that reads one object and writes another — prefixing
symbols, stripping sections — meets a drvref stub under nixgg and
reports "file format not recognized".

Same derivation kind as the in-place rewriter, different operand shape:
this reads one file and writes another, so nothing has to be copied out
of the store first, and the binary is normally already a store path so
it needs no store-add step.

Only the two-operand form is modelled. objcopy's other shapes — in-place
rewrite, --only-section extraction to stdout — are left to passthrough.

Adding the shim is not enough on its own: a build that passes absolute
tool paths needs OBJCOPY= pointed at it explicitly, exactly as with the
other non-compiler tools.
Not every input to a link or archive comes from a shim: a project may
compile some objects with a tool nixgg does not model, leaving ordinary
files on disk.

Bailing on those is not a local decision. An unmodellable input makes
this archive passthrough, which makes it a plain file, which makes its
parent unmodellable in turn — the same cascade an empty archive causes,
climbing the whole tree.

So put the file in the store and depend on its content. It is
content-addressed, so this stays deterministic, and the cost is one
store-add for an input that is rare by construction.

Sandbox mode only. Native mode has no cascade to break — its inputs are
thunks on disk, and a plain file there means the caller is doing
something deliberately unmodelled — and adding a store round-trip would
change drv content for builds that work today.

Staged into a one-file directory rather than added bare, because both
serializers render an input as Ref+"/"+Name and expect Ref to be a
directory, which is the shape every drv output already has.
A GNU thin archive (`ar` with T) stores PATHS TO its members, not their
bytes, and build systems nest them — an aggregate archive lists a
subdirectory's archive beside its own objects.

That works while every member path stays resolvable: a modelled
sub-archive records its members as absolute store paths, which resolve
anywhere. It breaks for a sub-archive nixgg did not model. Those arrive
as ordinary files, and storing one alone leaves its siblings behind, so
when the parent `ar` flattens the nested archive every member resolves
to nothing and is dropped.

Silently — ar reports no error, the archive is simply smaller. The
damage surfaces much later at the final link, as undefined references to
symbols whose objects were quietly discarded.

classifyInputs now expands a thin archive into its members and depends
on those, via a worklist since members can themselves be thin archives.

Parsed directly rather than shelled out to `ar t`, because ar tries to
resolve the members it lists: on exactly the broken archives this fixes,
it fails with a message naming the archive rather than the member.

Tests build real archives with `ar`, including a path long enough to
exercise the "//" long-name table. Two notes worth keeping: ar must be
located in the store when it is not on PATH, or these tests SKIP in the
environment where a parser regression would go unnoticed; and an EMPTY
archive is written as plain "!<arch>", so it is correctly not thin and
falls through to being stored whole, which is harmless since it has no
member paths to lose.

The "//" long-name table and the "/" symbol index are the only members
carrying data, and both are bounds-checked against the PADDED length.
ar pads members to an even boundary, so an odd-sized member running
exactly to the end of the file has no pad byte; guarding on the raw size
would slice past the end and panic inside the shim mid-build instead of
falling back to real ar.
Nix restricts store path names to [A-Za-z0-9+._?=-] and rejects anything
else outright. Naming a store object after the file it holds puts
arbitrary source filenames through that check, and real source trees
contain names Nix will not accept — a comma is enough.

Renaming is safe. The name is cosmetic: a staged tree references the
object at its own path, and identity comes from the content hash. Two
filenames can map to the same name, which only matters if their content
is identical too, in which case sharing one object is right.

Applied inside StoreAddScan rather than at each call site, because that
is the exact boundary where Nix's naming rules take effect, so no future
caller can miss it.

The length clamp runs before the leading-dot trim, not after. Nix caps
names at 211 chars, so an over-long name is truncated to its last 200 —
and truncating can expose a dot that was interior in the full string,
which Nix then rejects. Trimming first would let exactly that case
through. The property test carries an input whose dot lands on the
truncation boundary.
nixgg writes staged source trees, thunks and memo caches under the build
root. `nixgg assemble` captures that root and hands it to
`nix store add --scan`, which records a reference for every store path
it finds — and a memo file's entire content IS a store path.

So scratch left at the build root adds one reference per memo to the
captured tree's closure, and Nix bind-mounts that closure into the
sandbox of every derivation consuming the tree. At scale that exceeds
the kernel's mount limit and fails as "bind mount ... failed: No space
left on device" on a disk that is nowhere near full — a message that
names neither the cache nor the assembly.

Two halves to the fix:

Rooting all shim scratch under .nixgg, which assemble already excludes,
so a new cache is excluded by construction. Adding each new name to a
denylist in another package is the alternative, and it fails silently.

Applying skipNames at every depth. StageForScan filtered only its own
top-level ReadDir, but the scratch dir sits at the project root, usually
several levels down. Walk already skipped by name at any depth, so the
two halves of the assembly disagreed about what counts as build output.

Not every dot-entry at the build root is scratch — dynDrvStdenv writes
files there for phase 2 to read back, some referencing store paths
legitimately — so this is a positive convention rather than a blanket
exclusion.
A drvref stub's whole content is a /nix/store/….drv path, and
`nix store add --scan` records every store path it finds. So copying
stubs verbatim makes the captured tree reference every producing
derivation — and a derivation's closure contains its own inputs, which
for a compile is its entire staged source tree.

Nix bind-mounts that closure into the sandbox of every derivation that
consumes the tree. At scale it exceeds the kernel's mount limit and
fails as "bind mount ... failed: No space left on device" on a disk that
is nowhere near full, pointing at neither stubs nor staging.

Blanking them is safe because of ordering, not luck: cmdAssemble calls
Walk on the ORIGINAL root and already holds every stub's drv path before
StageForScan runs. The staged copy needs only the tree's shape, and the
assembly overlays the real artifact over each stub anyway, so those
bytes are dead.

An empty file is kept rather than skipping the entry, because recipes
stat their outputs and the overlay's `cp -a` wants the path to exist.
Staging copied every header into every TU's tree. The same headers
recur across translation units — roughly 17x in sampled builds — so a
large project stages the same bytes over and over, and the total runs to
tens of gigabytes.

With sharedStaging the staged tree becomes a farm of symlinks into
per-file, content-addressed store objects: identical content is stored
once. Measured several times smaller on examples/gcc, and the ratio
grows with TU count.

Off by default, and sandbox-only, because it suspends the project's core
invariant. It relies on `nix store add --scan` recording symlink targets
as references, and native mode imports its staging dir as a plain path
literal, which does no scanning — the targets would dangle. That
asymmetry means native and sandbox no longer produce identical drvs, so
it is opt-in via a parameter on both mkNixggBuild and dynDrvStdenv.

Two properties now have tests, because content-addressing works through
an indirection here rather than directly:

  - a header edit must change the farm's hash, or dependents reuse a
    stale compile. Copying got this free; symlinking gets it only
    because the target path is itself content-derived.

  - the farm must REFERENCE its symlink targets, which is what --scan
    records. That cannot be unit-tested — --scan only works inside a
    recursive-nix builder — so tests/shared-closure.sh builds for real
    and inspects closures. It also documents why that half is less
    fragile than it looks: Nix mounts only an input's closure, so a
    missing reference fails the compile outright rather than silently.

flake.nix gains .#<name>-shared for each example, generated from the
same definitions so they cannot drift.
Some subtrees cannot be modelled as derivations at all. Their build
reads object BYTES inline — piping nm or objdump output into a generated
header or a grep that decides whether to fail — or it compiles something
it EXPECTS to fail and reads the diagnostic. In both cases the answer is
needed before the build continues, and a derivation cannot supply it: one
that fails simply fails the build, where the caller meant to inspect the
error and carry on.

That is a different reason from Realise, which exists for builds needing
a runnable artifact immediately, so it gets its own mode rather than
overloading that one.

WHICH subtrees behave this way is not general — it is specific to a
project, often to its target architecture and version. Compiling a list
into a general-purpose tool would make every such change a Go change and
leave dead string comparisons in every other project. So the paths come
from a `passthroughPaths` parameter on dynDrvStdenv and mkNixggBuild,
exported as a JSON array with the same eval-time-computed contract as
the existing known-store-paths manifest — both modes must export it
identically or native and sandbox drv hashes diverge.

Rules keyed on build-system CONVENTION stay compiled in: autoconf's
conftest and cmake's probe files are emitted by those tools in every
project that uses them, so requiring callers to declare them would be
noise.

Matching is substring-based: an entry names a subtree, and both a source
and the object built from it must match, from whatever directory the
build happens to be in. Empty entries are dropped — one would match
every path and silently pass the entire build through, which looks like
nixgg accelerating nothing rather than like a configuration error.
A declared subtree is about where the object LANDS, not where its source
lives, and the two differ: a build may compile a source from a shared
directory into a subtree that then reads the object's bytes.

Keying on the source alone let those objects through as stubs, and
whatever the subtree does with them — strip, objdump, nm — failed on a
file that was not an object.
A declared subtree means nixgg models nothing there, but only the
compile shim consulted it. That was survivable by accident: compiles
passed through, so the archive and link shims met plain files, bailed,
and the whole subtree fell through together.

Store-adding unmodelled inputs removed the accident. Now a shim can
happily model an artifact inside a subtree whose siblings were passed
through — and something downstream that was passed through then meets a
stub and fails on a file that is not an object.

So every shim that produces an artifact checks directly rather than
inheriting the decision from its inputs.
The smallest fixture that exercises a real kbuild: an out-of-tree module
built against the running kernel's headers, so it uses kbuild's own
machinery — thin archives, modpost, the .cmd files — without the hours a
full kernel takes.

Two phases because kbuild's module link consumes objects it just
produced, which is the same shape the full kernel forces at a larger
scale. Phase 1 models the compiles; phase 2 runs the link against the
realised outputs.

Wired into smoke.sh so the module builds on every run.
A new tool role rather than a reuse of the compiler ones. rustc's unit
of work is a crate, not a translation unit: one invocation consumes a
source file plus every module, `include!` and macro file it reaches,
and may emit several artifacts from it. Nothing in the cc/c++ argv
grammar carries over.

Basename() returns "rustc" so the derivation names a tool that exists
inside the sandbox, the same rule the six compiler roles follow.

Nothing dispatches here yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
rustc's @-file format is not the compiler drivers': each line is exactly
one argument, taken verbatim, with no quote processing. Tokenising it the
compiler-driver way strips quotes rustc requires — a kernel's generated
cfg file holds

    --cfg=CONFIG_RTC_DRV_CROS_EC="m"

and rustc rejects the unquoted form outright:

    error: invalid `--cfg` argument: `CONFIG_RTC_DRV_CROS_EC=m`

That was the first rustc invocation of a kernel build, before the shim
did anything else. In the other direction a value containing spaces,
legal on one line here, would be split into several arguments.

Both expanders share the walk; only the per-line split differs. Nothing
calls the rustc one yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
Rust has no preprocessor, so the question `gcc -M` answers can only be
put to the compiler front end. rustc answers it directly: dep-info
lists every file the crate reaches, including `include!`,
`include_str!` and `include_bytes!`. That last group is what `gcc -M`
cannot see — the `.incbin` case the compile shim has to pass through —
so for Rust it comes for free.

The scan runs against dependencies that are drvref stubs, so rustc
reports an unresolvable extern and exits non-zero. It writes dep-info
anyway: collection happens during expansion, before crate resolution.
Measured across a missing extern, a stub extern and no extern at all —
all three produce the same complete dep list as a build with the real
rlib present, and the test pins it.

It is handed the caller's argv verbatim rather than a reduced flag
list. Stripped of `-L` and `--extern`, a crate compiled under
`--sysroot=/dev/null` — how a kernel builds every Rust crate — cannot
resolve `core`, and rustc then fails at the driver level, before
writing any dep-info at all. rustc's stderr is kept and reported when
the dep file is missing, because "scan failed" alone says nothing about
a crate that then silently builds unaccelerated.

The caller's --emit/--out-dir are replaced with a scratch dir. Left in
place, a scan compiling against stub externs would drop a broken object
exactly where the build expects a correct one.

Run's cache handling moves into readCache/writeCache so both scanners
share it; no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
Pre-existing: TransformJSON's field values were misaligned, so any
later edit to the file would carry the realignment as unrelated noise.
Whitespace only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
A new Kind rather than a variant of KindCompile, because the shape
differs on both ends. One front-end run consumes a whole source tree and
emits several artifacts from it — an object, plus the .rmeta dependent
crates resolve `--extern` against — so they share a derivation;
splitting them would compile the crate twice.

Every input contributes a `-L` search path, and those the caller named
also get an explicit `--extern <crate>=<path>`. Both are needed. The
explicit binding turns a filesystem search into a derivation input: the
caller's bare `--extern <crate>` searches `-L` dirs that are the build
tree, absent inside the sandbox and holding drvref stubs outside it. The
search paths are for the crates the caller never named — a crate's own
dependencies travel in its metadata, so a kernel driver names two
externs and rustc then loads ten crates.

Plain `-L`, not `-L dependency=`: the narrower kind only joins the
search for a crate's own dependencies, while rustc looks for `core` —
which a kernel compiles itself and passes here like any other crate —
on the unrestricted path. Measured against the kernel's real artifacts,
one directory per crate: `dependency=` dies on "can't find crate for
core", plain `-L` produces the object.

A store input's search path stays flat. Only a sibling drv places its
artifact under an FHS subdir; applying it to both would point at a bin/
that does not exist, which is every proc-macro, since those are always
passed through and so always arrive as store inputs.

.rmeta and .rlib join .o as flat artifacts — intermediates only the next
rustc reads, so FHS placement buys nothing.

Nothing constructs one of these yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
Parse the command line, resolve every crate the compile can reach, scan
the crate, stage it, and emit one derivation with a drvref stub per
artifact. All the artifacts share one derivation because rustc produces
them from one front-end run, so a dependent crate resolves `--extern`
against the .rmeta by filename inside that same output.

Crate inputs are the ones named by `--extern` plus every crate in the
caller's `-L` dirs. The unnamed ones are needed because a crate's own
dependencies travel in its metadata, not on the command line — a kernel
driver names two externs and rustc then loads ten crates. That
over-approximates: a crate merely sitting next to a real dependency
becomes an input. The exact answer needs `-Zbinary_dep_depinfo=y`, which
the caller may not pass and which this shim cannot add on stable, and
search dirs hold a project's own crates and little else.

The caller's dep file is written here rather than by the derivation,
whose emit flags were replaced; kbuild's `if_changed_dep` runs fixdep
over it and hard-fails when it is missing.

Four invocation shapes bail into passthrough, and each runs during an
ordinary build:

  - proc-macro crates. rustc dlopen's them to expand a dependent
    crate's macros, including during that crate's dependency scan.
    Defer one and every downstream scan silently loses whatever its
    macros pull in.
  - `--print`, `--version`, `-Zunpretty`: questions and stdout, not
    artifacts. A build asks several before compiling anything.
  - an `--emit` kind with no path. A derivation cannot write a stub for
    a file it cannot name.
  - a crate that cannot be modelled. Dropping one would compile against
    a dependency Nix does not know about.

The last of those is loud and the rest are quiet, deliberately: an
unexpected operand count means a two-argument flag is missing from
rustcSeparatedFlags, and that bail is invisible otherwise — it left
every Rust file in a kernel build unaccelerated with no error and no log
line. The table is pinned by the command line kbuild recorded for a real
Rust driver.

Unlike every other shim this one is reached through PATH: kbuild's RUSTC
defaults to a bare `rustc` and nixpkgs does not override it. So the
fallback skips any candidate that resolves to this executable, or the
build forks until it dies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
Lyndeno and others added 8 commits September 9, 2026 19:44
Rust has no `-D` for build-time constants, so a crate that needs one
reads the environment and rustc bakes the value in:

    include!(concat!(env!("OBJTREE"), "/rust/uapi/uapi_generated.rs"))

That makes the variable part of the compile's input. Unset inside the
sandbox it is a hard error; passed through unchanged it names a
directory the sandbox does not have, and rustc points at the include
rather than at the variable.

So declared variables travel, with any value inside the project root
rewritten onto the staged tree — the same content, at the path the
sandbox actually mounts it under.

Declared rather than inferred, like NIXGG_PASSTHROUGH_PATHS: nothing on
the command line mentions these, and carrying the whole environment
would put the caller's PATH and TMPDIR into every drv hash, so no two
machines would share a cache entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
rustc names a custom target after its spec file's STEM, and a crate's
metadata is only loadable by a compile whose target has the same name.
So the filename is load-bearing, and staging does not preserve it:
shared staging replaces the staged file with a symlink into a per-file
store object called `<hash>-target.json`, rustc follows it, calls the
target `<hash>-target`, and nothing matches —

    error[E0463]: can't find crate for `core`
      = note: the `pr5ngxn…-target` target may not be installed

with `core` itself compiled for a target named `target`.

storeAddLooseFile keeps the basename intact inside its own directory, so
the stem stays `target` however the file arrived. That also makes the
spec an explicit derivation input rather than a tree entry smuggled in
beside the sources.

An explicit flag list rather than "any value that looks like a path",
for the reason objcopyTwoArg is one: a flag's value is not
self-identifying, and guessing wrong either drops a real input or
rewrites something that was never a path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
A derivation's build script is one argument to `bash -c`, and execve
caps a single argument at MAX_ARG_STRLEN — 32 pages, 131072 bytes, a
compile-time kernel constant with no runtime knob.

Rust reaches that where C does not. A kernel's generated cfg file is
19,746 lines and 601 KB, one `--cfg=CONFIG_…` per config symbol, and
rustc takes it as an @-file precisely so it never has to be a command
line. Expanding it inline to model the compile put all 601 KB back into
one argument — 4.6x over — and the builder died with "Argument list too
long", naming nothing.

So an oversized flag list goes back into an @-file, this time a store
object. That is not a size trick at correctness's expense: the object is
content-addressed and enters the derivation as an input, so the compile
still depends on every flag byte exactly as it did inline, and two
crates with identical flags share one object.

Below the threshold nothing changes, so ordinary builds keep the
legible inline form and their existing drv hashes. storeDeps is computed
before the spill, or the store paths the flags mentioned would stop
being mounted once they collapse to a single `@<path>`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
The kernel fixture is the only other thing that reaches the rustc shim,
and it is opt-in and hours long, so nothing in the fast suite would
catch a regression here. This builds in seconds.

Deliberately not cargo: cargo drives rustc through its own protocol and
picks the compiler from a toolchain file rather than PATH, so a shim
never sees the invocation. A build system that calls rustc directly
does, which is the shape the shim models.

Covers what nothing else does — one invocation emitting two artifacts,
a crate compiled against a dependency that is still a drvref stub,
`include!`, and an @-argfile. The last is self-checking: app/main.rs
carries a compile_error! that fires if the cfg the argfile supplies goes
missing, so mistokenising it fails loudly rather than silently dropping
a cfg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
The line named six of the ten shims — objtool, ld and objcopy were
already missing, and rustc would have made it a fourth. A reader
looking up which tools nixgg intercepts got a wrong answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
A Linux kernel built through nixgg's dynamic derivations, and the guard
for the one thing about it that is easy to break silently.

Not wired into drv-equivalence.sh, smoke.sh or the flake, and nothing
references it: a cold run models ~22,000 derivations over hours, where
the regular scripts are meant to finish in minutes. Keeping it out of
flake.nix also keeps it out of `nix flake check` and `nix flake show`.

This is where the project's kernel-specific knowledge lives. The
passthroughPaths list names six subtrees that build by reading object
bytes inline or by compiling something they expect to fail, each
annotated with what it does that defeats modelling. They are specific to
Linux, mostly to x86, and to a kernel version, which is why they are a
parameter rather than a list inside internal/mode.

NIXGG_RUSTC_ENV is the same kind of knowledge for Rust: three variables
the crates read at COMPILE time that nothing on the command line
mentions. OBJTREE reaches bindgen's generated bindings through
include!(concat!(env!("OBJTREE"), …)); RUST_MODFILE names the module for
the `module!` proc macro; RUSTC_BOOTSTRAP is exported by Makefile:627 so
a stable rustc accepts the -Z flags kbuild relies on, without which the
derivation's rustc rejects them outright.

kernel-passthrough.sh guards the passthrough list by evaluation alone,
~1s, no build. It reads the value out of the real phase-1 derivation
rather than re-reading a Nix list, so it also proves the plumbing:
mutation-tested against a dropped entry, a stray "" (which
substring-matches every path and would silently pass the whole build
through), and the parameter being dropped between dynDrvStdenv and the
shims — a list-vs-list check would pass on that last one.

It is a change-detector by design. Whether these six are the RIGHT
subtrees is only answerable by actually building, which the next commit
covers.

README.md records the operational traps, each of which cost a run: the
daemon store rejects text-hashed outputs, the flake's own nixConfig is
read too late to enable the experimental features, fs.mount-max needs
raising and reports a full disk when it is not, and liveness must be
judged by log growth because `pgrep -f` matches the checking command's
own argv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ncWRneGyLsBNrEqKJR8r5
Structural checks — correct bzImage headers, a complete symbol table,
the expected module count — rule out obvious corruption and nothing
else. The failure modes this design actually produces are subtler: a
dropped object, a mis-ordered archive member, a stale command record.
Each leaves an image that passes every structural check and dies at
boot.

That is not hypothetical. The thin-archive bug silently dropped three
whole subtrees while producing a vmlinux.o of plausible size with a
valid ELF header; only the final link caught it, and only because some
symbols happened to be referenced. A quieter version of the same bug
would have built, installed, and panicked.

So boot it under QEMU and assert three things, in increasing order of
what they catch: multi-user.target is reached; `uname -r` equals the
derivation's own modDirVersion, so it cannot pass against a stale
literal or a silently substituted fallback kernel; and `modprobe loop`
plus `losetup -f` succeed.

The third earns its cost. Loading a module exercises modules_install,
depmod's dependency table and modpost's symbol versioning together,
which is the closest cheap proxy for "this kernel is coherent" rather
than merely "this kernel linked".

Like the fixture it depends on, this is run by hand — it needs the full
kernel build first, then a NixOS closure and QEMU. Nothing references
it.
NIXGG_PASSTHROUGH_PATHS was set as a derivation attr (sandbox mode) but
not in scrubWrapperEnv, which is what `shell`'s shellHook uses for the
native replay tests/drv-equivalence.sh compares against.
NIXGG_KNOWN_STORE_PATHS, right beside it, was already exported both ways.

internal/mode/passthrough.go requires both modes to see the same list.
Without this, a package setting passthroughPaths passes those TUs
through under the sandbox and models them as derivations natively, so
the two modes produce different drv sets and equivalence fails.

Not caught by CI: drv-equivalence.sh runs ONLY=hello, which sets no
passthrough paths, and the kernel fixture that does set them is opt-in.
tomberek added a commit that referenced this pull request Sep 14, 2026
Ported from github.com/tomberek/nixgg PR #1 (Lyndeno's kernel-support
branch), which found these building the full stock kernel via
splitStdenv. None are kernel-specific — all four are bugs in shared
phase-split infrastructure already used by hello-dyndrv/mosh-dyndrv/
zstd-dyndrv and the whole configure-cache example family; master's own
examples/linux-kernel fixture never exercises splitStdenv at all
(it hand-writes both stages), so it never tripped any of these.

1. __structuredAttrs was forced false in both phases. A package that
   sets it writes phases against bash array syntax
   (`installFlags+=(...)`), which silently becomes wrong-but-valid
   string ops when forced off — no error, just corrupted flags. Honour
   the package's own setting (or make-derivation.nix's own default)
   instead, moving the out="/nonexistent" placeholder into `env` when
   structuredAttrs is on (only `env` reaches the derivation then).

2. The build tree was submitted from a postBuild hook, which only
   fires if the package's own buildPhase calls `runHook postBuild` —
   plenty of hand-written ones don't (nixpkgs' own linux-config is
   one). Those built fine, submitted nothing, and failed with Nix's
   opaque "failed to submit output path for 'out'". Submit from a
   real phase (ggSubmitPhase, spliced via postPhases, which setup.sh
   always runs) instead.

3. The build stage's exported shell state never reached the final
   stage. Splitting one mkDerivation into two splits the shell too,
   and packages routinely export in one phase and read in a later one
   (nixpkgs' kernel: configurePhase exports $buildRoot,
   installPhase reads it). Dump the build stage's exports
   (dynDrvShared.nix's submitBuildTreeScript) and gap-fill-replay them
   in the final stage — never overriding what the final stage already
   set, and skipping PATH/NIXGG_*/phase-control vars, which the final
   stage must decide for itself.

4. The final stage ran with no NIXGG_* env at all, so a shim path
   baked in at configure time (cmake does this; so can a caller's own
   makeFlags) had no config to read and exited non-zero before doing
   any work — reported as a broken toolchain, not a nixgg issue.
   Export the same shim-activation env as the build stage, with
   NIXGG_BYPASS=1: reachable so a baked-in path still resolves, inert
   because everything is already built and restored.

Verified: nix flake check --no-build (same 4 pre-existing non-derivation
errors as before), tests/smoke.sh (every splitStdenv combination:
hello-dyndrv/mosh-dyndrv/zstd-dyndrv, hello-cache/zstd-cache/
*-cache-filtered, and all four *-dyndrv-configure-cached fixtures),
tests/configure-cache-cutoff.sh, tests/dyndrv-configure-cache-cutoff.sh,
and ONLY=hello tests/drv-equivalence.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tomberek added a commit that referenced this pull request Sep 14, 2026
Ported and reimplemented from PR #1 (Lyndeno's kernel-support branch)
against current master's architecture. `ld -r` (combine several
objects into one relocatable object — Kbuild's multi-object kernel
module fusion) doesn't fit KindLink (no executable/.so results) or
KindArchive (result is one object, not an archive): routing it through
KindLink as master previously did put the output at $out/bin/<name>.o,
which ArtifactSubdir's consumer-side logic reads back at the FLAT ""
subdir for any .o name not ending in "-prelink.o" — a real producer/
consumer path mismatch for every fused module object.

New KindPartialLink derivation kind: sandbox/eager-drv only (no
native-mode helper — nothing that needs ld -r accelerated reaches
nixgg without already requiring one of those modes for its other
steps), same drv-construction pattern as Compile/Link/Archive.
dispatch.ToolLD now routes to the new shim.LD, which recognizes the
`-r -o <out> <obj>...` shape and falls through to Link for everything
else (including a raw full link some builds route through ld directly
rather than a compiler driver).

Fixed two bugs the PR's own version has (never surfaced there because
its own kernel fixture never declares an ld -r output as a
NIXGG_SANDBOX_TARGET): LD didn't call maybeSubmit at all, and didn't
check multiTargetName for the submitted drv's required name override.
Master's own examples/linux-kernel fixture (added in 9f3ea7b, before
this session) declares exactly this shape (`test-dhry-o` ->
`lib/test_dhry.o`, a multi-object CONFIG_TEST_DHRY module) and caught
both: the build failed with "failed to submit output path" without
the first fix, then with a drv-name mismatch against submit-output's
naming contract without the second.

Verified: go build/vet/test, ONLY=hello tests/drv-equivalence.sh,
tests/smoke.sh (default set, unaffected), and a full real build of
.#linux-kernel (2800+ TUs) producing a valid vmlinux (ELF 32-bit,
statically linked) and test_dhry.ko (ELF 32-bit relocatable) via the
new ld -r path — the actual multi-object module fusion this shim
exists for, not just a unit test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tomberek added a commit that referenced this pull request Sep 14, 2026
Ported and reimplemented from PR #1 (Lyndeno's kernel-support branch)
against current master's architecture. Two new shims for tools that
rewrite an existing object rather than producing one from sources —
neither fits any existing Kind:

- objcopy (`objcopy <flags> <in> <out>`): read one object, write
  another (symbol prefixing, section stripping —
  scripts/Makefile.lib's cmd_objcopy). Only the two-operand rewrite is
  modelled; single-operand in-place and read-only forms (--dump-section)
  pass through.
- objtool (`objtool <flags> foo.o`): rewrite an object in place after
  compiling it. Reached only when a build points its own `objtool=`
  make variable at this shim — nothing resolves it via PATH otherwise.
  The rewritten object is often itself a store path or one of nixgg's
  own drvref stubs; either way it has to be copied out of read-only
  storage before objtool can write to it.

Both share a new KindTransform derivation kind: sandbox/eager-drv
only, same construction pattern as PartialLink. dispatch.ToolObjtool/
ToolObjcopy route through argv0, same mechanism as cc/ar/ld; flake.nix
links both shim names alongside the existing ones (with the same
"present so pointing a tool variable here works" caveat objtool needs,
since it's the whole reason it's reachable at all).

Also ports a real, separately-motivated fix objtool's own memoization
needed: assemble.skipNames didn't exclude nixgg's own scratch dir
(.nixgg — staged sources, thunks, memo caches) from the captured tree,
and did so only at the top level besides. At kernel scale that drags
tens of thousands of extra references into the closure Nix bind-mounts
per derivation, failing as "bind mount ... failed: No space left on
device" on a disk nowhere near full. Fixed both halves: added ".nixgg"
to skipNames and applied it at every depth in copyRecursive, not just
StageForScan's own top-level ReadDir.

Verified: go build/vet/test, ONLY=hello tests/drv-equivalence.sh,
tests/smoke.sh (all examples, unaffected), and a full rebuild of
.#linux-kernel producing byte-identical drv hashes to before this
change (confirming the new, unused-by-that-fixture code paths are
purely additive).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tomberek added a commit that referenced this pull request Sep 14, 2026
Ported and reimplemented from PR #1 (Lyndeno's kernel-support branch)
against current master's architecture.

New KindRustc derivation kind, sandbox/eager-drv only: one invocation
compiles a whole crate (not a single TU) and may emit several
artifacts from one front-end run (an object, the .rmeta dependents
resolve --extern against). Reached through PATH rather than an
explicit tool= assignment — Kbuild's RUSTC defaults to a bare `rustc`
— which needed its own PATH self-recognition guard (realRustc) so the
fallback lookup can't resolve back to this same shim and fork forever.

Real complexity this pulls in: rustc's own dependency scan
(scan.RunRust, via --emit=dep-info, tolerating the non-zero exit a
stubbed --extern always produces); resolving --extern/-L search-path
crates into explicit derivation inputs, since the sandbox has no build
tree for rustc to search; a proc-macro passthrough (rustc dlopens
these, so deferring one blinds every dependent crate's scan);
rustc's own one-argument-per-line @-argfile grammar, different enough
from the compiler drivers' that dispatch gained a second expander
(ExpandRustArgfiles) alongside ExpandRspfiles; and a MAX_ARG_STRLEN
spill for oversized flag lists (a kernel's generated cfg file is
hundreds of KB of --cfg flags), mirroring the existing compile-side
passAsFile rationale.

Also required, as real dependencies rather than scope creep:
- NIXGG_PASSTHROUGH_PATHS (mode.Passthrough, mode/passthrough.go):
  caller-declared subtrees that read object bytes inline or expect a
  compile to fail, generalizing the pattern the existing compiled-in
  Kbuild carveouts (isKbuildRealmodeELF etc.) already established for
  project-specific cases. Wired into compile.go (checked before the
  scan) and a new storeinput.go carvedOut() helper, which ld.go/
  objcopy.go/objtool.go now also consult.
- ArtifactSubdir: .rmeta/.rlib now stay flat like .o, fixing a real
  producer/consumer path mismatch (rustc writes flat, but the default
  inference sent any non-.o/.a name to bin/) caught by actually
  building the new examples/rustc fixture, not by a unit test.

examples/rustc (ported from the PR): two crates built by make and
bare rustc, covering one-invocation/two-artifacts, a crate compiled
against a stub dependency, `include!`, the @-argfile, and ar over
rustc-produced stubs. Not in tests/drv-equivalence.sh: rustc is
sandbox-only, so native mode runs it for real and produces nothing to
compare. Added to tests/smoke.sh's quick set instead.

Verified: go build/vet/test (plain and with real gcc+rustc on PATH),
a real `nix build .#rustc` producing a valid ar archive of both
crates' objects, tests/smoke.sh (full set including the new example),
ONLY=hello tests/drv-equivalence.sh, and a full rebuild of
.#linux-kernel producing byte-identical drv hashes to before this
change — confirming mode.Passthrough/carvedOut's wiring into
compile.go/ld.go/objcopy.go/objtool.go is purely additive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tomberek

Copy link
Copy Markdown
Owner

I'm starting a merge/consolidation of this. Will push when there is something....

tomberek added a commit that referenced this pull request Sep 14, 2026
Wire passthroughPaths through splitStdenv.nix/dynDrvShared.nix into
NIXGG_PASSTHROUGH_PATHS — the Go-side mechanism (mode.Passthrough,
internal/mode/passthrough.go) landed earlier this session, but nothing
exposed it as a splitStdenv parameter yet. tests/kernel/kernel.nix
(cherry-picked from PR #1, previous two commits) is the first real
caller.

Drop kernel.nix's own sharedStaging parameter and inherit: that optimises
staging via stage.SourcesShared/shim/shared.go, which this session
deliberately did not adopt (flagged experimental/unproven by the PR's
own comments — depends on an unverified assumption about `nix store
add --scan` following symlinks for reference-scanning). Passing it
through unconditionally would either error (parameter splitStdenv
doesn't accept) or silently no-op; dropping it here is honest about
what's actually implemented, at the cost of the ~150GB-vs-a-fraction
staging duplication the PR's own comment on this parameter describes
for a full cold kernel build.

Verified tests/kernel-passthrough.sh (the cherry-picked evaluation-only
guard) passes against this wiring — confirms passthroughPaths actually
reaches NIXGG_PASSTHROUGH_PATHS in a real (evaluated, not built) kernel
derivation. Also reran tests/smoke.sh and ONLY=hello
tests/drv-equivalence.sh to confirm the new splitStdenv/dynDrvShared
parameter is additive and doesn't perturb any existing caller.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tomberek added a commit that referenced this pull request Sep 17, 2026
tests/kernel/boot-test.nix and tests/kernel/kernel.nix, which this
session's work (examples/linux-kernel, tests/kernel-boot-smoke.sh)
builds directly on, originated in PR #1.

Co-authored-by: Lyndon Sanche <lsanche@lyndeno.ca>
@tomberek

Copy link
Copy Markdown
Owner

incorporated into 062bb9a

thanks!

@tomberek tomberek closed this Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants