sched/arch/libc: give fork(), vfork() and task_fork() their real, separate semantics - #19562
sched/arch/libc: give fork(), vfork() and task_fork() their real, separate semantics#19562casaroli wants to merge 3 commits into
Conversation
|
|
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):
|
f8d329e to
e5675dc
Compare
jerpelea
left a comment
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
0132031 to
9b787d4
Compare
🔗 Cross-repo PR dependenciesThe read-only Build run reported the following dependent PR(s) and fetched head SHA(s):
CI run: https://github.com/apache/nuttx/actions/runs/30747464194 |
🔗 Cross-repo PR dependenciesThe read-only Build run reported the following dependent PR(s) and fetched head SHA(s):
CI run: https://github.com/apache/nuttx/actions/runs/30747546033 |
Three places call fork() from code that is compiled unconditionally, which is fine only for as long as every architecture provides it. NuttX is splitting fork() into three primitives -- see apache/nuttx#19562 -- after which ARCH_HAVE_FORK announces POSIX fork() specifically, and is off until an architecture implements it. These three then fail to link. system/libuv: test-fork.c and test-pipe-close-stdout-read-stdin.c are filtered out of the test-*.c glob. Every test they define is already excluded from the task list on NuttX by 0001-libuv-port-for-nuttx.patch, so they were dead code that compiled only because fork() happened to be declared. Nothing is lost. testing/ltp: the open_posix_testsuite is filtered through LTP's existing BLACKWORDS mechanism, which already drops tests for absent features and is already conditioned on configuration symbols. The pattern spares vfork() and task_fork(). Where fork() is absent this drops 278 of 1943 test files; those tests exercise fork() and cannot link without it, and they return per architecture as fork() lands. testing/drivers/nand_sim: gains in Kconfig the dependency it always had implicitly. It accepts either primitive -- it wants a daemon that outlives its caller and shares its memory, which today's fork() provides and task_fork() will provide after the split -- so gating it on ARCH_HAVE_FORK alone would silently drop it from the two sim configurations that enable it once that symbol is withdrawn. Against today's master this is a no-op: ARCH_HAVE_FORK is set everywhere, so nothing is filtered and nothing is disabled. It is what lets the NuttX side build against apps master. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
NuttX implements fork() and vfork() as the same function and is gaining three separate primitives -- see apache/nuttx#19562: task_fork() (shares memory, private stack copy, both running), vfork() (shares memory, parent suspended until _exit()/exec()) and POSIX fork() (child gets its own copy). This gives each one a test of its own. ostest's "vfork" test was never testing vfork(). It has the child write a global and the parent observe the write -- the defining property of *sharing*, not of vfork(), whose defining property is that the parent is suspended and whose contract forbids the child to write anything at all. It is renamed to task_fork.c, unchanged, because that is the primitive it has always described. vfork.c is rewritten to test what vfork() promises. The child does only what POSIX permits: it calls _exit(42) and nothing else, not even exit(), which would run atexit handlers and flush stdio in the parent's address space. Since the child may not write memory and the parent cannot run while the child lives, the observable is the child's exit status -- had the parent not been suspended it would have reached waitpid() while the child was still alive. Where child status is not retained, because ostest_main() sets SA_NOCLDWAIT for the whole run, ECHILD is accepted as equally good evidence. fork.c is new and tests POSIX fork(): the child's writes to .data, .bss and the heap are invisible to the parent and vice versa, a pointer to a stack local taken before the fork names the same object in both, and the child does everything a vfork() child may not -- calls malloc() and printf(), and returns from the function that called fork(). All three run at the top of user_main(). They exercise the lowest-level machinery in the suite -- address environments, stack setup, the architecture's register context -- so a fault in one takes the process down instead of reporting a failure. Learning that in seconds rather than after everything else has passed matters when a port is being brought up. Each test gates on the one primitive it tests and nothing stands in for anything. task_fork_test() keys on CONFIG_TASK_FORK rather than the capability symbol: ARCH_HAVE_TASK_FORK says the architecture can clone a task, TASK_FORK says this build asked for it, and task_fork() is declared only under the latter. TESTING_NAND_SIM depends on the same symbol for the same reason -- it wants a daemon that outlives its caller and shares its memory, which is task_fork(). vfork_test() and fork_test() have no such split and key on ARCH_HAVE_VFORK and ARCH_HAVE_FORK directly. The other in-tree callers are audited for which primitive they meant: python's _posixsubprocess and libwebsockets' LWS_HAVE_WORKING_VFORK want the fork-then-exec path, so they follow ARCH_HAVE_VFORK; python's os.fork() and libwebsockets' LWS_HAVE_FORK mean real fork() and stay on ARCH_HAVE_FORK, so they become absent rather than silently wrong; fdsantest's vfork case follows vfork(). Depends on apache/nuttx#19562 and must not merge before it. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
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>
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>
9b787d4 to
82f8e8b
Compare
🔗 Cross-repo PR dependenciesThe read-only Build run reported the following dependent PR(s) and fetched head SHA(s):
CI run: https://github.com/apache/nuttx/actions/runs/30753741821 |
Two places call fork() from code that is compiled unconditionally, which is fine only for as long as every architecture provides it. NuttX is splitting fork() into three primitives -- see apache/nuttx#19562 -- after which ARCH_HAVE_FORK announces POSIX fork() specifically, and is off until an architecture implements it. Both then fail to link. system/libuv: test-fork.c and test-pipe-close-stdout-read-stdin.c are filtered out of the test-*.c glob. Every test they define is already excluded from the task list on NuttX by 0001-libuv-port-for-nuttx.patch, so they were dead code that compiled only because fork() happened to be declared. Nothing is lost. testing/ltp: the open_posix_testsuite is filtered through LTP's existing BLACKWORDS mechanism, which already drops tests for absent features and is already conditioned on configuration symbols. The pattern spares vfork() and task_fork(). Where fork() is absent this drops 278 of 1943 test files; those tests exercise fork() and cannot link without it, and they return per architecture as fork() lands. Against today's master this is a no-op: ARCH_HAVE_FORK is set everywhere, so nothing is filtered. It is part of what lets the NuttX side build against apps master. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
…ground. Neither of these wants fork() semantics. Both reach for fork() only to put work in the background, and each has a NuttX-native way to do that, so neither needs a fork primitive at all -- which matters once apache/nuttx#19562 makes ARCH_HAVE_FORK conditional on the architecture implementing POSIX fork(). netutils/dropbear: the port already routes every fork-then-exec through vfork(), because sysoptions.h selects DROPBEAR_VFORK when HAVE_FORK is undefined and the port leaves it undefined. spawn_command() in dbutil.c and both call sites in scp.c follow that switch. The one exception is the daemon() fallback that compat.c compiles under #ifndef HAVE_DAEMON, which calls fork() directly and bypasses it. NuttX provides daemon() in libs/libc/unistd/lib_daemon.c and declares it in unistd.h, so the fallback is redundant; define HAVE_DAEMON alongside the HAVE_STRLCAT and HAVE_STRLCPY entries that are there for exactly the same reason. The code was unreachable in any case -- the port hands svr_getopts() an argv containing -F, so svr_opts.forkbg is always zero and dropbear never calls daemon() at all. testing/drivers/nand_sim: forked so that the parent could return to the shell while the child registered the MTD device and slept forever. Nothing from before the fork is used after it, so the child is a self-contained entry point, and task_create() expresses that directly. The emulator body moves into nand_sim_daemon() unchanged. TESTING_NAND_SIM therefore needs no fork dependency, and the two sim configurations that enable it keep working whatever ARCH_HAVE_FORK is set to. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
NuttX implements fork() and vfork() as the same function and is gaining three separate primitives -- see apache/nuttx#19562: task_fork() (shares memory, private stack copy, both running), vfork() (shares memory, parent suspended until _exit()/exec()) and POSIX fork() (child gets its own copy). This gives each one a test of its own. ostest's "vfork" test was never testing vfork(). It has the child write a global and the parent observe the write -- the defining property of *sharing*, not of vfork(), whose defining property is that the parent is suspended and whose contract forbids the child to write anything at all. It is renamed to task_fork.c, unchanged, because that is the primitive it has always described. vfork.c is rewritten to test what vfork() promises. The child does only what POSIX permits: it calls _exit(42) and nothing else, not even exit(), which would run atexit handlers and flush stdio in the parent's address space. Since the child may not write memory and the parent cannot run while the child lives, the observable is the child's exit status -- had the parent not been suspended it would have reached waitpid() while the child was still alive. Where child status is not retained, because ostest_main() sets SA_NOCLDWAIT for the whole run, ECHILD is accepted as equally good evidence. fork.c is new and tests POSIX fork(): the child's writes to .data, .bss and the heap are invisible to the parent and vice versa, a pointer to a stack local taken before the fork names the same object in both, and the child does everything a vfork() child may not -- calls malloc() and printf(), and returns from the function that called fork(). All three run at the top of user_main(). They exercise the lowest-level machinery in the suite -- address environments, stack setup, the architecture's register context -- so a fault in one takes the process down instead of reporting a failure. Learning that in seconds rather than after everything else has passed matters when a port is being brought up. Each test gates on the one primitive it tests and nothing stands in for anything. task_fork_test() keys on CONFIG_TASK_FORK rather than the capability symbol: ARCH_HAVE_TASK_FORK says the architecture can clone a task, TASK_FORK says this build asked for it, and task_fork() is declared only under the latter. vfork_test() and fork_test() have no such split and key on ARCH_HAVE_VFORK and ARCH_HAVE_FORK directly. The other in-tree callers are audited for which primitive they meant: python's _posixsubprocess and libwebsockets' LWS_HAVE_WORKING_VFORK want the fork-then-exec path, so they follow ARCH_HAVE_VFORK; python's os.fork() and libwebsockets' LWS_HAVE_FORK mean real fork() and stay on ARCH_HAVE_FORK, so they become absent rather than silently wrong; fdsantest's vfork case follows vfork(). Depends on apache/nuttx#19562 and must not merge before it. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
| .type arm_fork_common, function | ||
| #endif | ||
|
|
||
| arm_fork_common: |
There was a problem hiding this comment.
why not keep the name as up_fork but add FORK_TYPE_XXX as it's argument?
| */ | ||
|
|
||
| mov r0, sp | ||
| mov r1, r2 |
There was a problem hiding this comment.
why not pass r0 as FORK_TYPE_xxx and r1 as sp
| ****************************************************************************/ | ||
|
|
||
| pid_t arm_fork(const struct fork_s *context) | ||
| pid_t arm_fork(const struct fork_s *context, int type) |
There was a problem hiding this comment.
swap the argument order
| ****************************************************************************/ | ||
|
|
||
| pid_t arm64_fork(const struct fork_s *context) | ||
| pid_t arm64_fork(const struct fork_s *context, int type) |
|
|
||
| #ifdef CONFIG_ARCH_HAVE_TASK_FORK | ||
| GTEXT(up_task_fork) | ||
| SECTION_FUNC(text, up_task_fork) |
| Where this is not selected fork() is not provided at all, and code | ||
| that calls it fails to build -- see FORK_IS_TASK_FORK. | ||
|
|
||
| config FORK_IS_TASK_FORK |
There was a problem hiding this comment.
let's remove this option
| default n | ||
| depends on ARCH_HAVE_MULTICPU | ||
|
|
||
| config ARCH_HAVE_TASK_FORK |
There was a problem hiding this comment.
remove? we don't need another nonstandard fork implementation.
| config ARCH_HAVE_ADDRENV_FORK | ||
| bool | ||
| default n | ||
| depends on ARCH_ADDRENV && !ARCH_STACK_DYNAMIC |
There was a problem hiding this comment.
why can't implement with ARCH_STACK_DYNAMIC
|
|
||
| config ARCH_HAVE_FORK | ||
| bool | ||
| default y if ARCH_ADDRENV && ARCH_HAVE_ADDRENV_FORK |
There was a problem hiding this comment.
depends on ARCH_ADDRENV
remove ARCH_HAVE_ADDRENV_FORK,
| ****************************************************************************/ | ||
|
|
||
| #ifdef CONFIG_ARCH_HAVE_TASK_FORK | ||
| #ifdef CONFIG_TASK_FORK |
There was a problem hiding this comment.
let's drop task_fork directly
Two places call fork() from code that is compiled unconditionally, which is fine only for as long as every architecture provides it. NuttX is splitting fork() into three primitives -- see apache/nuttx#19562 -- after which ARCH_HAVE_FORK announces POSIX fork() specifically, and is off until an architecture implements it. Both then fail to link. Each is dropped only where ARCH_HAVE_FORK is unset, so builds that have fork() are unaffected. system/libuv: test-fork.c and test-pipe-close-stdout-read-stdin.c are filtered out of the test-*.c glob. Nothing is lost even where they are dropped: every test they define is already excluded from the task list on NuttX by 0001-libuv-port-for-nuttx.patch, which extends the _WIN32 guards around them to __NuttX__ -- all nine fork_* entries and pipe_close_stdout_read_stdin. They are compiled today but never run. testing/ltp: the open_posix_testsuite is filtered through LTP's existing BLACKWORDS mechanism, which already drops tests for absent features and is already conditioned on configuration symbols. The pattern spares vfork() and task_fork(). Where fork() is absent this drops 278 of 1943 test files; those tests exercise fork() and cannot link without it, and they return per architecture as fork() lands. Against today's master this is a no-op: ARCH_HAVE_FORK is set everywhere, so neither filter drops anything. It is part of what lets the NuttX side build against apps master. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
…ground. Neither of these wants fork() semantics. Both reach for fork() only to put work in the background, and each has a NuttX-native way to do that, so neither needs a fork primitive at all -- which matters once apache/nuttx#19562 makes ARCH_HAVE_FORK conditional on the architecture implementing POSIX fork(). netutils/dropbear: the port already routes every fork-then-exec through vfork(), because sysoptions.h selects DROPBEAR_VFORK when HAVE_FORK is undefined and the port leaves it undefined. spawn_command() in dbutil.c and both call sites in scp.c follow that switch. The one exception is the daemon() fallback that compat.c compiles under #ifndef HAVE_DAEMON, which calls fork() directly and bypasses it. NuttX provides daemon() in libs/libc/unistd/lib_daemon.c and declares it in unistd.h, so the fallback is redundant; define HAVE_DAEMON alongside the HAVE_STRLCAT and HAVE_STRLCPY entries that are there for exactly the same reason. The code was unreachable in any case -- the port hands svr_getopts() an argv containing -F, so svr_opts.forkbg is always zero and dropbear never calls daemon() at all. testing/drivers/nand_sim: forked so that the parent could return to the shell while the child registered the MTD device and slept forever. Nothing from before the fork is used after it, so the child is a self-contained entry point, and task_create() expresses that directly. The emulator body moves into nand_sim_daemon() unchanged. TESTING_NAND_SIM therefore needs no fork dependency, and the two sim configurations that enable it keep working whatever ARCH_HAVE_FORK is set to. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
NuttX implements fork() and vfork() as the same function and is gaining three separate primitives -- see apache/nuttx#19562: task_fork() (shares memory, private stack copy, both running), vfork() (shares memory, parent suspended until _exit()/exec()) and POSIX fork() (child gets its own copy). This gives each one a test of its own. ostest's "vfork" test was never testing vfork(). It has the child write a global and the parent observe the write -- the defining property of *sharing*, not of vfork(), whose defining property is that the parent is suspended and whose contract forbids the child to write anything at all. It is renamed to task_fork.c, unchanged, because that is the primitive it has always described. vfork.c is rewritten to test what vfork() promises. The child does only what POSIX permits: it calls _exit(42) and nothing else, not even exit(), which would run atexit handlers and flush stdio in the parent's address space. Since the child may not write memory and the parent cannot run while the child lives, the observable is the child's exit status -- had the parent not been suspended it would have reached waitpid() while the child was still alive. Where child status is not retained, because ostest_main() sets SA_NOCLDWAIT for the whole run, ECHILD is accepted as equally good evidence. fork.c is new and tests POSIX fork(): the child's writes to .data, .bss and the heap are invisible to the parent and vice versa, a pointer to a stack local taken before the fork names the same object in both, and the child does everything a vfork() child may not -- calls malloc() and printf(), and returns from the function that called fork(). All three run at the top of user_main(). They exercise the lowest-level machinery in the suite -- address environments, stack setup, the architecture's register context -- so a fault in one takes the process down instead of reporting a failure. Learning that in seconds rather than after everything else has passed matters when a port is being brought up. Each test gates on the one primitive it tests and nothing stands in for anything. task_fork_test() keys on CONFIG_TASK_FORK rather than the capability symbol: ARCH_HAVE_TASK_FORK says the architecture can clone a task, TASK_FORK says this build asked for it, and task_fork() is declared only under the latter. vfork_test() and fork_test() have no such split and key on ARCH_HAVE_VFORK and ARCH_HAVE_FORK directly. The other in-tree callers are audited for which primitive they meant: python's _posixsubprocess and libwebsockets' LWS_HAVE_WORKING_VFORK want the fork-then-exec path, so they follow ARCH_HAVE_VFORK; python's os.fork() and libwebsockets' LWS_HAVE_FORK mean real fork() and stay on ARCH_HAVE_FORK, so they become absent rather than silently wrong; fdsantest's vfork case follows vfork(). Depends on apache/nuttx#19562 and must not merge before it. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Two places call fork() from code that is compiled unconditionally, which is fine only for as long as every architecture provides it. NuttX is splitting fork() into three primitives -- see apache/nuttx#19562 -- after which ARCH_HAVE_FORK announces POSIX fork() specifically, and is off until an architecture implements it. Both then fail to link. Each is dropped only where ARCH_HAVE_FORK is unset, so builds that have fork() are unaffected. system/libuv: test-fork.c and test-pipe-close-stdout-read-stdin.c are filtered out of the test-*.c glob. Nothing is lost even where they are dropped: every test they define is already excluded from the task list on NuttX by 0001-libuv-port-for-nuttx.patch, which extends the _WIN32 guards around them to __NuttX__ -- all nine fork_* entries and pipe_close_stdout_read_stdin. They are compiled today but never run. testing/ltp: the open_posix_testsuite is filtered through LTP's existing BLACKWORDS mechanism, which already drops tests for absent features and is already conditioned on configuration symbols. The pattern spares vfork() and task_fork(). Where fork() is absent this drops 278 of 1943 test files; those tests exercise fork() and cannot link without it, and they return per architecture as fork() lands. Against today's master this is a no-op: ARCH_HAVE_FORK is set everywhere, so neither filter drops anything. It is part of what lets the NuttX side build against apps master. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
…ground. Neither of these wants fork() semantics. Both reach for fork() only to put work in the background, and each has a NuttX-native way to do that, so neither needs a fork primitive at all -- which matters once apache/nuttx#19562 makes ARCH_HAVE_FORK conditional on the architecture implementing POSIX fork(). netutils/dropbear: the port already routes every fork-then-exec through vfork(), because sysoptions.h selects DROPBEAR_VFORK when HAVE_FORK is undefined and the port leaves it undefined. spawn_command() in dbutil.c and both call sites in scp.c follow that switch. The one exception is the daemon() fallback that compat.c compiles under #ifndef HAVE_DAEMON, which calls fork() directly and bypasses it. NuttX provides daemon() in libs/libc/unistd/lib_daemon.c and declares it in unistd.h, so the fallback is redundant; define HAVE_DAEMON alongside the HAVE_STRLCAT and HAVE_STRLCPY entries that are there for exactly the same reason. The code was unreachable in any case -- the port hands svr_getopts() an argv containing -F, so svr_opts.forkbg is always zero and dropbear never calls daemon() at all. testing/drivers/nand_sim: forked so that the parent could return to the shell while the child registered the MTD device and slept forever. Nothing from before the fork is used after it, so the child is a self-contained entry point, and task_create() expresses that directly. The emulator body moves into nand_sim_daemon() unchanged. TESTING_NAND_SIM therefore needs no fork dependency, and the two sim configurations that enable it keep working whatever ARCH_HAVE_FORK is set to. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Depends-On: apache/nuttx-apps#3673
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. That companion is now deliberately minimal: it narrows the source lists ofsystem/libuvandtesting/ltpand adds the Kconfig dependencytesting/drivers/nand_simalways had implicitly. It introduces no new symbols and is a no-op against today's master, whereARCH_HAVE_FORKis set everywhere, so it can merge on its own terms.The test work moved out of it. Splitting
ostest's fork test intotask_fork,vforkandforkis now apache/nuttx-apps#3685, which depends on this PR and merges after it. That split carries no compatibility layer: each test gates directly on the primitive it tests. The honest consequence is that between #3673 merging and this PR merging,ostesthas no fork test — the alternative was a fallback shim inapps, and reviewers were right that it did not belong there.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, system, netutils: do not depend on fork() where it is absent or unneeded 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.