Skip to content

Commit d2cec4b

Browse files
committed
process: fix execve()/fork() new-namespace exec hanging OSv shutdown
execve() (CONF_fork) launches the target as a fresh OSv application in its own ELF namespace via application::run(new_program=true) and, matching Linux, does not return to the caller. A successful exec DID launch the program (the elf::program new-namespace construction path is fine), but after the exec'd program finished the unikernel would hang at shutdown instead of powering off: the loader's application::join() blocked forever on the top-level app's _terminated flag. Root cause is the fork child thread's lifecycle, not execve or elf::program. fork_thread() (arch/{x64,aarch64}/fork.cc) creates the child as a normal *attached* sched::thread. At construction the thread captures a shared_ptr to the current application's application_runtime (sched.cc sets _app_runtime = app->runtime()). Nothing ever join()s the fork child -- the parent reaps it through the fork pid registry / waitpid(), not sched::thread::join() -- so the thread object is never destroyed and its _app_runtime shared_ptr is never released. With that reference outstanding the application_runtime's use count never reaches zero, ~application_runtime never runs, the app's _terminated is never set, and application::join() waits forever. (This bit every fork(), and was most visible after fork()+execve() where the child adopts the caller app's runtime.) Fix: create the fork child detached and dispose it in its cleanup. - arch/{x64,aarch64}/fork.cc: mark the child attr().detached(), so on completion it is handed to the thread reaper (which runs its set_cleanup()), rather than sitting forever waiting to be joined. - libc/process/fork.cc: the child's cleanup now also calls sched::thread::dispose(child) after the existing bookkeeping (record exit status if it fell off the end, free the copied user stack). Disposing the thread releases its _app_runtime reference, letting the owning application's runtime drop to zero so join() completes and OSv powers off. This mirrors the default detached-thread cleanup ([this]{ dispose(this); }). Both files are compiled only under CONF_fork, so default OSv builds are unchanged. Tests: tst-fork test 2 now execs a real payload (/tests/payload-exit7.so, which prints a marker and exit(7)s) instead of the previous _exit(7) sentinel that masked whether execve actually launched anything; a return from execve() is now treated as a failure. Added tst-execve.so (fork+execve launches the payload and its exit code is reaped; missing path returns -1/ENOENT) and payload-exit7.so. Validated on x86-64 (KVM disk boot): tst-fork 10/10 and tst-execve 3/3 pass with the real exec payload and OSv shuts down cleanly (5/5 repeat runs, no hang). Copyright (C) 2026 Greg Burd
1 parent b306dd4 commit d2cec4b

7 files changed

Lines changed: 112 additions & 15 deletions

File tree

arch/aarch64/fork.cc

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,15 @@ sched::thread *fork_thread(void *caller_ret, void *caller_sp,
6969
"br %1 \n\t" // resume in fork()'s caller
7070
: : "r"(resume_sp), "r"(resume_pc) : "x0", "memory");
7171
}, sched::thread::attr().
72-
stack(4096 * 4),
72+
stack(4096 * 4).
73+
// Detached: nobody join()s the fork child (the parent reaps it via the
74+
// pid registry / waitpid, not sched::thread::join). A detached thread
75+
// is handed to the reaper on completion, which runs our set_cleanup()
76+
// (freeing the copied stack and disposing the thread object, releasing
77+
// its application_runtime reference). Without this the thread object
78+
// (and its app_runtime shared_ptr) would leak and OSv would hang at
79+
// shutdown -- see the cleanup comment in libc/process/fork.cc.
80+
detached(),
7381
false,
7482
true);
7583
t->set_app_tcb(parent->get_app_tcb());

arch/x64/fork.cc

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,15 @@ sched::thread *fork_thread(void *caller_ret, void *caller_sp, void **out_stack_t
9191
"jmpq *%1 \n\t" // resume in fork()'s caller
9292
: : "r"(resume_sp), "r"(resume_pc) : "memory");
9393
}, sched::thread::attr().
94-
stack(4096 * 4),
94+
stack(4096 * 4).
95+
// Detached: nobody join()s the fork child (the parent reaps it via the
96+
// pid registry / waitpid, not sched::thread::join). A detached thread
97+
// is handed to the reaper on completion, which runs our set_cleanup()
98+
// (freeing the copied stack and disposing the thread object, releasing
99+
// its application_runtime reference). Without this the thread object
100+
// (and its app_runtime shared_ptr) would leak and OSv would hang at
101+
// shutdown -- see the cleanup comment in libc/process/fork.cc.
102+
detached(),
95103
false,
96104
true);
97105
t->set_app_tcb(parent->get_app_tcb());

libc/process/fork.cc

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,14 +184,22 @@ pid_t fork(void)
184184
// ahead of the parent's bookkeeping.
185185
fork::register_child(cpid, parent);
186186

187-
// Single cleanup: free the copied user stack and, if the child fell off the
188-
// end without exit(), record a default status. Real exit codes are
189-
// recorded by exit()/execve() via fork::child_exited() before this runs.
190-
child->set_cleanup([cpid, stack_to_free] {
187+
// Single cleanup, run by the thread reaper once the (detached) child has
188+
// fully terminated: free the copied user stack, record a default status if
189+
// the child fell off the end without exit() (real codes are recorded by
190+
// exit()/execve() via fork::child_exited() before this runs), and finally
191+
// dispose the child thread object itself. Disposing is essential: the
192+
// child thread holds a shared_ptr to its application_runtime (set at thread
193+
// construction), and only destroying the thread releases that reference.
194+
// Without it the top-level application's runtime never drops to zero, its
195+
// ~application_runtime never fires, application::join() blocks forever on
196+
// _terminated, and OSv hangs at shutdown instead of powering off.
197+
child->set_cleanup([cpid, stack_to_free, child] {
191198
fork::child_exited(cpid, 0);
192199
if (stack_to_free) {
193200
free(stack_to_free);
194201
}
202+
sched::thread::dispose(child);
195203
});
196204

197205
child->start();

modules/tests/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ tests := tst-pthread.so misc-ramdisk.so tst-vblk.so tst-mq-smoke.so tst-bsd-evh.
130130
tst-queue-mpsc.so tst-af-local.so tst-pipe.so tst-yield.so \
131131
misc-ctxsw.so tst-read.so tst-symlink.so tst-openat.so \
132132
tst-eventfd.so tst-remove.so misc-wake.so tst-epoll.so misc-lfring.so \
133-
tst-fork.so \
133+
tst-fork.so tst-execve.so payload-exit7.so \
134134
misc-fsx.so tst-sleep.so tst-resolve.so tst-except.so \
135135
misc-tcp-sendonly.so tst-tcp-nbwrite.so misc-tcp-hash-srv.so \
136136
misc-loadbalance.so misc-scheduler.so tst-console.so tst-app.so \

tests/payload-exit7.cc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright (C) 2026 Greg Burd
3+
*
4+
* This work is open source software, licensed under the terms of the
5+
* BSD license as described in the LICENSE file in the top-level directory.
6+
*
7+
* Trivial exec payload used by tst-fork / tst-execve: prints a marker so the
8+
* test can confirm the exec'd program actually ran, then exit(7)s so the
9+
* parent can verify the exit code was reaped.
10+
*/
11+
#include <cstdio>
12+
int main() {
13+
printf("payload-exit7: running, will exit(7)\n");
14+
fflush(stdout);
15+
return 7;
16+
}

tests/tst-execve.cc

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright (C) 2026 Greg Burd
3+
*
4+
* This work is open source software, licensed under the terms of the
5+
* BSD license as described in the LICENSE file in the top-level directory.
6+
*
7+
* Regression test for execve() launching a program in a fresh ELF namespace
8+
* (see documentation/fork.md). Covers the two independent facts that the
9+
* original execve() new-namespace path broke:
10+
* 1. a successful execve() actually LAUNCHES the target program, and
11+
* 2. after the exec'd program exits, control returns/reaps cleanly and OSv
12+
* is able to shut down (the fork-child app_runtime leak used to hang it).
13+
* Requires CONF_fork (execve is a stub otherwise).
14+
*/
15+
#include <unistd.h>
16+
#include <sys/wait.h>
17+
#include <cstdio>
18+
#include <cerrno>
19+
#include <cstring>
20+
21+
static int failures = 0;
22+
#define CHECK(cond, msg) do { \
23+
if (!(cond)) { printf("FAIL: %s\n", msg); failures++; } \
24+
else { printf("PASS: %s\n", msg); } \
25+
} while (0)
26+
27+
int main()
28+
{
29+
printf("=== tst-execve ===\n"); fflush(stdout);
30+
31+
// execve() a real payload from a fork child; a successful exec never
32+
// returns, so if it returns the exec FAILED (child exits 99). The payload
33+
// (/tests/payload-exit7.so) prints a marker and exit(7)s.
34+
pid_t pid = fork();
35+
if (pid == 0) {
36+
char *const argv[] = { (char*)"/tests/payload-exit7.so", nullptr };
37+
char *const envp[] = { nullptr };
38+
execve(argv[0], argv, envp);
39+
_exit(99); // execve returned => it failed to launch the program
40+
}
41+
int status = 0;
42+
pid_t w = waitpid(pid, &status, 0);
43+
CHECK(w == pid, "waitpid() reaps the exec'd child");
44+
CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 7,
45+
"execve launched the payload and its exit code (7) was reaped");
46+
47+
// execve() of a missing path must fail cleanly with ENOENT (not crash).
48+
char *const bad[] = { (char*)"/tests/does-not-exist.so", nullptr };
49+
errno = 0;
50+
int rc = execve(bad[0], bad, nullptr);
51+
CHECK(rc == -1 && errno == ENOENT,
52+
"execve of a missing path returns -1/ENOENT");
53+
54+
printf("=== tst-execve done: %d failures ===\n", failures); fflush(stdout);
55+
return failures == 0 ? 0 : 1;
56+
}

tests/tst-fork.cc

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,27 +49,28 @@ static void test_fork_return()
4949
"parent's stack local intact after child mutated its own copy");
5050
}
5151

52-
// 2. fork() + execve(): child execs a trivial program; parent waits.
52+
// 2. fork() + execve(): the child really execs a program in a fresh ELF
53+
// namespace and the parent reaps it. The payload (/tests/payload-exit7.so,
54+
// built by modules/tests) prints a marker and exit(7)s; the child never
55+
// returns from a successful execve(), so a return means exec failed and we
56+
// signal that with a distinct code (99) that the parent flags as a failure.
5357
static void test_fork_exec()
5458
{
5559
pid_t pid = fork();
5660
if (pid == 0) {
57-
// Exec /libtrue.so or fall back to a program that exits 7. On OSv the
58-
// test image includes a simple echo/true-like helper; if none exists
59-
// execve returns and we _exit a sentinel the parent recognizes.
6061
char *const argv[] = { (char*)"/tests/payload-exit7.so", nullptr };
6162
char *const envp[] = { nullptr };
6263
execve(argv[0], argv, envp);
63-
// execve failed (no such payload in this image) -> sentinel
64-
_exit(7);
64+
_exit(99); // only reached if execve() FAILED to launch the payload
6565
}
6666
CHECK(pid > 0, "fork() before execve returns child pid");
6767
int status = 0;
6868
pid_t w = waitpid(pid, &status, 0);
6969
CHECK(w == pid, "waitpid() reaps the fork+exec child");
70-
// Whether the exec payload ran or the sentinel fired, exit status is 7.
70+
// 7 means the exec'd payload actually ran and exited; 99 means execve()
71+
// returned (failed to launch) -- that is now a real failure.
7172
CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 7,
72-
"fork+exec child exit status observed (7)");
73+
"fork+exec: exec'd payload ran and its exit code (7) was reaped");
7374
}
7475

7576
// 3. vfork() maps to fork(); same contract.

0 commit comments

Comments
 (0)