Skip to content

!sched/arch/libc: Give fork() and vfork() their real, separate semantics. - #19562

Draft
casaroli wants to merge 2 commits into
apache:masterfrom
casaroli:fork-semantics-core
Draft

!sched/arch/libc: Give fork() and vfork() their real, separate semantics.#19562
casaroli wants to merge 2 commits into
apache:masterfrom
casaroli:fork-semantics-core

Conversation

@casaroli

@casaroli casaroli commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

NuttX implements fork() and vfork() as the same function. Both are libc wrappers around one up_fork() syscall; vfork() differs only by a trailing waitpid(). Underneath, the child joins the parent's address environment — the same addrenv_join() that pthread_create() uses — and gets a private copy of the stack. So the child shares .data, .bss and the heap with its parent and runs 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.

This is step 1 of the plan agreed in #19540 (ordering, go-ahead): the core semantics, plus the arch/Kconfig change that withdraws fork() from every architecture so per-architecture patches can restore it, one at a time, with real POSIX semantics.

What changes

Two primitives, chosen by which function the caller called rather than by what the hardware happens to be.

memory parent availability
fork() child gets its own copy at the same virtual addresses runs concurrently CONFIG_ARCH_HAVE_FORK, which now depends on ARCH_ADDRENV and which no architecture selects yet
vfork() child shares the parent's memory suspended until the child _exit()s or exec()s CONFIG_ARCH_HAVE_VFORK, selected exactly where ARCH_HAVE_FORK used to be

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() — the single place the memory semantics are decided. The flag arrives in the first argument register and is handed on unchanged, so each architecture's entry point only has to keep it alive across its snapshot sequence.

The vfork() parent suspension moves out of libc into nxtask_start_fork(), released from nxsched_release_tcb(). The parent is therefore 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.

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.

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. A build error is strictly better than the silent wrongness it replaces.

Quick fix, chosen by why the call was made:

why you called fork() use
to run a program vfork() + exec*(), or better posix_spawn()
a second flow of control that shares the caller's memory pthread_create()
a genuinely independent copy of the process keep fork(), and wait for the per-architecture patch that implements up_addrenv_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. The migration guide is Documentation/guides/fork_vfork_migration.rst.

Review

This revision addresses all of @xiaoxiang781216's comments. task_fork() is gone entirely, along with ARCH_HAVE_TASK_FORK, TASK_FORK, FORK_IS_TASK_FORK and ARCH_HAVE_ADDRENV_FORK. There is one entry point, still called up_fork, taking a bool rather than a FORK_TYPE_* selector, with r0 the flag and r1 the context pointer as suggested — which removed the per-primitive entry points and the shared-snapshot trampolines, so the assembly is now smaller than it was before this PR. include/nuttx/fork.h is deleted, nxtask_start_vfork() is folded into nxtask_start_fork(), and nxtask_vfork_resume() is renamed nxtask_resume_vfork().

The companion apache/nuttx-apps#3685 splits ostest's fork test in two and must merge after this PR. It is not a blocker for CI here: everything in apps that called fork() unconditionally was fixed by apache/nuttx-apps#3673, which merged on 2026-08-03, so apps master builds against this branch as it stands. Between this PR merging and #3685 merging, ostest has no fork test — the deliberate 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-gcc 14.2.0-3, Arm GNU arm-none-eabi-gcc/aarch64-none-elf-gcc 14.2.rel1, xtensa-esp32s3-elf-gcc 12.2.0.

tools/checkpatch.sh -c -u -m -g — ✔️ all checks pass. Also clean with -b, so it is ready for the breaking change label.

Built and run with apache/nuttx-apps#3685, ostest driven from NSH:

target fork path exercised result
raspberrypi-pico-2:nsh — real RP2350, Cortex-M33 arm/gnu/fork.S + arm_fork.c vfork() passes
esp32s3-devkit:ostest — real ESP32-S3, Xtensa LX7 none exists on Xtensa boots, 16 tests pass
qemu-armv7a:nsh arm/gnu/fork.S vfork() passes
qemu-armv8a:nsh arm64_fork_func.S + arm64_fork.c vfork() passes
qemu-armv8a:citest_smp — SMP, 4 CPUs as above, plus the SMP release path vfork() passes
rv-virt:nsh64 risc-v/fork.S + riscv_fork.c vfork() passes
sim:nsh sim_fork_arm64.S + sim_fork.c vfork() passes
stm32f4discovery:nsh arm/gnu/fork.S builds -Werror; up_fork disassembly verified
rv-virt:pnsh64, rv-virt:knsh64 syscall proxy and stub build; runtime not reached, see below
qemu-intel64:nsh x86_64/fork.S builds and links; not runnable, see below
user_main: vfork() test
vfork_test: Started
vfork_test: Child 5 ran and exited before the parent resumed

The protected and kernel builds generate the right syscall glue for the new argument:

pid_t up_fork(bool parm1) { return (pid_t)sys_call1((unsigned int)SYS_up_fork, (uintptr_t)parm1); }
uintptr_t STUB_up_fork(int nbr, uintptr_t parm1) { return (uintptr_t)up_fork((bool)parm1); }

The SMP case earned its row. nxtask_exit() raises rtcb->lockcount directly rather than through sched_lock(), so the nxsem_post() that wakes the vfork() parent leaves it queued where a task collects while pre-emption is off, and the matching raw decrement does not publish it. An earlier revision handled that only for !CONFIG_SMP, on the mistaken reasoning that SMP has no pending list — it does not, but the task collects in g_readytorun there instead and still needs publishing. qemu-armv8a:citest_smp hung the instant the vfork() test ran. The fix mirrors what sched_unlock() already does for each case: nxsched_merge_pending(), or nxsched_deliver_task() under CONFIG_SMP.

Three caveats, all independent of this change and all verified against an unmodified nuttx + unmodified apps baseline:

  • ostest does not run to completion on any target. It aborts in timedmutex_timeout_regression_test() at timedmutex.c:185, added by apps master eea8384ff. This is why the fork tests were moved to the top of user_main() — otherwise they never run at all.
  • rv-virt:pnsh64 does not boot here, at baseline too, so the protected build is verified by compilation and by the generated stubs rather than at runtime. rv-virt:knsh64 produces no console output at all in my setup.
  • qemu-intel64 cannot run on an Apple Silicon host: NuttX needs tsc-deadline and pcid, which TCG does not implement, and there is no KVM.

Please try this on a board you have. CI builds; it does not run fork() on your hardware. This touches the lowest-level machinery in the system — address environments, stack setup, the architecture's register context — and I would sooner it sat open collecting evidence than merged on the strength of my matrix. Check out this branch together with apache/nuttx-apps#3685, run ostest, and the vfork() test is the first thing you will see.

@github-actions github-actions Bot added Area: Documentation Improvements or additions to documentation Arch: arm Issues related to ARM (32-bit) architecture Arch: arm64 Issues related to ARM64 (64-bit) architecture Arch: ceva Issues related to CEVA architecture Arch: mips Issues related to the MIPS architecture Arch: risc-v Issues related to the RISC-V (32-bit or 64-bit) architecture Arch: simulator Issues related to the SIMulator Arch: x86_64 Issues related to the x86_64 architecture Size: XL The size of the change in this PR is very large. Consider breaking down the PR into smaller pieces. labels Jul 27, 2026
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

MemBrowse Memory Report

arduino-mega2560

  • flash: .text +6 B (+0.0%, 67,622 B / 262,144 B, total: 26% used)

esp32-devkitc

  • ROM: .flash.text +8 B (+0.0%, 124,396 B / 4,194,272 B, total: 3% used)
  • irom0_0_seg: .flash.text +8 B (+0.0%, 88,600 B / 3,342,304 B, total: 3% used)

hifive1-revb

  • flash: .text +16 B (+0.0%, 83,372 B / 4,194,304 B, total: 2% used)
  • sram: .bss +32 B (+0.8%, 3,964 B / 16,384 B, total: 24% used)

mirtoo

  • kseg0_progmem: .text +52 B (+0.1%, 67,476 B / 131,072 B, total: 51% used)

qemu-armv8a

  • Code: .rodata -306 B, .text.nxsched_release_tcb +8 B, .text.nxtask_exit +12 B, .text.pthread_start -4 B, .text.up_initial_state -4 B, .text.user_main -16 B (-0.6%, 316,716 B)
  • Data: .bss.g_idletcb +8 B (+0.0%, 76,977 B)

qemu-intel64

  • Code: .text -1,562 B (-0.0%, 8,657,184 B)
  • Data: .bss +64 B, .rodata -278 B (-0.2%, 120,593 B)

s698pm-dkit

  • Code: .text +16 B (+0.0%, 363,952 B)

stm32-nucleo-f103rb

  • flash: .text +32 B (+0.1%, 34,016 B / 131,072 B, total: 26% used)
    No memory changes detected for:
  • rx65n-rsk2mb

@acassis acassis linked an issue Jul 27, 2026 that may be closed by this pull request
1 task
@casaroli

casaroli commented Jul 27, 2026

Copy link
Copy Markdown
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 fork() (an MMU one) in this PR as a sample and that anyone can replicate the results. I am happy to follow any direction..

these are MMU capable, in order of complexity (low to high):

  • armv8-a
  • armv7-a
  • riscv
  • xtensa LX7 (esp32s3)
  • sim (could be simpler but I did not investigate enough
  • x86_64
  • x86 - pending sampething, no?

@casaroli
casaroli force-pushed the fork-semantics-core branch 5 times, most recently from f8d329e to e5675dc Compare July 28, 2026 07:11

@jerpelea jerpelea left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
casaroli force-pushed the fork-semantics-core branch 4 times, most recently from 0132031 to 9b787d4 Compare July 30, 2026 09:47
@casaroli
casaroli marked this pull request as ready for review July 30, 2026 11:31
@casaroli casaroli reopened this Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

🔗 Cross-repo PR dependencies

The 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

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

🔗 Cross-repo PR dependencies

The 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

casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 2, 2026
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>
casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 2, 2026
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>
@casaroli
casaroli force-pushed the fork-semantics-core branch from 9b787d4 to 82f8e8b Compare August 2, 2026 15:10
@casaroli casaroli closed this Aug 2, 2026
@casaroli casaroli reopened this Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

🔗 Cross-repo PR dependencies

The 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

casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 2, 2026
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>
casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 2, 2026
…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>
casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 2, 2026
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>
Comment thread arch/arm/src/common/gnu/fork.S Outdated
Comment thread arch/arm/src/common/gnu/fork.S Outdated
Comment thread arch/arm/src/common/arm_fork.c Outdated
Comment thread arch/arm64/src/common/arm64_fork.c Outdated
Comment thread arch/arm64/src/common/arm64_fork_func.S Outdated
Comment thread arch/Kconfig Outdated
Comment thread arch/Kconfig Outdated
Comment thread arch/Kconfig Outdated
Comment thread arch/Kconfig Outdated
Comment thread arch/arm64/src/common/arm64_fork_func.S Outdated
casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 2, 2026
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>
casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 2, 2026
…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>
casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 2, 2026
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>
acassis pushed a commit to apache/nuttx-apps that referenced this pull request Aug 3, 2026
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>
acassis pushed a commit to apache/nuttx-apps that referenced this pull request Aug 3, 2026
…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>
@casaroli
casaroli force-pushed the fork-semantics-core branch from 82f8e8b to 9132156 Compare August 8, 2026 07:48
casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 8, 2026
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 <marco.casaroli@gmail.com>
@casaroli casaroli changed the title sched/arch/libc: give fork(), vfork() and task_fork() their real, separate semantics !sched/arch/libc: Give fork() and vfork() their real, separate semantics. Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

❌ Cross-repo dependency could not be applied

The 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

@casaroli
casaroli marked this pull request as draft August 8, 2026 07:51
@casaroli
casaroli force-pushed the fork-semantics-core branch from 9132156 to df3ccde Compare August 8, 2026 10:10
Comment thread arch/arm/src/common/gnu/fork.S Outdated
Comment thread libs/libc/libc.csv
Comment thread arch/Kconfig
Comment thread arch/Kconfig
Comment thread arch/Kconfig
Comment thread arch/x86_64/src/common/fork.S Outdated
Comment thread arch/risc-v/src/common/fork.S Outdated
Comment thread include/nuttx/sched.h Outdated
Comment thread sched/task/task_fork.c
…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 <marco.casaroli@gmail.com>
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 <marco.casaroli@gmail.com>
@casaroli
casaroli force-pushed the fork-semantics-core branch from df3ccde to 9093ae5 Compare August 9, 2026 12:22
*/

mov r0, sp
mov r1, sp

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove since r1 already equals sp

Comment thread sched/sched/sched.h
#ifdef CONFIG_ARCH_HAVE_VFORK
struct vfork_s
{
sem_t sem; /* Posted when the child is done */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wy not use sem_t directly and remove vfork_s

.globl up_fork
.type up_fork, @function
up_fork:
push {r4, lr}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove extra spaces before {r4, lr}

Comment on lines +94 to +95
mov r4, r0 /* Callee-saved: carries the vfork flag
* across setjmp() */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge to previous line

* stack usage should be the difference between those two.
*/

stacktop = (uint64_t)parent->stack_base_ptr +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not check (child->stack_base_ptr == parent->stack_base_ptr) like arm


SYMBOL(up_fork):
push %ebx /* Callee-saved: carries the vfork flag
* across setjmp() */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Arch: arm Issues related to ARM (32-bit) architecture Arch: arm64 Issues related to ARM64 (64-bit) architecture Arch: ceva Issues related to CEVA architecture Arch: mips Issues related to the MIPS architecture Arch: risc-v Issues related to the RISC-V (32-bit or 64-bit) architecture Arch: simulator Issues related to the SIMulator Arch: x86_64 Issues related to the x86_64 architecture Area: Documentation Improvements or additions to documentation Size: XL The size of the change in this PR is very large. Consider breaking down the PR into smaller pieces.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] give fork() and vfork() their real, separate semantics

3 participants