← Back · NextBSD Research · companion to Debian userland: debootstrap and the apt wrapper and Mounts, fstab and DiskArbitration §5
Who mounts /proc, /sys, /dev/fd and /dev/shm for Linux programs on NextBSD now that no /etc/fstab ships? The plan must also cover the case where the Linux root does not exist yet. The answer is a single boot-time LaunchDaemon that does what FreeBSD’s rc.d/linux does. It creates the Linux root’s mount points and always mounts them, whether or not a Linux userland has been installed.
Implemented (2026-09-21): nextbsd-userland#244, fixing #190. The mounts are made by linux_abi_mounts() inside launchctl bootstrap, next to the other boot-time mounts. That replaced the earlier draft of a one-shot LaunchDaemon plus a /usr/libexec tool, which would have shown up in launchctl list. CI proved it end to end on amd64 and arm64: pkg install claude-code into the pre-mounted /compat/linux, a Linux uname -s, and claude --help. That test was then removed to keep CI fast; a one-second boot check stays. Sections below are updated to match.
The short answers.
linux_abi_mounts() (support/launchctl.c) creates $emul/{proc,sys,dev,tmp} and mounts all five filesystems plus a nullfs of /tmp. Here $emul is compat.linux.emul_path, default /compat/linux. It does this even when no Linux userland is installed, exactly as stock FreeBSD’s rc.d/linux does with linux_enable=YES (mkdir -p, then mount, unconditionally, rc.d/linux:17-26, 74-80). There is no LaunchDaemon and no separate tool.$emul/proc, so the apt wrapper remounts it (mount -t linprocfs linprocfs $emul/proc) when debootstrap finishes (§3).linux_base) is already mounted; CI proved this with pkg install claude-code. WatchPaths is inert on this launchd, and restoring it is optional (§5)./compat/linux gives every Linux process Linux-style /proc, /sys, /dev/fd and /dev/shm through the alt-root lookup. That includes static AppImage runtimes with no Linux userland at all. Mounting linprocfs on the real /proc is safe for native binaries but not needed (§4).linux_pwd_onexec() resolves compat.linux.emul_path and installs it as the process’s alt root. If the path does not exist, execution continues with no alt root (sys/compat/linux/linux_util.c:77-94). emul_path is one global CTLFLAG_RWTUN string, default /compat/linux (:71-75).namei() tries every absolute path under the alt root first. Only when the whole lookup fails with ENOENT does it restart from the real root (sys/kern/vfs_lookup.c:741-757, and :689-697 for the fast path). EACCES and ENOTDIR do not fall back, and neither does a create whose parent exists.pwd_chroot sets the alt root to the new root (kern_descrip.c:4206-4208), so a chroot into the Linux root only needs the mounts inside it.So with an empty but mounted $emul, /proc/*, /sys/* and /dev/* resolve to the compat instances, and every other path (/lib64/ld-linux…, /etc/passwd, /tmp/.mount_*) returns ENOENT under $emul and is retried on the real root. That is today’s behaviour with no alt root, plus one negative lookup.
A likely correction to #54. #54 records that AppImages “need the native set”. The mechanism says otherwise. The probable explanation is that in those tests /compat/linux/proc existed as an unmounted empty directory created by linux_base-rl9. /proc/self was then ENOENT under $emul and fell back to the native /proc, which only worked once linprocfs was mounted there. Test T3 settles it.
| B1: pre-create, always mount (chosen) | B2: mount only when the root exists | |
|---|---|---|
| Precedent | stock rc.d/linux | #190’s current draft |
| Static Linux binary / AppImage, no userland | works through the alt root | needs the native set, or /proc fails |
Root created later (debootstrap, tar, linux_base) | already mounted; the wrapper re-runs the tool for proc | must be started after the root appears; easy to forget, and races with the wrapper |
Root moved (emul_path=/compat/debian) | next run creates and mounts the new root | same, once the root exists |
| Cost | /compat/linux exists on every image; a mistyped emul_path creates a junk tree | behaviour depends on state, and is hard to test in CI |
Mount order (§8 has the full spec):
$emul/proc$emul/sys$emul/dev-o linrdlnk on $emul/dev/fd. linrdlnk is required, because Bun and claude-code resolve cwd through readlink("/dev/fd/N") (#54).mode=1777,size=… on $emul/dev/shmdevfs goes first, and no mkdir is needed for dev/fd or dev/shm: devfs supplies fd, and linux_dev_shm_create() puts shm/ in every devfs instance at ABI init (sys/compat/linux/linux.c:699-709). The tool also re-asserts kern.elf64.fallback_brand=3 only if it reads -1. The kernel default is already 3 (patch 0009). It never touches elf32.
Where the root comes from. The root is sysctl -n compat.linux.emul_path. To move it persistently, write the line unquoted in /etc/sysctl.conf, as compat.linux.emul_path=/compat/debian. launchctl bootstrap applies that file before load -D all (launchctl.c:2342). sysctl does not strip quotes, so a quoted value would include them. --root PATH overrides for one run, and the wrapper uses it before the sysctl is set.
Pi 5. The linsysfs boot panic mentioned in #171 is fixed (nextbsd-kernel#209, patches 0056/0057). It happened at init time and was unrelated to mounting. nextbsd-kernel#212, where the RP1 PCI subtree is missing from /sys, is still open.
This was the deciding question. Here is what debootstrap does, with our five mounts already in place:
| Step | Source | Effect |
|---|---|---|
| Target check | debootstrap:68, 552-558 | The “not empty” error applies only to --make-tarball. The FreeBSD sanity check is just mkdir -p (functions:1853-1859). |
Stage 1: gtar -k -x of each .deb | functions:1085-1154 | base-files’ ./dev/ ./proc/ ./sys/ already exist. pseudofs ignores unchangeable attributes (pseudofs_vnops.c:1080-1091). |
setup_devices | functions:1330-1353 | No-op on FreeBSD (no mknod). |
Stage 2: mount -t devfs $TARGET/dev, fdescfs -o linrdlnk $TARGET/dev/fd | functions:1356-1366 | Stacks on top of ours (no nocover; vfs_mount.c:1163-1170). |
setup_proc: umount $TARGET/proc, then mount its own linprocfs | functions:1271-1283 | Unmounts ours. |
| dpkg installs base packages | dpkg archives.c:896-903, 1065 | An existing directory is “do nothing”. base-files’ postinst never touches proc, sys or dev. |
Exit: unmounts /proc, /dev/fd, /dev (its own) | functions:1232-1263 | Our dev, dev/fd, dev/shm and sys reappear; $emul/proc stays unmounted. |
Workaround: after debootstrap exits, whether it succeeded or failed, the wrapper remounts $emul/proc with one mount -t linprocfs. No pre-unmount is needed. If a maintainer script ever misbehaves because linsysfs is visible at $TARGET/sys during bootstrap, the fallback is the Handbook’s exact order: unmount first, bootstrap, then remount. Stock FreeBSD never has linsysfs mounted there during bootstrap.
Bootstrap in place. Do not bootstrap into a temporary directory and rename it into place: the root carries mounts, so it cannot be replaced by a rename. Bootstrapping in place is what makes B1 work.
/proc expecting FreeBSD procfs. grep '"/proc' over ps, procstat, libkvm, libprocstat, truss and libc/gen in releng/15.0 finds nothing. In the Darwin userland, the only readers are under TARGET_OS_LINUX (CFPlatform.c:181-184, CFBundle_Internal.h:441-444). GNUstep uses libkvm on FreeBSD (NSProcessInfo.m:528-602)./dev/fd with fdescfs linrdlnk only changes getattr and readdir to report symlinks (fdesc_vnops.c:459-472, 583-588). open("/dev/fd/N") still dups, and descriptors above 2 appear, as on macOS.ls /proc looks like Linux, and /dev/fd/N shows as symlinks./sys may be a dangling symlink to usr/src/sys from FreeBSD’s distribution target. The tool must replace it rather than fail on mkdir.Decision: not mounted. Under B1, Linux processes don’t need it. If /dev/fd/N parity for native shells is ever wanted, it is a few more lines in linux_abi_mounts().
linux_abi_mounts() in system_specific_bootstrap(). It runs after /etc/sysctl.conf (so a moved emul_path is honoured), after mount -vat nonfs and the /tmp and /var/run sweep, and before load -D all, so every LaunchDaemon starts with the mounts in place. Each mount is skipped if that filesystem is already there (an admin fstab line), and -o nocover never stacks. This is where the vendored launchd-842 does its other boot mounts.proc; the apt wrapper remounts it.WatchPaths, QueueDirectories and KeepAlive.PathState are inert. launchctl rewrites them into com.apple.fsevents.matching LaunchEvents (launchctl.c:1611-1648). No EventMonitor job exists, and libxpc’s publisher API is stubbed (libxpc/xpc/private.h:60-68). The getty plist records that PathState is not implemented (org.nextbsd.getty.ttyv0.plist:64-68).launchd_core_logic.c: semaphoreitem_watch 5135-5223, semaphoreitem_callback 5226-5291, PATH_* reasons 305-320). It watches the parent directory until the target appears, and falls back to a 3 s poll on ENOTSUP. The backport is about 350–450 lines in core.c, plus removing distill_fsevents(). On FreeBSD, open the watch fds with O_PATH, because O_EVTONLY is shimmed to 0 and would pin the vnode (open(2) allows EVFILT_VNODE on O_PATH). Three things want it: nextbsd#278 “Lever D”, userland#54 option B, and the getty PathState guard. An EventMonitor daemon instead would need XPC plumbing NextBSD has deferred.sys/compat/linux)./dev/shm sizingDocumentation/filesystems/tmpfs.rst:78-92).size= is given, with vfs.tmpfs.memory_percent at 95% as a global guard. NextBSD has no swap, and the live /cow is an uncapped tmpfs (#326, #329).memfd_create first, and the Linuxulator implements it (linux_file.c:1757). It falls back to /dev/shm and aborts if /dev/shm is unwritable (platform_shared_memory_region_posix.cc:300-345). How much Chrome writes there on NextBSD is unmeasured.Policy. size is a percentage of hw.physmem, page-rounded, with a 128 MiB floor:
/ being unionfs.There is no override today. A cap turns an OOM kill (#326) into ENOSPC inside the offending Linux process.
These ran on PR #244, on amd64 and arm64, in the userland CI image boot:
/compat/linux held only dev, proc, sys and tmp, with all six mounts present.pkg install claude-code installed linux_base-rl9 9.8, claude-code 2.1.268 and bash (581/555 MiB) with no errors under /compat/linux. The mounts were intact afterwards./compat/linux/bin/uname -s printed Linux, and claude --help printed its usage. The port’s wrapper had already checked linprocfs, fdescfs and linrdlnk itself.unsupported prctl option 1398164801 is PR_SET_VMA, unimplemented in the Linuxulator and ignored by Bun.That end-to-end test (pkg from the FreeBSD mirror, pinned to the release image’s FreeBSD:15:<arch> ABI) was removed after it passed, to keep userland CI fast. What stays is the one-second LINUX-MOUNTS boot gate (§8).
Still to prove (with #241): debootstrap into the mounted root and the proc remount; Chrome/Edge/VS Code windows; AppImages; the Pi 500+ with linsysfs.
linux_abi_mounts()This is C in support/launchctl.c, called from system_specific_bootstrap(). It is about 130 lines.
compat.linux.emul_path. If the sysctl doesn’t exist, the kernel has no Linuxulator, so return.{proc,sys,dev,tmp} (mkdir each path component), then realpath() it.statfs shows that filesystem already mounted exactly there; otherwise run /sbin/mount -t … -o nocover,… via fwexec(). On failure, log to the console and continue.
procsysdevlinrdlnk on dev/fdmode=1777,size=… on dev/shmrealpath("/tmp") on tmpdev/shm is sized at hw.physmem / 2, or / 4 when / is unionfs (live media), with a 128 MiB floor.Deliberately left out of the earlier tool draft:
--status/--unmountfallback_brand step (the kernel default is already 3)CI gate (LINUX-MOUNTS): all six mounts present after boot; linrdlnk checked by behaviour (dev/fd/0 is a symlink); dev/shm capped below RAM.
| Ticket | Change |
|---|---|
| userland#190 (B2, parked) | Done: implemented in #244 (launchctl bootstrap, no LaunchDaemon, no tool). |
| userland#189 (B1, manual docs) | Shrinks to documenting the tool, emul_path (unquoted) and the /tmp rule. The fstab lines become the fallback for admins who want them. |
| userland#54 | Moves from option H (manual) to option A (LaunchDaemon). The “AppImages need the native set” note awaits T3. |
| userland#171 | Closes once #190 lands; its /dev/shm size question and Chrome test are §6 and T5. |
| nextbsd-userland#243 (optional) | Restore native WatchPaths/PathState in launchd from 392.39 using O_PATH (§5). This also unblocks nextbsd#278 Lever D and the getty guard. |
/sys is a dangling symlink on NextBSD images.Evidence: FreeBSD releng/15.0 (libexec/rc/rc.d/linux, sys/compat/linux/{linux_util,linux,linux_file}.c, sys/kern/{vfs_lookup,vfs_mount,kern_descrip}.c, sys/fs/{fdescfs,pseudofs,tmpfs,nullfs}); debootstrap 1.0.145 (debootstrap, functions, scripts/debian-common) and the FreeBSD port’s patches; dpkg archives.c; Chromium shared-memory code; Linux tmpfs.rst; Apple launchd-392.39 launchd_core_logic.c and -842; nextbsd-userland main (launchctl.c, core.c, ipc.c, libxpc); nextbsd-kernel#209/#212. Related: E13 (#461), E15 (#471), #460.