sched/arch/libc: give fork(), vfork() and task_fork() their real, separate semantics - #19562
Open
casaroli wants to merge 3 commits into
Open
sched/arch/libc: give fork(), vfork() and task_fork() their real, separate semantics#19562casaroli wants to merge 3 commits into
casaroli wants to merge 3 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
5 times, most recently
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
2 times, most recently
from
July 29, 2026 11:28
5287ae3 to
ba5a7a6
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. 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>
casaroli
force-pushed
the
fork-semantics-core
branch
from
July 30, 2026 06:17
ba5a7a6 to
f5715ec
Compare
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>
task_fork() is a NuttX extension, not POSIX, and a configuration that does not call it has no reason to carry it. Add CONFIG_TASK_FORK to leave it out. It depends on ARCH_HAVE_TASK_FORK and defaults to y, so it is enabled exactly where fork() existed before the split and no configuration loses the primitive by upgrading. ARCH_HAVE_TASK_FORK keeps its meaning -- the architecture *can* clone the calling task -- and the new symbol says whether this build wants it. Everything that provides task_fork() moves to the new symbol: the declaration in sched.h, up_task_fork() in arch.h, the two .csv entries that generate the system call stub and proxy, the syscall_lookup.h table entry, the architecture entry points, and the build rules for the files that hold them. FORK_IS_TASK_FORK now depends on TASK_FORK rather than ARCH_HAVE_TASK_FORK. Aliasing fork() to a task_fork() that was not built would not link. vfork() and fork() are untouched; each is still selected on its own, and task_fork.c, lib_fork.c and the architecture's fork file are still built when any one of the three is present. 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 30, 2026 09:47
0132031 to
9b787d4
Compare
casaroli
marked this pull request as ready for review
July 30, 2026 11:31
casaroli
requested review from
anchao,
pussuw and
xiaoxiang781216
as code owners
July 30, 2026 11:31
casaroli
requested review from
Donny9,
GUIDINGLI,
Ouss4,
davids5,
liuguo09,
masayuki2009,
raiden00pl,
simbit18,
yamt and
yf13
as code owners
July 30, 2026 11:31
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.
Summary
This is 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.Where this stands, and what I am asking for
apache/nuttx-apps#3673 needs to merge first. NuttX PRs build against
appsmaster, and three things there callfork()unconditionally, so until the companion lands this PR's CI cannot go green —Linux (sim-01)fails and the rest of the matrix is cancelled behind it. The companion is deliberately written to work against NuttX with or without this change, so merging it early costs nothing and loses no coverage: the fork testostestruns today keeps running against master, under the name of the primitive it has always actually tested. If reviewers are content with it, getting it in soon is what would let this PR show real numbers instead of a red ✗.A green CI here would not mean this is ready to merge, and I would rather it waited. CI builds; it does not run
fork()on your board. This touches the lowest-level machinery in the system — address environments, stack setup, the architecture's register context — and it is a breaking change for anyone who callsfork(). I have tested what I can reach, but almost all of it is QEMU, and only one board was real silicon — an RP2350 (Cortex-M33), wheretask_fork()andvfork()both pass. My matrix is not a substitute for other people's hardware, and I would sooner this sat open for a while and collected evidence than merged on the strength of it.So: please try it on a board you have. Anything at all is useful.
Check out this branch together with testing/ostest: split the fork test into task_fork, vfork and fork nuttx-apps#3673.
Build and run
ostest. The three fork tests now run first, so a fault shows up in seconds rather than after everything else has passed:Build and run your own application — especially if it calls
fork()orvfork(). That is the case I cannot test for you, and it is the one that matters.Useful to report: board and defconfig, build mode (FLAT / PROTECTED / KERNEL), the final
osteststatus, and whether your application still behaves. If it callsfork()you will get a build error naming the function — that is the intended signal, andCONFIG_FORK_IS_TASK_FORK=yis meant to restore the previous behaviour exactly; please tell me if it does not. Failures are more useful to me than successes, and so is the opinion that some part of this is the wrong shape.Real
fork()already works — it is just not in this PRfork()is implemented and passing on most of the MMU-capable architectures already, in a follow-up branch: armv7-a, arm64, risc-v (Sv32 and Sv39), x86_64 and Xtensa LX7. In a twenty-configurationostestsweep there, POSIXfork()passes on all fiveBUILD_KERNELconfigurations, and the Xtensa row is on real ESP32-S3 silicon. Each of those becomes its own PR once this one has settled, because each is a separateup_addrenv_fork()and deserves separate review. Splitting them that way is also the reason this PR temporarily leavesfork()unavailable rather than shipping one architecture's implementation inline — but if reviewers would prefer to see one MMU architecture land alongside the core so that the result is demonstrable in-tree, I am happy to do that instead (discussion).The problem
NuttX implements
fork()andvfork()as the same function. Both are libc wrappers around a singleup_fork();vfork()differs only by a trailingwaitpid(). Underneath, the child joins the parent's address environment — the sameaddrenv_join()thatpthread_create()uses — and gets a private copy of the stack. The child therefore shares.data,.bssand the heap with its parent 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 onnxtask_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 land in 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 aFORK_TYPE_*selector (include/nuttx/fork.h) handed tonxtask_setup_fork(), which is the single place the memory semantics are decided. 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 a thread. The nearest precedent is Plan 9'srfork(RFPROC|RFMEM)— data and bss shared, stack copied. The name deliberately avoids "vfork", because the defining property ofvfork()is the suspension, which this primitive does not have; something likestack_vfork()would recreate the very confusion this removes.The
vfork()parent suspension moves out of libc into the kernel —nxtask_start_vfork(), released fromnxsched_release_tcb(). Two things follow. The parent is now resumed atexec()as POSIX requires, becauseexec_swap()has already handed the child's pid to the loaded program by the time thevforkstub 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 tears the TCB down, so thenxsem_post()that wakes thevfork()parent queues it ong_pendingtasks— and the matching rawlockcount--does not merge that list the waysched_unlock()would. The parent is left stranded with nothing to move it off. Anxsched_merge_pending()after the decrement publishes it; the call is a no-op while pre-emption is still disabled, andup_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 callssched_unlock().fork()is built on a newaddrenv_fork(), backed by anup_addrenv_fork()hook that duplicates an address environment into freshly allocated pages mapped at the same virtual addresses — unlikeup_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 beforefork()must name the same object in the child that it named in the 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 preferposix_spawn()orvfork(), on NuttX as anywhere.Why
fork()ends up unavailable everywhereNo architecture implements
up_addrenv_fork()in this PR, soCONFIG_ARCH_HAVE_FORKis unset in every configuration andfork()is not declared. 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 redefinesCONFIG_ARCH_HAVE_FORKto mean "can provide POSIX semantics", and splitting them would require an intermediate release in which the symbol means two things at once.The generic 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 an error 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 orsysconf()query by which an application can discover that afork()does not copy.CONFIG_FORK_IS_TASK_FORK=yrestores the previous behaviour exactly — same sharing, same concurrency, no new suspension — on precisely the configurations that hadfork()before.default n, so the honest behaviour is what you get unless 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 switch can shipdefault ywith a deprecation warning for a cycle. The end state should still bedefault n; the schedule is negotiable, the destination shouldn't be.vfork()changes behaviour. The parent is now suspended in the kernel and released when the child's TCB is torn down, so by the time it runs the child is completely gone. Where the child calledexec()this makes no difference:exec_swap()has already given the loaded program the child's pid and that program is still running, sowaitpid()behaves normally. Where the child called_exit(),waitpid()can only return its status ifCONFIG_SCHED_CHILD_STATUSis enabled; otherwise it returnsECHILD. That is a pre-existing property of that configuration rather than a change — the previous implementation blocked in a libcwaitpid(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. Onqemu-armv7a:knsh, unmodified master faults inostest'stask_forkcase with "Child did not run" and then a data abort.ARCH_ARM64andARCH_X86_64already carriedif !BUILD_KERNELfor exactly this reason — ARM was the outlier. Conditioning it turns a latent runtime fault into an honest absence, andqemu-armv7a:knshnow runsostestto status 0.arch/armtakes the condition off again in the patch that adds its saved-syscall-frame path. Cortex-M is unaffected, since it cannot buildBUILD_KERNELat all.Nothing is deleted. Every line of the existing machinery survives as
task_fork().task_fork()is optional, and on by default.CONFIG_TASK_FORKdepends onARCH_HAVE_TASK_FORKand defaults toy, so it is enabled exactly wherefork()existed before and no configuration loses the primitive by upgrading. The two symbols say different things:ARCH_HAVE_TASK_FORKis the architecture's capability,TASK_FORKis whether this build asked for it. Turning it off drops the architecture entry point, the system call and the libc stub — worth having becausetask_fork()is a NuttX extension that a configuration calling onlyvfork()need not carry.vfork()andfork()are unaffected; each is still selected on its own.apps: companion PR, must merge first, as above. The full audit, 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 to declarefork().Documentation:
Documentation/guides/fork_vfork_migration.rstis new and answers "which replacement do I want?" from the reader's own reason for having calledfork().reference/user/01_task_control.rstgainsfork()andtask_fork()entries and rewritesvfork()'s.standards/posix.rstmovesfork()from "No" to "Cond." andvfork()from "Yes" to "Cond.".Testing
Host: macOS 15 (Darwin 25.5.0) on Apple Silicon. Toolchains: xPack
riscv-none-elf-gcc14.2.0-3; Arm GNUarm-none-eabi-gccandaarch64-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, not merely inferred from the config.
nmshowsup_task_fork,up_vfork,task_forkandvfork, and nofork,up_forkorfork_test. For example,rv-virt:nsh64:Run under real hardware — full
ostestsuite, exit status 0 on every onetask_fork()vfork()fork()ostestpimoroni-pico-2-plus:nsh(RP2350, Cortex-M33)pimoroni-pico-2-plus:pnsh(RP2350, Cortex-M33)Both flashed over a Raspberry Pi Debug Probe.
pnshis the row worth noting: a protected build on an MPU, wherefork()cannot exist at all because a Cortex-M33 has no MMU to duplicate an address environment.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. Onsim,task_fork_testandvfork_testalso pass at run time.The break, and the escape hatch, both verified
Without
CONFIG_FORK_IS_TASK_FORK(qemu-armv8a:nsh), a call tofork()is a build error naming the function, while the honest spellings still compile:With
CONFIG_FORK_IS_TASK_FORK=y(rv-virt:nsh64), the same translation unit compiles,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 is advertised for a configuration that does not have POSIXfork().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 codespell checks the whole of any file a patch touches, which is why the docs commit also corrects three long-standing typos inimplementation/memory_configurations.rst.sphinx-build -b htmloverDocumentation/— build succeeded, 1 warning, and that warning is the pre-existingplantuml command cannot be runinguides/port_bootsequence.rst, unrelated to this change.