Skip to content

testing/ostest: split the fork test into task_fork, vfork and fork - #3685

Draft
casaroli wants to merge 3 commits into
apache:masterfrom
casaroli:fork-semantics-ostest-cleanup
Draft

testing/ostest: split the fork test into task_fork, vfork and fork#3685
casaroli wants to merge 3 commits into
apache:masterfrom
casaroli:fork-semantics-ostest-cleanup

Conversation

@casaroli

@casaroli casaroli commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Depends-On: apache/nuttx#19562

Summary

NuttX implements fork() and vfork() as the same function, and apache/nuttx#19562 splits them into three separate primitives: 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.

Companion PR: apache/nuttx#19562, declared above with Depends-On: so CI builds the combined change rather than each half against a master that does not yet contain the other. This PR must merge after it, because the symbols each test keys on do not exist until it lands. The apps change that unblocks that PR's CI is #3673, which merges first and is independent of this one.

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. It is also the clearest single piece of evidence for the proposal: the test upstream has run for years is a task_fork() test wearing vfork()'s name.

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().

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. There is no compatibility layer and no mapping between symbols.

task_fork_test() keys on CONFIG_TASK_FORK rather than on the capability symbol: ARCH_HAVE_TASK_FORK says the architecture can clone a task while TASK_FORK says the build asked for it, and task_fork() is declared only under the latter, so gating on the capability alone would fail to compile a TASK_FORK=n build. 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 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.

Impact

Between #3673 merging and apache/nuttx#19562 merging, ostest has no fork test. That is the deliberate cost of carrying no compatibility layer: CONFIG_TASK_FORK and CONFIG_ARCH_HAVE_VFORK do not exist on a pre-split NuttX, so task_fork_test() and vfork_test() are not built, and fork_test() is not built either because after the split ARCH_HAVE_FORK is off until a per-architecture PR turns it on. Coverage returns the moment the NuttX side lands.

fork_test() costs nothing on size-constrained configurations, because it is not built on them. It keys on ARCH_HAVE_FORK, which no architecture sets until up_addrenv_fork() lands for it, so lm3s6965-ek:qemu-protected — the configuration with the tightest user flash region — never compiles it.

fork_test() itself is exercised by the per-architecture PRs that follow, which are what turn ARCH_HAVE_FORK back on.

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 14.2.Rel1.

Style

../nuttx/tools/checkpatch.sh -c -u -m -g <base>..HEAD, the exact command .github/workflows/check.yml runs — ✔️ All checks pass, with codespell, cvt2utf, cmake-format and nxstyle all installed.

Verification

This PR was restructured after review: the compatibility layer is gone entirely, each test keys on the primitive it tests, and Depends-On: now carries the relationship to apache/nuttx#19562 that the fallbacks used to stand in for. The branch has been rebased onto current master.

The functional matrix is being re-run against the restructured branch and will be posted here — full ostest to exit status 0 on rv-virt:nsh64, rv-virt:pnsh64, rv-virt:knsh64, qemu-armv7a:nsh, qemu-armv8a:nsh, qemu-intel64:nsh and sim:ostest, plus lm3s6965-ek:qemu-protected free bytes against the apps master baseline. The previous revision's results are in this PR's history.

@casaroli
casaroli force-pushed the fork-semantics-ostest-cleanup branch from d3b7722 to bbc21fe Compare August 2, 2026 10:35
@casaroli casaroli changed the title testing/ostest: drop the pre-split fork() fallbacks testing/ostest: split the fork test into task_fork, vfork and fork Aug 2, 2026
acassis
acassis previously approved these changes Aug 2, 2026
else
@echo "export ac_cv_func_fork=\"no\"" >> $@
endif
ifneq ($(CONFIG_ARCH_HAVE_VFORK),)

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 move to #3673


/* Define to 1 if you have the `fork' function. */

#ifdef CONFIG_ARCH_HAVE_FORK

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 move to #3673

assert_int_equal(open_count, close_count);
}

#ifdef CONFIG_ARCH_HAVE_VFORK

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 move to #3673

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>
@casaroli
casaroli force-pushed the fork-semantics-ostest-cleanup branch from ce27719 to 1f61625 Compare August 2, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants