!sched/arch/libc: Give fork() and vfork() their real, separate semantics. - #19562
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) [email protected]
with
Assisted-by: Claude Opus 5 (1M context) [email protected]
0132031 to
9b787d4
Compare
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) <[email protected]> Signed-off-by: Marco Casaroli <[email protected]>
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) <[email protected]> Signed-off-by: Marco Casaroli <[email protected]>
…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) <[email protected]> Signed-off-by: Marco Casaroli <[email protected]>
82f8e8b to
9132156
Compare
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 passed because NuttX implemented fork() and vfork() as the same sharing primitive, which apache/nuttx#19562 separates. 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 -- ostest_main() sets SA_NOCLDWAIT for the whole run, deliberately -- ECHILD is accepted as equally good evidence, since it says the child was already gone when the parent asked. 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(). Both 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, ARCH_HAVE_VFORK and ARCH_HAVE_FORK respectively. There is no compatibility layer and no mapping between symbols. vfork.c no longer requires SCHED_WAITPID: the suspension is in the kernel primitive now, so the test's core assertion holds without it and only the status check is conditional. The other in-tree callers are audited for which primitive they actually meant: * interpreters/python's _posixsubprocess and netutils/libwebsockets' LWS_HAVE_WORKING_VFORK want the fork-then-exec path -- 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. * testing/fs/fdsantest's vfork case follows ARCH_HAVE_VFORK. interpreters/bas is deliberately left alone. Its SHELL and EDIT statements reach for vfork() under an ARCH_HAVE_FORK guard and want the same treatment, but checkpatch.sh checks the whole of any file a patch touches and bas_statement.c produces 1681 pre-existing findings against master, so a one-line change there fails CI on its own. The consequence is small: EXAMPLES_BAS_SHELL is EXPERIMENTAL and already depends on ARCH_HAVE_FORK, so it becomes unselectable rather than misbehaving. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marco Casaroli <[email protected]>
❌ Cross-repo dependency could not be appliedThe Build report says the declared dependency PR(s) could not be applied, so CI did not run against the combined code: Reason: cherry-pick failed (if your PR has merge commits, rebase instead) CI run: https://github.com/apache/nuttx/actions/runs/31246987967 |
9132156 to
df3ccde
Compare
df3ccde to
9093ae5
Compare
…ics. 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 two 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. Below libc there is still one syscall. up_fork() gains a bool saying which primitive the caller used, since the per-architecture register snapshot is the same for both, and passes it to nxtask_setup_fork(), which is the single place the memory semantics are decided. The argument arrives in the first argument register and is never touched: each architecture's snapshot takes some other call-clobbered register for its scratch, so the flag is simply still there when the C worker is called. The vfork() parent suspension moves out of libc into nxtask_start_fork(), released from nxsched_release_tcb() by nxtask_resume_vfork(). 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 leaves it queued where a blocked task collects while pre-emption is off -- g_pendingtasks, or g_readytorun on SMP -- and the matching raw lockcount-- does not publish it the way sched_unlock() would, leaving the parent stranded with nothing to move it on. The fix mirrors sched_unlock() for each case: nxsched_merge_pending(), or nxsched_deliver_task() under CONFIG_SMP. Both are no-ops 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 wherever no other task happens to call sched_unlock() afterwards -- rv-virt:nsh64 and rv-virt:pnsh64, where NSH is blocked in waitpid() holding the lock, and qemu-armv8a:citest_smp, which hangs the moment the vfork() test runs. 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. In the meantime the sharing primitive is still there under the name that describes it: vfork() for a child that runs a program, pthread_create() for a second flow of control that shares memory, posix_spawn() for both at once. Kconfig: ARCH_HAVE_VFORK inherits 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 now depends on ARCH_ADDRENV. 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 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. BREAKING CHANGE: fork() is withdrawn from every architecture. It is no longer declared in unistd.h, so code that calls it fails to build with an error naming the function, and the sharing behaviour it used to have is gone rather than renamed. CONFIG_ARCH_HAVE_FORK no longer means "fork() exists"; it means "this configuration can provide POSIX fork() semantics", and no architecture selects it yet. Quick fix, chosen by why the call was made: to run a program vfork() + exec*(), or better posix_spawn() a second flow of control that pthread_create() shares the caller's memory a genuinely independent copy keep fork(), and wait for the per-arch patch of the process that implements up_addrenv_fork() and selects CONFIG_ARCH_HAVE_FORK Out-of-tree code that tests CONFIG_ARCH_HAVE_FORK to decide whether a fork-then-exec path is available wants CONFIG_ARCH_HAVE_VFORK instead, which is selected in exactly the places CONFIG_ARCH_HAVE_FORK used to be. The full migration guide is Documentation/guides/fork_vfork_migration.rst. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marco Casaroli <[email protected]>
Documentation/guides/fork_vfork_migration.rst is new. It says what changed and why, gives the two 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() for a second flow of control that shares memory, fork() itself for an independent copy. It also documents the two 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 an entry for 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. BREAKING CHANGE: this commit carries no code; it is the migration guide for the fork() withdrawal in the commit before it, and is marked so that every commit in the series carries the marker CONTRIBUTING.md 1.13 requires. The quick fixes are in Documentation/guides/fork_vfork_migration.rst. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marco Casaroli <[email protected]>
9093ae5 to
fcf49da
Compare
|
@xiaoxiang781216 i addressed all the comments. can you please check again? When you are ok with the changes, I will send an email to the list asking for more people to test |
…mv7-a.
In a kernel build the cloning primitives are reached through a system call,
and armv7-a dispatches one by re-pointing the caller's own exception frame at
dispatch_syscall() and switching to the task's kernel stack. The snapshot the
entry point in fork.S takes for itself therefore describes the kernel-side
stub, and the frames below it are on a stack the child gets no copy of: a
child built from that snapshot resumes at a kernel address with a stack
pointer into its own user stack. It faulted with a prefetch abort at PC 0 on
qemu-armv7a:knsh, which is why the fork family had never been run there.
Record what the caller was actually doing instead. arm_syscall() stores the
exception frame of the outermost system call in xcp.sregs, mirroring
riscv_swint.c, and arm_fork() chooses where the caller's registers live:
arm_fork_syscall() when a user stack pointer is saved, rebuilding the child
from xcp.sregs so that it returns from the very same SVC
as the parent, in the same mode, on its own stack and
with no inherited system call nesting;
arm_fork_direct() otherwise -- the flat build, a kernel thread in any
build, and a build without a kernel stack, where the
call is dispatched on the caller's own stack so the
caller's frames are copied along with the kernel-side
ones.
Note that the discriminator is xcp.ustkptr rather than TCB_FLAG_SYSCALL. On
armv7-a the caller is the task that runs the kernel side of its own system
call, so being in a system call is not by itself a reason to distrust the
snapshot; the switch to the kernel stack is. Because arm_syscall() has
already re-pointed the frame by the time arm_fork() runs, the caller's PC,
CPSR and SP come from where arm_syscall() put them -- syscall[0].sysreturn,
syscall[0].cpsr and ustkptr -- and the rest from the frame itself.
Nothing selects the primitives on an ARM kernel build yet, so this commit
changes no configuration; it is what the next one needs to be correct.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <[email protected]>
ARCH_ARM has selected ARCH_HAVE_VFORK only "if !BUILD_KERNEL" since the fork()/vfork() split. That condition was deliberate but temporary: it was added because the fork family had never worked on a 32-bit ARM kernel build -- the entry point in fork.S snapshots the kernel-side stub rather than the caller, so a child resumes at a kernel address -- and said in as many words that "arch/arm takes the condition off again in the patch that adds its saved-syscall-frame path". That patch is the one before this. arm_syscall() records the caller's exception frame in xcp.sregs and arm_fork() builds the child from it, so the condition has nothing left to protect against. Cortex-M is unaffected either way -- BUILD_KERNEL depends on ARCH_USE_MMU, which it does not have -- so the only configurations this changes are the MMU-capable ARM ports, which are exactly the ones the previous commit fixed. Verified on qemu-armv7a:knsh under qemu-system-arm: ostest's vfork_test passes, where before the change vfork() was absent. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marco Casaroli <[email protected]>
In a kernel or protected build vfork() is reached through a system call, so
the return address and stack pointer that the entry point in
arm64_fork_func.S can snapshot for itself belong to the kernel-side stub, not
to the caller. A child built from that snapshot resumes at a kernel address
on a kernel stack. This is why arm64 selected the fork family only for the
flat build.
Record what the caller was actually doing instead. arm64_sync_exc passes the
exception frame to dispatch_syscall() in x7 -- x0-x6 carry the call number and
its six parameters, so x7 is free -- and dispatch_syscall() stores it in
xcp.sregs, mirroring what riscv_swint.c does.
arm64_fork() then chooses where the caller's registers live:
arm64_fork_syscall() when TCB_FLAG_SYSCALL is set, rebuilding the child
from xcp.sregs so that it returns from the very same
SVC as the parent;
arm64_fork_direct() otherwise, which is the flat build and any kernel
thread that calls the entry point as a plain function.
The stack copy and the relocation of pointers into it are shared by both
paths in arm64_fork_stack() and arm64_fork_reloc().
With that in place ARCH_ARM64 can select ARCH_HAVE_VFORK unconditionally.
Verified on qemu-armv8a:knsh (BUILD_KERNEL), qemu-armv8a:nsh (BUILD_FLAT) and
qemu-armv8a:citest_smp under qemu-system-aarch64: ostest's vfork_test passes
on all three, and it was absent from knsh before the change. The protected
configurations are build-verified only (fvp-armv8r:pnsh), there being no
emulator for them here.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <[email protected]>
In a kernel build vfork() is reached through a system call, so the return
address and stack pointer the architecture's entry point can see for itself
are the kernel's, not the caller's. A child built from those resumes at a
kernel address, which is why x86_64 selected the fork family only for the
flat build.
x86_64_syscall() now publishes the caller's frame in xcp.sregs for the
duration of the stub call, and x86_64_fork() builds the child from it:
x86_64_fork_syscall() when xcp.sregs is non-NULL, so that the child
returns from the very same `syscall' instruction as
the parent, in user mode, on its own stack;
x86_64_fork_direct() otherwise, which is the flat build and any kernel
thread that calls the entry point as a plain
function.
The discriminator is xcp.sregs rather than TCB_FLAG_SYSCALL, which arm64 and
RISC-V use: that flag also defers signal actions, x86_64 has never raised it,
and its kernel-build signal path does not survive being made to -- a
pre-existing problem that does not belong to this work.
Two properties of SYSCALL/SYSRET shape the child's frame. The instruction
leaves the caller's RIP and RFLAGS in RCX and R11 rather than on a stack, so
they are moved into the RIP and RFLAGS slots of the interrupt frame the child
is resumed from; and the hardware never records the caller's CS and SS at all,
SYSRETQ reconstructing them from IA32_STAR, so the child's are filled in with
the user code and data selectors at RPL 3. The frame is therefore not copied
wholesale: the extended state and the general registers are inherited, while
the segment registers and the thread pointer stay as up_initial_state() left
them, the child's stack being a fresh allocation the parent's FS base does not
describe.
x86_64_fork_relocfp() is new and is not optional here. A function returns
with `leave', which feeds the frame pointer into the stack pointer, so
relocating only the RBP the child resumes with gets it exactly one frame:
the next return loads a saved RBP still pointing into the parent's stack.
With that in place ARCH_X86_64 can select ARCH_HAVE_VFORK unconditionally.
Build-verified on qemu-intel64:knsh_romfs and qemu-intel64:ostest. NuttX on
qemu-intel64 requires tsc-deadline and pcid, which TCG does not implement, so
it cannot be run on this host.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <[email protected]>
if the patch is ready, it's better to remove the draft mark from pr. |
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 passed because NuttX implemented fork() and vfork() as the same sharing primitive, which apache/nuttx#19562 separates. 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 -- ostest_main() sets SA_NOCLDWAIT for the whole run, deliberately -- ECHILD is accepted as equally good evidence, since it says the child was already gone when the parent asked. 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(). Both 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, ARCH_HAVE_VFORK and ARCH_HAVE_FORK respectively. There is no compatibility layer and no mapping between symbols. vfork.c no longer requires SCHED_WAITPID: the suspension is in the kernel primitive now, so the test's core assertion holds without it and only the status check is conditional. The other in-tree callers are audited for which primitive they actually meant: * interpreters/python's _posixsubprocess and netutils/libwebsockets' LWS_HAVE_WORKING_VFORK want the fork-then-exec path -- 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. * testing/fs/fdsantest's vfork case follows ARCH_HAVE_VFORK. interpreters/bas is deliberately left alone. Its SHELL and EDIT statements reach for vfork() under an ARCH_HAVE_FORK guard and want the same treatment, but checkpatch.sh checks the whole of any file a patch touches and bas_statement.c produces 1681 pre-existing findings against master, so a one-line change there fails CI on its own. The consequence is small: EXAMPLES_BAS_SHELL is EXPERIMENTAL and already depends on ARCH_HAVE_FORK, so it becomes unselectable rather than misbehaving. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marco Casaroli <[email protected]>
Summary
NuttX gives
fork()andvfork()the same behaviour. Both are libc wrappers around oneup_fork()system call.vfork()only adds awaitpid()at the end. Below them, the child joins the address environment of the parent. This is the sameaddrenv_join()thatpthread_create()uses. The child gets a private copy of the stack, but it shares.data,.bssand the heap with the parent, and it runs at the same time as the parent.That is not
fork(). It isvfork()with a private stack, under the name offork(). The history agrees. Commit c33d1c9 renamed the oldvfork()tofork()in 2023 and changed no behaviour. The result was a silent fault. A program written for POSIXfork()compiled and ran, and the writes of the child went into the variables of the parent.This is step 1 of the plan in #19540 (ordering, go-ahead). It gives the two primitives their own semantics. It also withdraws
fork()from every architecture, so that each architecture can restore it later with the correct behaviour.What changes
There are now two primitives. The caller selects one by the function that it calls, not by the hardware.
fork()CONFIG_ARCH_HAVE_FORK, which nowdepends on ARCH_ADDRENV, and which no architecture selectsvfork()_exit()orexec()CONFIG_ARCH_HAVE_VFORKBelow libc there is still one system call.
up_fork()takes aboolthat says which primitive the caller used, because the register snapshot is the same for both. It gives the flag tonxtask_setup_fork(), which is the one place that decides the memory semantics. The flag arrives in the first argument register and passes on unchanged, so each architecture only keeps it alive across its snapshot sequence.The suspension of the
vfork()parent moves out of libc intonxtask_start_fork().nxsched_release_tcb()releases it. The parent therefore continues atexec(), becauseexec_swap()has already given the pid of the child to the loaded program when thevforkstub exits.vfork()no longer needsCONFIG_SCHED_WAITPID.fork()uses a newaddrenv_fork(). Anup_addrenv_fork()hook supports it. The hook copies an address environment into new pages at the same virtual addresses.Breaking change
fork()is withdrawn from every architecture.unistd.hno longer declares it, so code that calls it fails to build with an error that names the function. The old sharing behaviour is gone. It is not renamed. A build error is better than the silent fault that it replaces.Select the correct replacement by the reason for the call:
fork()vfork()andexec*(), or betterposix_spawn()pthread_create()fork(), and wait for the patch that implementsup_addrenv_fork()for your architectureOut-of-tree code that tests
CONFIG_ARCH_HAVE_FORKto find a fork-then-exec path must testCONFIG_ARCH_HAVE_VFORKinstead. The migration guide isDocumentation/guides/fork_vfork_migration.rst.Which architectures have vfork()
Every architecture that had the old
fork()now hasvfork(), under the same condition.if !HOST_WINDOWSARM 32-bit, ARM64 and x86_64 gain build modes that never had a working fork family. In each of them the entry point can only snapshot itself, and in a kernel or protected build that snapshot describes the kernel-side stub, not the caller. A child built from it resumes at a kernel address. That is why the old
ARCH_HAVE_FORKexcluded those modes.The last four commits record the frame of the caller instead.
arm_syscall(),dispatch_syscall()on arm64, andx86_64_syscall()store the exception frame inxcp.sregs.arm_fork(),arm64_fork()andx86_64_fork()then build the child from it. This mirrorsriscv_swint.c. Cortex-M is not affected, becauseBUILD_KERNELneedsARCH_USE_MMU.Companion pull request
The companion apache/nuttx-apps#3685 divides the fork test of
ostestin two. It must merge after this PR. It does not block CI here. apache/nuttx-apps#3673 fixed everything inappsthat calledfork()without a condition, and it merged on 2026-08-03, soappsmaster builds against this branch. Between the two merges,ostesthas no fork test. That is the cost of carrying no compatibility layer.Testing
Host: macOS 15 (Darwin 25.5.0) on Apple Silicon. QEMU 11.0.3, xPack
riscv-none-elf-gcc14.2.0-3, Arm GNUarm-none-eabi-gccandaarch64-none-elf-gcc14.2.rel1,xtensa-esp32s3-elf-gcc12.2.0,x86_64-elf-gcc16.1.0.tools/checkpatch.sh -c -u -m -ggives no errors. It is also clean with-b, so thebreaking changelabel can be applied.I built and ran every configuration with apache/nuttx-apps#3685, and started
ostestfrom NSH. Therevcolumn gives the revision that each row ran at.vfork()passesqemu-armv7a:nshvfork()passesqemu-armv7a:knshvfork()passes — newqemu-armv8a:nshvfork()passesqemu-armv8a:citest_smp— 4 CPUsvfork()passesqemu-armv8a:knshvfork()passes — newfvp-armv8r:pnshqemu-armv8a:pnshvfork()passes — new; needs the board of the follow-up branch, see belowmps2-an500:nshvfork()passesmps2-an500:knshvfork()passesmps2-an521:nshvfork()passesmps3-an547:nshvfork()passesmps3-an547:knshlm3s6965-ek:qemu-flatvfork()passesrv-virt:nsh,rv-virt:flatsvfork()passesrv-virt:smp— 8 CPUsvfork()passesrv-virt:pnshvfork()passesrv-virt:knsh_romfsvfork()passesrv-virt:nsh64vfork()passesrv-virt:knsh64qemu-intel64:nshand:ostestvfork()passes under TCG, see belowqemu-intel64:knsh_romfsvfork()passes — newsim:nshvfork()passesstm32f4discovery:nsh-Werror, and I checked the disassembly ofup_forkfork_testis absent from every run. I searched all the run logs foruser_main: fork() testand found none. That is the purpose of the change.The protected and kernel builds generate the correct system call glue for the new argument:
The SMP row found a defect.
nxtask_exit()increasesrtcb->lockcountdirectly, not throughsched_lock(). Thenxsem_post()that wakes thevfork()parent therefore leaves it in the list where a task collects while pre-emption is off, and the matching decrease does not publish it. An earlier revision handled only!CONFIG_SMP, because I believed that SMP has no pending list. SMP has no pending list, but the task collects ing_readytoruninstead, and it still needs to be published.qemu-armv8a:citest_smpstopped as soon as thevfork()test ran. The fix does whatsched_unlock()already does in each case:nxsched_merge_pending(), ornxsched_deliver_task()underCONFIG_SMP.One result that is not clean
mps3-an547:knsh, which is armv8-m protected, stops invfork_test. It printsStarted, and then every task is blocked and the CPU stays inup_idle(). That configuration is already broken at the baselined960bc39ee, where the same test fails withInvalid PC load, caused by an invalid PC load by EXC_RETURN,PC: 00000006, atarmv8-m/arm_usagefault.c:116. This branch neither causes the fault nor repairs it. It changes a hard fault into a stop. It deserves attention, because armv7-m protected (mps2-an500:knsh) passes.Failures that exist before this change
I verified each of these against an unmodified baseline.
ostestdoes not run to the end on any target. It stops intimedmutex_timeout_regression_test()attimedmutex.c:185, whichappsmaster added in eea8384ff. This is why the fork tests moved to the top ofuser_main(). Otherwise they never run.lm3s6965-ek:qemu-protectedcannot boot. The kernel image is larger than its 128 KiBkflashregion and overlaps the user image. QEMU refuses to start.lddoes not report it, because the overflow is in theAT > kflashload region of.data. The baseline is also above the limit. This branch adds 124 bytes.lm3s6965-ek:qemu-kostestcannot boot.DEBUGASSERT(pid > 0)fails atnx_bringup.c:449. The baseline fails in the same way.rv-virt:knshprints nothing. The baseline prints nothing too. The rv32 kernel row usesrv-virt:knsh_romfsinstead, which passes.qemu-intel64needs three configuration changes under TCG, which is the only choice on a host that is not x86. DisableARCH_INTEL64_HAVE_PCIDandARCH_INTEL64_TSC_DEADLINE, enableARCH_INTEL64_HPET_ALARM, and start QEMU with-machine pc,hpet=on -cpu max.mps3-an547does not work as shipped.CONFIG_CMSDK_UART0_TX_IRQ=49andRX_IRQ=50are exchanged with respect to the QEMU model. Exchange them and the console works. That is how I obtained the two an547 rows. This has no relation to fork, but it stops all an547 tests under QEMU.Work that follows this PR
Two items are known and are not in this patch. I keep them separate to hold the size of this one down.
A runnable ARM64 protected board. The only ARM64 protected configurations in the tree are the ARMv8-R FVPs, and QEMU has no Cortex-R82, so nobody can run them. I have a
qemu-armv8a:pnshconfiguration, protected over an MMU, on the brancharm64-qemu-protected.vfork()passes there. It adds a board, a user linker script and a userspace initialisation file, which is too much to put in a semantics patch. It will follow as its own PR.The armv8-m protected stop.
mps3-an547:knshstops invfork_test, and the baseline fails at the same place with a usage fault. The defect is older than this PR. I will send the fix separately, because it belongs to armv8-m and not to the fork family.Please test vfork() on your board
CI builds this branch. It does not run
vfork()on your hardware. This patch changes the lowest level of the system: address environments, stack setup and the register context of each architecture. I would rather leave this open and collect evidence than merge it on my test matrix alone.vfork()is now available on ARM 32-bit, ARM64, RISC-V and x86_64 in every build mode, on SIM except under Windows, and on MIPS32.Two rows need attention most. The ARM 32-bit, ARM64 and x86_64 kernel builds are new, and I ran them only under QEMU, so a report from real silicon is the most useful thing you can send. The armv8-m protected build stops, and it also stops at the baseline.
To test, take this branch together with apache/nuttx-apps#3685, build your configuration and run
ostest. Thevfork()test is the first output. Please report the target, the build mode and the three lines above.A report that the test fails is as useful as a report that it passes. Please also say if your architecture has
vfork()in a build mode that my table does not cover.