sched/arch/libc: give fork(), vfork() and task_fork() their real, separate semantics - #19562
Draft
casaroli wants to merge 2 commits into
Draft
sched/arch/libc: give fork(), vfork() and task_fork() their real, separate semantics#19562casaroli wants to merge 2 commits into
casaroli wants to merge 2 commits into
Conversation
|
1 task
Contributor
Author
|
I was planning on having one follow up PR for each relevant architecture, however I now think maybe we should keep one of the architectures that supports these are MMU capable, in order of complexity (low to high):
|
casaroli
force-pushed
the
fork-semantics-core
branch
4 times, most recently
from
July 28, 2026 06:39
8293166 to
f8d329e
Compare
NuttX implemented fork() and vfork() as the same function. Both were libc wrappers around a single up_fork() syscall; vfork() differed only by a trailing waitpid(). Underneath, the child joined the parent's address environment -- the same addrenv_join() that pthread_create() uses -- and got a private copy of the stack. So the child shared .data, .bss and the heap with its parent and ran concurrently with it. That is not fork(). It is vfork()-with-a-private-stack under fork()'s name, and the history says so: today's fork() is NuttX's old vfork(), renamed in c33d1c9 (2023) without any change of behaviour. The failure was silent -- a program written against POSIX fork() compiled, ran, and had its child's writes land in the parent's variables. Separate them into three primitives, chosen by which function the caller called rather than by what the hardware happens to be: fork() child gets its own copy of the parent's memory at the same virtual addresses; runs concurrently. Only where an address environment can be duplicated -- elsewhere it is not declared at all, so calling it is a build error naming the function. vfork() child shares the parent's memory; parent suspended until the child _exit()s or exec()s. Implementable everywhere. task_fork() the historical behaviour under an honest name: shares memory, private stack copy, both running. Non-POSIX, in sched.h. Below libc there are now three syscalls -- up_task_fork(), up_vfork() and up_fork(). The per-arch register snapshot is common to all three; each architecture's entry points share one sequence and differ only in a FORK_TYPE_* selector (include/nuttx/fork.h) handed to nxtask_setup_fork(), which is the single place the memory semantics are decided. The vfork() parent suspension moves out of libc into nxtask_start_vfork(), released from nxsched_release_tcb(). Two things follow: the parent is resumed at exec(), since exec_swap() has already handed the child's pid to the loaded program by the time the vfork stub exits, and vfork() no longer depends on CONFIG_SCHED_WAITPID. Releasing there requires one fix in nxtask_exit(). It raises rtcb->lockcount directly rather than through sched_lock() while it tears the TCB down, so the nxsem_post() that wakes the vfork() parent queues it on g_pendingtasks -- and the matching raw lockcount-- does not merge that list the way sched_unlock() would, leaving the parent stranded with nothing left to move it off. A nxsched_merge_pending() after the decrement publishes it. The call is a no-op while pre-emption is still disabled, and up_exit() re-reads this_task() afterwards, so a change of the ready-to-run head is honoured. Without it vfork() deadlocks on any configuration where no other task happens to call sched_unlock() afterwards -- rv-virt:nsh64 and rv-virt:pnsh64, for instance, where NSH is blocked in waitpid() holding the lock. fork() is built on a new addrenv_fork(), backed by an up_addrenv_fork() hook that duplicates an address environment into freshly allocated pages mapped at the same virtual addresses -- unlike up_addrenv_clone(), which copies only the representation and leaves both pointing at the same page tables. The child then adopts the parent's stack geometry rather than being given a relocated copy: a pointer to a stack local taken before fork() must name the same object in the child that it named in the parent, and the parent's stack is already in the duplicate, with its contents, at the parent's address. No architecture implements up_addrenv_fork() yet, so this commit leaves fork() unavailable everywhere. That is the intended state. It withdraws fork() from ARCH_ARM, flat ARCH_ARM64, ARCH_RISCV, ARCH_SIM and ARCH_X86_64, where until now it named the sharing primitive; per-architecture patches restore it, with POSIX semantics, as up_addrenv_fork() lands. Nothing is lost in the meantime: task_fork() is that same sharing primitive under its own name, and CONFIG_FORK_IS_TASK_FORK (default n) aliases fork() back to it for legacy code, on exactly the configurations that had fork() before. Kconfig: ARCH_HAVE_TASK_FORK and ARCH_HAVE_VFORK inherit ARCH_HAVE_FORK's select lines, conditions included, so no configuration gains machinery; ARCH_HAVE_FORK is redefined to mean "can provide POSIX fork() semantics" and derives from the new ARCH_HAVE_ADDRENV_FORK. There is one deliberate departure from "verbatim". ARCH_ARM selected the fork family unconditionally, BUILD_KERNEL included, and that has never worked: on a kernel build the architecture's fork entry point sees the kernel's return address and stack pointer rather than the caller's, so the child resumes at a kernel address. On qemu-armv7a:knsh master faults in ostest's task_fork case with "Child did not run" and then a data abort; without the condition this change faults the same way through vfork(). ARCH_ARM64 and ARCH_X86_64 already carried "if !BUILD_KERNEL" for exactly this reason -- ARM was the outlier. Conditioning it turns a runtime fault into an honest absence, which is the whole point of the change; arch/arm takes the condition off again in the patch that adds its saved-syscall-frame path. Only the MMU-capable ARM ports are affected, since Cortex-M cannot build BUILD_KERNEL at all. ARCH_VFORK_STACK_BORROW lets a vfork() child borrow the parent's stack outright instead of relocating a copy of it, which is what an architecture whose frames hold absolute stack addresses needs; the reserve withheld for the parent's own frames is guarded by a canary. Also fixes two latent syntax errors found on the way: a missing comma in riscv_fork.c and mips_fork.c, both in *_FRAMEPOINTER && !SAVE_GP branches that are never compiled today. Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com> Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documentation/guides/fork_vfork_migration.rst is new. It says what changed and why, gives the three primitives as a table, states plainly what breaks, and answers "which replacement do I want?" from the reader's own reason for having called fork() -- posix_spawn() or vfork() to run a program, pthread_create() or task_fork() for a second flow of control that shares memory, fork() itself for an independent copy, FORK_IS_TASK_FORK for code that cannot be changed today. It also documents the seven configuration symbols, what an architecture has to implement to gain real fork(), and the one visible consequence of moving the vfork() suspension into the kernel: a waitpid() after a child that _exit()s can only report status where CONFIG_SCHED_CHILD_STATUS is enabled. reference/user/01_task_control.rst gains entries for fork() and task_fork() and rewrites the one for vfork(), which described NuttX's limitations rather than the interface's contract. standards/posix.rst moves fork() from "No" to "Cond." and vfork() from "Yes" to "Cond.", both being conditional on the configuration now. implementation/memory_configurations.rst no longer lists fork() as unimplementable in the presence of address environments, which was the whole point of that section's wish list. Three long-standing typos in that file are corrected while touching it, since codespell checks the whole of any file a patch modifies. Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com> Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
casaroli
force-pushed
the
fork-semantics-core
branch
from
July 28, 2026 07:11
f8d329e to
e5675dc
Compare
jerpelea
requested changes
Jul 28, 2026
jerpelea
left a comment
Contributor
There was a problem hiding this comment.
please replace
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
with
Assisted-by: Claude Opus 5 (1M context) noreply@anthropic.com
casaroli
force-pushed
the
fork-semantics-core
branch
from
July 28, 2026 07:53
e5675dc to
5287ae3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note: Please adhere to Contributing Guidelines.
Summary
This implements step 1 of the plan agreed in #19540
(ordering,
go-ahead):
the core semantics, plus the
arch/Kconfigchange that withdrawsfork()from every architecture so that per-architecture patches can restore it, one
at a time, with real POSIX semantics.
Companion PR: apache/nuttx-apps#3673. It must merge first. It is written
to work against NuttX with or without this change, so no
ostestcoverage islost across the transition.
The problem
NuttX implements
fork()andvfork()as the same function. Both are libcwrappers around a single
up_fork();vfork()differs only by a trailingwaitpid(). Underneath, the child joins the parent's address environment — thesame
addrenv_join()thatpthread_create()uses — and gets a private copy ofthe stack. The child therefore shares
.data,.bssand the heap with itsparent and runs concurrently with it.
That is not
fork(). It isvfork()-with-a-private-stack published underfork()'s name, and the history says so: today'sfork()is NuttX's oldvfork(), renamed inc33d1c9c97(2023) with no change of behaviour. NuttX's own comment on
nxtask_setup_fork()documentsfork()by quoting the POSIX definition ofvfork(), word for word.The failure mode is the worst one available: silent. A program written
against POSIX
fork()compiles, links, runs — and has its child's writes landin the parent's variables. No diagnostic, no error.
What this does
Three primitives, chosen by which function the caller called rather than by
what the hardware happens to be:
fork()ARCH_HAVE_FORKvfork()_exit()/exec()ARCH_HAVE_VFORKtask_fork()ARCH_HAVE_TASK_FORKBelow libc there are now three syscalls —
up_task_fork(),up_vfork()andup_fork(). The per-architecture register snapshot is common to all three:each architecture's three entry points share one sequence and differ only in a
FORK_TYPE_*selector (include/nuttx/fork.h) handed tonxtask_setup_fork(), which is the single place the memory semantics aredecided. Per architecture the plumbing is +5…+42 lines; nothing is rewritten.
task_fork()is today's behaviour under an honest name. Notfork(), notvfork(): a task cloned at the call site with the memory relationship of athread. The nearest precedent is Plan 9's
rfork(RFPROC|RFMEM)— data and bssshared, stack copied. The name deliberately avoids "vfork", because the
defining property of
vfork()is the suspension, which this primitive does nothave; something like
stack_vfork()would recreate the very confusion thisremoves.
The
vfork()parent suspension moves out of libc into the kernel —nxtask_start_vfork(), released fromnxsched_release_tcb(). Two thingsfollow. The parent is now resumed at
exec()as POSIX requires, becauseexec_swap()has already handed the child's pid to the loaded program by thetime the
vforkstub exits. Andvfork()no longer depends onCONFIG_SCHED_WAITPID— until now, a configuration without that option had novfork()at all.That release point needs one fix in
nxtask_exit(). It raisesrtcb->lockcountdirectly rather than throughsched_lock()while it tearsthe TCB down, so the
nxsem_post()that wakes thevfork()parent queues it ong_pendingtasks— and the matching rawlockcount--does not merge that listthe way
sched_unlock()would. The parent is left stranded with nothing to moveit off. A
nxsched_merge_pending()after the decrement publishes it; the callis a no-op while pre-emption is still disabled, and
up_exit()re-readsthis_task()afterwards. Without it,vfork()deadlocks onrv-virt:nsh64andrv-virt:pnsh64, where NSH is blocked inwaitpid()holding the lock and nothing else calls
sched_unlock().fork()is built on a newaddrenv_fork(), backed by anup_addrenv_fork()hook that duplicates an address environment into freshlyallocated pages mapped at the same virtual addresses — unlike
up_addrenv_clone(), which copies only the representation and leaves bothpointing at the same page tables. The child then adopts the parent's stack
geometry rather than being given a relocated copy: a pointer to a stack local
taken before
fork()must name the same object in the child that it named inthe parent, and the parent's stack is already in the duplicate, at the parent's
address, with its contents. Nothing is allocated and nothing is copied for it.
There is no copy-on-write — NuttX has no demand paging to build it on — so the
copy is eager and can fail with
ENOMEM. That is the nature of the primitive,not a defect:
fork()'s value here is correctness for portable code.Spawn-heavy code should prefer
posix_spawn()orvfork(), on NuttX asanywhere.
Why
fork()ends up unavailable everywhereNo architecture implements
up_addrenv_fork()in this PR, soCONFIG_ARCH_HAVE_FORKis unset in every configuration andfork()is notdeclared. That is deliberate, and it is step 3 of the agreed ordering folded
into step 1 — the two cannot be cleanly separated, because step 1 is what
redefines
CONFIG_ARCH_HAVE_FORKto mean "can provide POSIX semantics", andsplitting them would require an intermediate release in which the symbol means
two things at once.
Per-architecture PRs restore
fork()asup_addrenv_fork()lands. Thegeneric machinery is complete and needs no further work: an architecture
implements the hook, adds one
default y ifline, andfork()becomes live.Impact
This is a breaking change, and it breaks loudly rather than quietly.
fork()disappears fromARCH_ARM, flatARCH_ARM64,ARCH_RISCV,ARCH_SIMandARCH_X86_64. Code that calls it fails to build, with anerror naming the function. That is the point: a build error is strictly better
than the silent runtime wrongness it replaces, and it is the only signal
portable code can act on — there is no feature-test macro or
sysconf()queryby which an application can discover that a
fork()does not copy.CONFIG_FORK_IS_TASK_FORK=yrestores the previous behaviour exactly — samesharing, same concurrency, no new suspension — on precisely the configurations
that had
fork()before.default n, so the honest behaviour is what you getunless you ask otherwise, and the Kconfig help states plainly what you are
opting into. Existing users flip one switch and are where they were.
If withdrawing
fork()in a single release is judged too abrupt, the switchcan ship
default ywith a deprecation warning for a cycle. The end stateshould still be
default n; the schedule is negotiable, the destinationshouldn't be.
vfork()changes behaviour. The parent is now suspended in the kernel andreleased when the child's TCB is torn down, so by the time it runs the child is
completely gone. Where the child called
exec()this makes no difference:exec_swap()has already given the loaded program the child's pid and thatprogram is still running, so
waitpid()behaves normally. Where the childcalled
_exit(),waitpid()can only return its status ifCONFIG_SCHED_CHILD_STATUSis enabled; otherwise it returnsECHILD. That is apre-existing property of that configuration rather than a change — the previous
implementation blocked in a libc
waitpid(WNOWAIT)and an application's ownwaitpid()afterwards hit the same wall.vfork()gains availability: it no longer depends onCONFIG_SCHED_WAITPID.ARM kernel builds lose
vfork()/task_fork(), and that is a fix.ARCH_ARMselected the fork family unconditionally,BUILD_KERNELincluded,and it has never worked there: on a kernel build the architecture's fork entry
point sees the kernel's return address and stack pointer rather than the
caller's, so the child resumes at a kernel address. On
qemu-armv7a:knsh,unmodified master faults in
ostest'stask_forkcase with "Child did notrun" and then a data abort.
ARCH_ARM64andARCH_X86_64already carriedif !BUILD_KERNELfor exactly this reason — ARM was the outlier. Conditioningit turns a latent runtime fault into an honest absence, and
qemu-armv7a:knshnow runs
ostestto status 0.arch/armtakes the condition off again in thepatch that adds its saved-syscall-frame path. Cortex-M is unaffected, since it
cannot build
BUILD_KERNELat all.Nothing is deleted. Every line of the existing machinery survives as
task_fork().apps: companion PR, must merge first — until it does, this PR's CIcannot go green, because NuttX PRs build against
appsmaster and threethings there call
fork()unconditionally. The companion is deliberatelytolerant of both worlds, so
ostestkeeps runningtask_fork_testandvfork_testagainst NuttX master today and against this PR tomorrow. A smallfollow-up drops the compatibility fallbacks afterwards.
The full
appsaudit, for reviewers who want the blast radius up front:testing/ostesttesting/ltpopen_posix_testsuitefiles dropped via LTP's existingBLACKWORDS; no-op whilefork()existssystem/libuvtesting/drivers/nand_simtask_fork()all alonginterpreters/python,netutils/libwebsockets,testing/fs/fdsantestvfork()vsfork()games/NXDoom,system/syslogd#if 0, the other already usesposix_spawn()interpreters/basbas_statement.chas 1681 pre-existing style findings, so CI rejects any patch touching itIn-tree,
libs/libbuiltin/libgcc/gcov.cwas the only NuttX-side caller;__gcov_fork()is now compiled under the same conditionunistd.huses todeclare
fork().#19544 (
sched/addrenv: do not dereference a NULL address environment) wasa prerequisite for
BUILD_PROTECTEDconfigurations withCONFIG_ARCH_ADDRENV.It has since merged, and this branch is rebased on top of it, so there is
no outstanding dependency.
Documentation:
Documentation/guides/fork_vfork_migration.rstis new andanswers "which replacement do I want?" from the reader's own reason for having
called
fork().reference/user/01_task_control.rstgainsfork()andtask_fork()entries and rewritesvfork()'s.standards/posix.rstmovesfork()from "No" to "Cond." andvfork()from "Yes" to "Cond.".Unrelated finding, reported separately:
arm_fork()copiesxcp.syscall[].sysreturnand.excreturnto the child but not.ctrlreturn.The child's TCB is
kmm_zalloc()ed, soCONTROL == 0— nPRIV clear — and inBUILD_PROTECTEDa Cortex-M child returns to user space privileged whileits parent is unprivileged. Measured on an RP2350 (Cortex-M33): parent
ctrlreturn0x00000001, child0x00000000. It applies to master unchangedand has nothing to do with this change, so it is filed on its own.
Testing
Host: macOS 15 (Darwin 25.5.0) on Apple Silicon.
Toolchains: xPack
riscv-none-elf-gcc14.2.0-3; Arm GNUarm-none-eabi-gccand
aarch64-none-elf-gcc14.2.Rel1 (darwin-arm64); Homebrewx86_64-elf-gcc; QEMU 11.0.3.appsat the companion PR's branch throughout.Run under QEMU — full
ostestsuite, exit status 0 on every onetask_fork()vfork()fork()ostestrv-virt:nsh64rv-virt:pnsh64rv-virt:knsh64ARCH_ADDRENVqemu-armv7a:nshqemu-armv8a:nshqemu-intel64:nshrv-virt:knsh64is the interesting row: it is aBUILD_KERNEL+CONFIG_ARCH_ADDRENVconfiguration, and it is wherefork()visibly goes away.vfork()there also exercises the kernel-side suspension across a system call."absent" was checked in the linked image, not merely inferred from the config.
nmshowsup_task_fork,up_vfork,task_forkandvfork, and nofork,up_forkorfork_test. For example,rv-virt:nsh64:Built clean
stm32f4discovery:nsh(armv7-m),mps2-an521:nsh(armv8-m),sabre-6quad:nsh(armv7-a),sim:ostest— the last withCONFIG_MM_KASANdisabled, see the note below. On
sim,task_fork_testandvfork_testalsopass at run time.
The break, and the escape hatch, both verified
Without
CONFIG_FORK_IS_TASK_FORK(qemu-armv8a:nsh), a call tofork()is abuild error naming the function, while the honest spellings still compile:
With
CONFIG_FORK_IS_TASK_FORK=y(rv-virt:nsh64), the same translation unitcompiles,
unistd.hdeclaresfork()again, andstaging/libc.aprovides it:fork_testcorrectly stays out of that build: the alias setsFORK_IS_TASK_FORK, notARCH_HAVE_FORK, so no POSIXfork()test isadvertised for a configuration that does not have POSIX
fork().Style and docs
tools/checkpatch.sh -c -u -m -g 7fd17c9d7c..HEAD, the exact command.github/workflows/check.ymlruns — ✔️ All checks pass, withcodespell,cvt2utf,cmake-formatandnxstyleall installed. Note that codespellchecks the whole of any file a patch touches, which is why the docs commit
also corrects three long-standing typos in
implementation/memory_configurations.rst.sphinx-build -b htmloverDocumentation/— build succeeded, 1 warning,and that warning is the pre-existing
plantuml command cannot be runinguides/port_bootsequence.rst, unrelated to this change.Note on two pre-existing failures met on the way
Neither is caused by this change; both were worked around for the runs above.
Reported here only so the workarounds in the numbers are not mistaken for
something this PR needs.
CONFIG_ALLSYMS=yconfigurations failed to link here with a baremake[1]: *** [nuttx] Error 22. That turned out to be a local missingdependency, not a NuttX bug:
tools/mkallsyms.pyexitserrno.EINVAL(22) when
pyelftools/cxxfiltare absent. It is worth reporting onlybecause the script uses
os._exit(), which skips stdio flushing, so itsown "please install" message never reaches the terminal and the build dies
with an unexplained 22. Installing the two packages fixes the build.
sim:ostestdoes not build on macOS/arm64:CONFIG_MM_KASANpasses-fsanitize=kernel-address, which Apple clang does not support forarm64-apple-darwin. Worked around by clearing the sanitizer options. Thisis a host-toolchain limitation, not a NuttX one.