freebsd-livecd-gunion-reroot — experimental architecture DEFERRED

A research-grade attempt to fix the kernel-vs-userspace namespace split via gunion (block-level RAM overlay) + reboot -r reroot. The simpler symlink trick (cdroot /boot/firmware/sysroot/boot/firmware) verified working on real hardware, which addresses the immediate firmware-loading problem without an architectural rewrite. This experiment is preserved as a reference for the alternative architecture; pursue if the symlink approach starts hitting limitations or if a future feature requires kernel and userspace to genuinely share a root.

TL;DR

1. Goal & non-goals

1.1 Goal

Build a livecd variant where the kernel's root mount IS the writable rootfs (via gunion + reroot), so kernel-context filesystem operations (firmware loading, kld auto-search, etc.) see the same view as userspace. Specifically: iwlwifi/i915kms/amdgpu firmware loads correctly without needing a 1GB cdroot bloat or a `/boot/firmware → /sysroot/boot/firmware` symlink workaround.

1.2 Non-goals (this experiment)

2. The problem we're solving

All three of our existing livecd projects share the same architectural shape, and it has the same kernel-firmware-loading bug.

ProjectOverlayHandoffFirmware bug?
freebsd-launchdunionfschroot via Option D shebang on /init.shYes
freebsd-livecd-unionfsunionfschroot via init_chroot kenvYes
freebsd-livecd-guniongunionchroot via init_chroot kenvYes

The bug: kernel-context vn_open in sys/kern/subr_firmware.c's try_binary_file() resolves /boot/firmware/<name> against the kernel's root namespace, which on these livecds is the cd9660 mount. Userspace processes (post-chroot) see /boot/firmware/ in the unionfs/gunion view, which has the pkg-installed firmware files. The kernel's view is the cd9660 root, which contains only the bootloader-stage firmware (a small subset). Result: iwlwifi-8000C-36.ucode exists for userspace but not for kernel; wifi doesn't come up.

Verified empirically on a Lenovo laptop running freebsd-launchd's continuous-release ISO: file present (2.4MB) via ls /boot/firmware/iwlwifi-8000C-36.ucode; iwlwifi attaches at the bus level but reports "could not load binary firmware /boot/firmware/iwlwifi-8000C-36.ucode either" followed by "File size way too small!" — the kernel's read returned zero bytes despite the file being right there from userspace.

3. The proposed architecture

The fix is conceptually simple: make the kernel's root mount the same thing userspace sees. chroot can't do this (chroot only changes the userspace process's view, kernel root stays put). kern_reroot() can — it actually unmounts the kernel's root and re-mounts a new one. After reroot, kernel namei resolves paths against the new root.

Boot from cd9660: vfs.root.mountfrom="cd9660:/dev/iso9660/LIVECD" | v Loader preloads rootfs.uzip into RAM (mfsBSD pattern): mfs_load="YES" mfs_name="/rootfs.uzip" | v Kernel sees /dev/md0 already configured (RAM-backed) geom_uzip auto-creates /dev/md0.uzip | v /init.sh runs as PID 1: 1. gunion create /dev/md0.uzip → /dev/gunion0 (writable, RAM upper) 2. mount -t ufs /dev/gunion0 /sysroot (sanity check; could skip) 3. kenv vfs.root.mountfrom="ufs:/dev/gunion0" 4. kenv init_path="/sbin/init" 5. reboot -r | v kern_reroot(): vfs_unmountall() ← cd9660 unmounts CLEANLY (no busy vnodes — rootfs.uzip lives in memdisk, not on cd9660 anymore) vfs_mountroot() ← re-mounts root from gunion0 kernel root is now the writable gunion-backed UFS exec /sbin/init ← new init in new root | v Normal boot continues, but: - kernel root = gunion-backed UFS = same as userspace view - /boot/firmware/foo.ucode is visible to kernel namei - iwlwifi attaches → firmware loads → wlan0 appears

Three architectural improvements over the chroot variants:

4. The busy-cd9660 problem and how to dodge it

The naive version of this — set up gunion + reroot without preloading the rootfs.uzip — fails because:

1. /init.sh runs in cd9660 root
2. mdconfig -a -t vnode -f /rootfs.uzip   ← holds /rootfs.uzip vnode busy
   /dev/md0 is now backed by the FILE on cd9660
3. gunion create /dev/md0.uzip            ← /dev/gunion0
4. reboot -r with vfs.root.mountfrom=ufs:/dev/gunion0
5. kern_reroot() → vfs_unmountall()
6. unmount cd9660 → EBUSY  (md is holding /rootfs.uzip vnode)
7. cd9660 stays mounted; vfs_mountroot() runs anyway with old root still half-mounted
8. Inconsistent rootvnode state → panic in vfs_mountroot() OR
   half-rerooted system in unstable state

The exact failure mode (panic vs hang vs error) depends on FreeBSD's vfs_mountroot implementation details I haven't traced exactly. Either way it's bad enough that "don't try this naively" is the correct stance.

4.1 Approach A: loader preload (mfsBSD pattern)

Add to cdroot's /boot/loader.conf:

mfs_load="YES"
mfs_name="/rootfs.uzip"
mfs_type="mfs_root"

The FreeBSD loader reads /rootfs.uzip from the cd9660 boot media at loader time, before kernel boot. Loads the entire file into RAM. Kernel sees md0 already configured at boot — backed by RAM, not by a vnode on cd9660.

After loader hands off to kernel: cd9660 mounts as initial root (for kernel binary access), but nothing on cd9660 is held busy by a vnode-backed mdconfig (because md0 came from the loader, not from a runtime mdconfig -t vnode -f /rootfs.uzip).

cd9660 is the rescue environment. It already contains /rescue/sh, /sbin/gunion, /sbin/mdconfig, /sbin/reboot, /sbin/kenv — everything /init.sh needs to run gunion-create and reroot. We do NOT build or load a separate "rescue.img" memdisk. The cd9660 IS the rescue env.

Flow:

# /init.sh on cd9660 (Option D shebang as PID 1):

# /dev/md0.uzip already exists, RAM-backed (loader preloaded rootfs.uzip)

# Layer gunion on top for writable upper
gunion create /dev/md0.uzip       # /dev/gunion0

# Hand kernel the new root and trigger reroot
kenv vfs.root.mountfrom="ufs:/dev/gunion0"
exec /sbin/reboot -r              # process is replaced; kernel reroots

After reboot -r: kern_reroot() calls vfs_unmountall() which unmounts cd9660 cleanly (no busy vnodes). vfs_mountroot() mounts /dev/gunion0 as the new kernel root. /sbin/init runs from the gunion-backed UFS. From this point the kernel root and userspace root are the same view; firmware loading works natively.

Pros: standard FreeBSD pattern (mfsBSD has used it for decades). Loader-handled, simple init.sh, no separate rescue.img to build or maintain.

Cons: loader has to slurp ~400MB into RAM at boot — slower boot by 5-15 seconds depending on read speed (USB stick, optical, network). Loader has size limits in some BIOSes/UEFI firmwares; needs verification on the target hardware.

4.2 Approach B: in-init.sh memdisk copy

Same end state, done in init.sh instead of loader:

# Inside /init.sh, before any other md/gunion setup:

# 1. Create a malloc-backed memdisk sized for the rootfs
mdconfig -a -t malloc -s 400m -u 1   # /dev/md1, RAM-backed, empty

# 2. Copy rootfs.uzip into it
dd if=/rootfs.uzip of=/dev/md1 bs=1m

# 3. Detach the vnode-backed md (if we created one)
mdconfig -d -u 0 2>/dev/null    # no-op if we never created md0

# 4. /dev/md1.uzip is now RAM-backed; cd9660 is no longer needed
gunion create /dev/md1.uzip      # /dev/gunion0

# 5. Set reroot target and trigger
kenv vfs.root.mountfrom="ufs:/dev/gunion0"
reboot -r

Pros: doesn't depend on loader behavior. Easier to debug (it's a shell script, can be poked at interactively).

Cons: dd-copy of ~400MB takes 1-3 seconds on RAM. cd9660 stays mounted for the duration of the copy. Init.sh becomes more complex and order-sensitive.

4.3 Recommendation

Try Approach A first — it's the canonical pattern, less code, and if the loader supports the file size we get a clean boot. Fall back to B if loader-preload fails on real hardware.

4.4 Approach C: separate rescue.img + dual-memdisk (mfsBSD pattern)

The mfsBSD-canonical alternative: build a tiny rescue.img (~30MB UFS with just init + tools) and have the loader preload it alongside rootfs.uzip. Kernel boots from rescue.img (RAM-backed memdisk); rescue's init.sh sets up gunion over the second RAM-backed memdisk; reroots to gunion-backed UFS. cd9660 may not even be mounted at runtime.

# cdroot/boot/loader.conf:
mfs_load="YES"
mfs_name="/rescue.img"
mfs_type="mfs_root"

# Second memdisk (rootfs payload) loaded as a separate kernel preload:
rootfs_load="YES"
rootfs_name="/rootfs.uzip"
rootfs_type="md_image"
# /init.sh inside rescue.img:
gunion create /dev/md1.uzip          # md1 is the rootfs payload
kenv vfs.root.mountfrom="ufs:/dev/gunion0"
exec /sbin/reboot -r

What this pattern fixes (for mfsBSD's use cases)

ScenarioWhy miniroot helpsApplies to our livecd?
PXE / netbootkernel + rescue.img are small files PXE-servable in seconds. The big payload can come later over NFS / HTTP / wherever, decoupled from the boot path.No — we boot from cd9660 / USB optical media.
Eject-boot-media-mid-installOnce the rescue is in RAM, the user can yank the USB stick / eject the disc; install continues from a target disk. Useful for "USB stick → install to internal NVMe, eject USB."No — our livecd assumes media stays attached. We don't currently have an installer.
Multiple payload imagesRescue can pick at boot time which rootfs to load (minimal / full / desktop / server). One disc, several flavors.No — we ship a single rootfs.uzip. Could become useful if we ever ship "freebsd-launchd minimal" + "freebsd-launchd full Plasma" on the same image.
Diskless workstationsRescue is the fixed in-RAM boot env; payload comes from a network server. Common in fleet deployments.No — single-machine livecd, no fleet scenario.
Offline rescue when rootfs is brokenIf the big rootfs fails to load (corrupt download, hardware failure mid-install), the rescue is guaranteed-working in RAM and the user gets a shell.Partial — our cd9660 already serves this role. If /rootfs.uzip is corrupt, the cd9660 is still mounted with all its tools and the user is in a working shell.
Smaller loader I/O footprintLoader handles only ~30MB rescue, not 400MB+ rootfs. Faster initial boot on slow media.Marginal — Approach A's loader-preload of the full rootfs costs 5-15s extra. Acceptable tradeoff for build simplicity.

Why we're not adopting this

None of the scenarios where miniroot earns its complexity apply to our livecd today:

Cost of adopting it anyway: an entire second build pipeline (rescue.img generation, separate Makefile target, separate set of binaries to keep version-aligned with the rootfs), debug surface (two stages of init), and the mfsBSD-canonical pattern's quirks (loader configuration of multiple memdisks, kernel preload type for the secondary md, etc.). All for "the same outcome we already get from cd9660."

Verdict: keep this pattern documented as the right choice for mfsBSD-shape use cases (PXE, ejectable media, multi-payload, diskless rescue). If freebsd-launchd ever grows into one of those use cases — most plausibly an installer that wants to eject the USB stick after install completes — this is the right architecture to revisit. For the current livecd, Approach A (cd9660 as rescue + loader-preloaded rootfs) is correct and simpler.

5. Why gunion specifically (vs. unionfs, vs. tmpfs root)

OverlayLayerCan be reroot target?RAM costNotes
unionfsvnode-stackingNolow (writes only)Can't be a single-spec root mount; can't be mounted over /. Per unionfs(8): "meaningless to mount unionfs over a root mount point."
gunionGEOM block-deviceYeslow-mediumCreates a writable virtual block device backed by RO underlying + RAM upper. UFS on top mounts cleanly as root via standard vfs.root.mountfrom=ufs:/dev/gunion0.
tmpfs roottmpfs (RAM)YesHIGH (entire rootfs in RAM uncompressed, ~1-2GB)Works but burns RAM. mfsBSD does this for tiny rootfs; ours is too big.
RO UFS + selective tmpfs subdirsUFS + tmpfsYes (UFS as root)low (only writable subdirs in RAM)The mfsBSD/Path-A pattern. Simpler than gunion. Trade: only writable where we explicitly mount tmpfs.

gunion gives us the cleanest "writable root" semantic — no thinking about which subdirs are writable, no skeleton-population dance, no per-iface tmpfs-mount choreography. Everything is writable; writes go to RAM via gunion's upper layer; reads come from the RO uzip via gunion's lower.

Trade-off vs. RO UFS + tmpfs subdirs: gunion has a known performance overhead (block-level overlay isn't free) and write coalescing/syncing semantics that have caused issues historically. We moved off gunion to unionfs in freebsd-livecd-unionfs partly because of these.

For this experimental architecture, gunion is the right pick because it's the only writable-everywhere overlay that can be a reroot target. The performance trade-off is worth re-evaluating now that we have the rootfs entirely in RAM (less I/O contention than gunion-over-cd9660-uzip).

6. Phased delivery

Phase 0 — repo scaffold PLANNED

Phase 1 — loader preload of rootfs.uzip PLANNED

Phase 2 — gunion + reroot in init.sh PLANNED

Phase 3 — kernel-firmware loading verification PLANNED

Phase 4 — boot test extension in CI PLANNED

Phase 5 — comparison + decision RESOLVED — symlink wins

Comparison done before reroot was built. The symlink trick succeeded on real hardware:

AxisSymlink (shipped)gunion + reroot (this plan)
Code complexity1 line in build.shPhase 0-4 work (~3-5 days)
RAM floor~200MB (unchanged)~600-800MB
Boot timeunchanged+10-30s loader preload
Risk of breaking boot pathessentially zeroreal (half-rerooted state, panic on busy cd9660)
Solves firmware loadingyes (verified)yes (would have, if built)
Solves future kernel-context vfs opsonly paths under /boot/firmwareeverything (kernel root = userspace root)

The symlink wins on every axis except "future-proofing for unrelated kernel-context vfs operations" — which we don't currently need, and which hasn't surfaced as a real problem after the firmware case was fixed. Keep this experiment on the shelf in case that future need materializes.

7. Risks

Boot-path regression. Reroot is invasive. Anything that goes wrong between init.sh's reboot -r and the new init's first log line is a black-box failure. Mitigation: aggressive serial-console logging, test on real hardware before declaring success in CI.
Loader file-size limits. Some BIOSes/UEFI firmwares limit loader-mapped file sizes. If rootfs.uzip at ~400MB exceeds the limit, loader fails to preload and we fall back to in-init.sh memdisk copy (Approach B). Need to test on multiple firmware vendors.
gunion performance regressions. We moved off gunion to unionfs because of perf issues. They may resurface when gunion is the root filesystem rather than a sub-mount. Mitigation: profile fs operations during boot test; compare against unionfs baseline.
RAM floor too high for older hardware. ~600MB-1GB RAM minimum. Excludes a meaningful chunk of older laptops still in service. Mitigation: document the requirement; recommend the chroot-based variants for low-RAM systems.
Half-rerooted state. If kern_reroot() partially succeeds but vfs_mountroot() fails, the system can land in a state where neither the old nor new root is fully mounted. Behavior is undefined; might panic. Mitigation: extensive boot testing; have a recovery serial console available.

8. Comparison with sibling livecd projects

Axisfreebsd-launchd (today)freebsd-livecd-unionfsfreebsd-livecd-gunionfreebsd-livecd-gunion-reroot (this)
Overlayunionfsunionfsguniongunion
Handoffchroot (Option D)chroot (init_chroot)chroot (init_chroot)reboot -r
Kernel root after bootcd9660cd9660cd9660gunion-backed UFS
Kernel firmware loadingbroken (workaround: symlink)brokenbrokenworks natively
Rootfs.uzip location at runtimevnode on cd9660vnode on cd9660vnode on cd9660RAM (loader-preloaded)
RAM floor~200MB~200MB~200MB~600-800MB
Writable surfaceeverywhere (unionfs)everywhere (unionfs)everywhere (gunion)everywhere (gunion)
Boot timefastfastfast+10-30s (loader preload)
cd9660 stays mounted post-bootyes (kernel root)yesyesno (unmounted by reroot)
Architectural riskknown-goodknown-goodknown-goodexperimental

9. Open questions

Q1. Does the loader actually preload 400MB cleanly on FreeBSD 15.0? Only one way to find out — try it. If it fails on real hardware, fall back to Approach B (init.sh memdisk copy).
Q2. Does vfs.root.mountfrom=ufs:/dev/gunion0 work as expected? gunion presents as a normal block device with a UFS filesystem. Should mount via standard root-mount machinery. Verify in Phase 2.
Q3. Does reboot -r from a shell-as-PID-1 (Option D pattern) work? Our existing init.sh patterns set things up and exec the real init. reboot -r is a syscall (reboot(2) with RB_REROOT), should be callable from any process. But "shell as PID 1 calling reboot -r" is unusual; needs verification.
Q4. What happens to file descriptors/processes during reroot? All processes (init included) get killed and restarted. So our shell init script that calls reboot -r will be killed mid-execution. The kernel re-mounts root and execs init_path in the new root. The shell script's "after reboot -r" code never runs. Need to make sure reboot -r is the LAST thing in init.sh.
RESOLVED — Q5. Symlink trick vs. reroot. Symlink wins. Verified working on Lenovo iwlwifi-8260 hardware in freebsd-launchd commit 51e1b80; backported to freebsd-livecd-unionfs and freebsd-livecd-gunion. Reroot remains the architectural answer if a future need surfaces, but for the firmware problem specifically the simpler fix is sufficient.
Q6. Does this approach generalize to the freebsd-launchd architecture (Option D shebang init)? Probably yes — Option D is just "shell as PID 1 via imgact_shell"; that shell can call reboot -r the same way. But verifying is post-Phase-5 work, not in scope for this plan.

10. References