Add tuxreaperd micro-init and in-container zombie-reaping tests for 0.2
Some checks failed
Build & Upload tux-dock / build (push) Has been cancelled
Some checks failed
Build & Upload tux-dock / build (push) Has been cancelled
This commit is contained in:
parent
2be5aa17cc
commit
d31ab6af07
9 changed files with 343 additions and 6 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
build/
|
||||
/tuxreaperd
|
||||
|
|
|
|||
|
|
@ -95,4 +95,9 @@ if(BUILD_TESTING)
|
|||
|
||||
add_test(NAME tux-dock-docker-integration-tests
|
||||
COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_docker_integration.sh)
|
||||
|
||||
add_test(NAME tuxreaperd-zombie-tests
|
||||
COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_tuxreaperd.sh
|
||||
${CMAKE_CURRENT_BINARY_DIR}/tuxreaperd
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/tuxreaperd.c)
|
||||
endif()
|
||||
|
|
|
|||
17
DEVLOG.md
17
DEVLOG.md
|
|
@ -1,5 +1,22 @@
|
|||
# Tux-Dock Development Log
|
||||
|
||||
## 0.2-beta
|
||||
|
||||
This release introduces `tuxreaperd`, a micro init system for containers.
|
||||
|
||||
### Micro init
|
||||
|
||||
- Added `tuxreaperd.c`, a small C daemon used as PID 1 inside containers created by tux-dock.
|
||||
- Runs as a subreaper (`PR_SET_CHILD_SUBREAPER`) with a SIGCHLD handler that reaps every reparented/terminated descendant, so orphaned processes never accumulate as zombies.
|
||||
- Forwards SIGTERM/SIGINT/SIGHUP to the primary workload and propagates its exit status (128 + signal for signal deaths).
|
||||
- `compile.sh` statically builds it (`gcc -O2 -static -Wall -Wextra tuxreaperd.c -o build/tuxreaperd`) alongside the rest of the tree.
|
||||
- `DockerManager::createContainer` bind-mounts `tuxreaperd` into the container as PID 1 and resolves the binary next to the `tux-dock` executable when it is not in the current directory.
|
||||
|
||||
### Tests
|
||||
|
||||
- Added `tuxreaperd-zombie-tests`, an in-container test suite that boots a container with `tuxreaperd` as PID 1 and verifies orphaned grandchildren are reaped (zero zombies), exit status is propagated, and `docker stop` cleanly shuts down via forwarded SIGTERM (exit 143).
|
||||
- The HTML web test view now includes a "Tuxreaperd Zombie Reaping" section alongside the Docker integration and TUI transcript sections.
|
||||
|
||||
## 0.1.2-beta
|
||||
|
||||
This release adds signal trapping to prevent state corruption during long-running Docker operations.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ It gives you a guided, keyboard-first TUI for common Docker operations without m
|
|||
- Direct `fork`/`exec` process execution for CLI-backed streaming and interactive commands.
|
||||
- Persistent container listings that retain exited containers.
|
||||
- Robust stop handling with state polling, timeout retry, and idempotent stop responses.
|
||||
- `tuxreaperd` micro init system: a tiny statically-linked C daemon mounted into created containers as PID 1, acting as a subreaper so orphaned processes never linger as zombies, forwarding signals, and propagating exit status.
|
||||
- About screen in-app with project/version/repository info.
|
||||
|
||||
---
|
||||
|
|
@ -53,7 +54,9 @@ sudo ./build/tux-dock
|
|||
./compile.sh --web-test-view 9000
|
||||
```
|
||||
|
||||
The web report is generated under `/tmp` and includes exact CTest output, test summaries, Docker integration output, and a text rendition of the TUI flow. It requires `nc`, `netcat`, or Nmap's `ncat`; press `Ctrl-C` to stop the server. The default port is `8095`; pass a port after `--web-test-view` to override it. `--web-test-view --no-test` is invalid. Normal test runs include a Docker integration test using `debian:forky`, so Docker must be available. On systems with 1 GB or less of available memory, the script automatically uses a smaller build configuration and a single build job.
|
||||
`compile.sh` also statically builds the `tuxreaperd` micro init (`gcc -O2 -static -Wall -Wextra tuxreaperd.c -o build/tuxreaperd`) so containers can be created with it as PID 1.
|
||||
|
||||
The web report is generated under `/tmp` and includes exact CTest output, test summaries, Docker integration output, a text rendition of the TUI flow, and the tuxreaperd zombie-reaping results. It requires `nc`, `netcat`, or Nmap's `ncat`; press `Ctrl-C` to stop the server. The default port is `8095`; pass a port after `--web-test-view` to override it. `--web-test-view --no-test` is invalid. Normal test runs include a Docker integration test using `debian:forky`, so Docker must be available. On systems with 1 GB or less of available memory, the script automatically uses a smaller build configuration and a single build job.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -113,13 +116,13 @@ cmake --build build
|
|||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
`compile.sh` runs these tests by default. Pass `--no-test` to skip them.
|
||||
`compile.sh` runs these tests by default. Pass `--no-test` to skip them. The suite includes `tuxreaperd-zombie-tests`, which boots a container with `tuxreaperd` as PID 1 and verifies orphaned processes are reaped (no zombies), exit status propagates, and SIGTERM is forwarded on `docker stop`. Docker must be available.
|
||||
|
||||
---
|
||||
|
||||
## About / Version
|
||||
|
||||
- Version: `0.1.2-beta`
|
||||
- Version: `0.2-beta`
|
||||
- Created by: `markmental`
|
||||
- GitHub: https://github.com/MARKMENTAL/tuxdock
|
||||
- Forgejo: https://mentalnet.xyz/forgejo-v2/markmental/tuxdock
|
||||
|
|
|
|||
21
compile.sh
21
compile.sh
|
|
@ -74,6 +74,12 @@ else
|
|||
cmake -S . -B build -DBUILD_TESTING=OFF $cmake_args
|
||||
fi
|
||||
|
||||
command -v gcc >/dev/null 2>&1 || {
|
||||
printf '%s\n' "gcc is required to build tuxreaperd" >&2
|
||||
exit 1
|
||||
}
|
||||
gcc -O2 -static -Wall -Wextra tuxreaperd.c -o build/tuxreaperd
|
||||
|
||||
CMAKE_BUILD_PARALLEL_LEVEL="$build_jobs" cmake --build build
|
||||
|
||||
if [ "$run_tests" -eq 1 ]; then
|
||||
|
|
@ -143,6 +149,19 @@ if [ "$run_tests" -eq 1 ]; then
|
|||
awk '!/\[TEST OUTPUT BEGIN\]/ && !/\[TEST OUTPUT END\]/ && !/\[TEST RESULT\]/ { print }' "$integration_output" >"$tui_output"
|
||||
escaped_tui=$(mktemp "$report_dir/tui-escaped.XXXXXX")
|
||||
sed 's/&/\&/g; s/</\</g; s/>/\>/g' "$tui_output" >"$escaped_tui"
|
||||
reaper_output=$(mktemp "$report_dir/reaper-output.XXXXXX")
|
||||
if [ -f "$test_log" ]; then
|
||||
awk '
|
||||
/^[0-9]+\/[0-9]+ Testing: tuxreaperd-zombie-tests$/ { capture=1 }
|
||||
capture { print }
|
||||
/^Test Passed\.$/ && capture { end=1 }
|
||||
end && /time elapsed:/ { print; exit }
|
||||
' "$test_log" >"$reaper_output"
|
||||
else
|
||||
printf '%s\n' 'tuxreaperd zombie transcript unavailable: LastTest.log was not found.' >"$reaper_output"
|
||||
fi
|
||||
escaped_reaper=$(mktemp "$report_dir/reaper-escaped.XXXXXX")
|
||||
sed 's/&/\&/g; s/</\</g; s/>/\>/g' "$reaper_output" >"$escaped_reaper"
|
||||
{
|
||||
printf '%s\n' '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">'
|
||||
printf '%s\n' '<HTML><HEAD><TITLE>Tux-Dock Test Report</TITLE></HEAD><BODY>'
|
||||
|
|
@ -164,6 +183,8 @@ if [ "$run_tests" -eq 1 ]; then
|
|||
cat "$escaped_raw"
|
||||
printf '%s\n' '</PRE><H2>TUI Transcript</H2><PRE>'
|
||||
cat "$escaped_tui"
|
||||
printf '%s\n' '</PRE><H2>Tuxreaperd Zombie Reaping</H2><PRE>'
|
||||
cat "$escaped_reaper"
|
||||
printf '%s\n' '</PRE></BODY></HTML>'
|
||||
} >"$report"
|
||||
|
||||
|
|
|
|||
2
main.cpp
2
main.cpp
|
|
@ -699,7 +699,7 @@ void TuxDockApp::ActionExecDetachedCommand() {
|
|||
}
|
||||
|
||||
void TuxDockApp::ActionAbout() {
|
||||
OpenMessage("About Tux-Dock", "Tux-Dock 0.1.2-beta | Created by markmental");
|
||||
OpenMessage("About Tux-Dock", "Tux-Dock 0.2-beta | Created by markmental");
|
||||
}
|
||||
|
||||
void TuxDockApp::ExecuteSelectedAction() {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <system_error>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
|
|
@ -97,14 +98,42 @@ bool DockerManager::createContainer(const std::string& name,
|
|||
message = "Please provide a container name and choose an image first.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve host path to tuxreaperd binary: prefer the current directory,
|
||||
// then fall back to the directory holding the tux-dock executable
|
||||
// (e.g. build/tuxreaperd next to build/tux-dock).
|
||||
std::filesystem::path hostReaperPath = std::filesystem::current_path() / "tuxreaperd";
|
||||
if (!std::filesystem::exists(hostReaperPath)) {
|
||||
std::error_code ec;
|
||||
const auto exe = std::filesystem::read_symlink("/proc/self/exe", ec);
|
||||
if (!ec) {
|
||||
hostReaperPath = exe.parent_path() / "tuxreaperd";
|
||||
}
|
||||
}
|
||||
if (!std::filesystem::exists(hostReaperPath)) {
|
||||
message = "Failed to create container: tuxreaperd binary not found in the current directory or next to the tux-dock executable.";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::string> args{"docker", "create", "--name", name};
|
||||
|
||||
// Port mappings
|
||||
for (const auto& port : ports) {
|
||||
args.push_back("-p");
|
||||
args.push_back(port);
|
||||
}
|
||||
args.insert(args.end(), {image, "sleep", "infinity"});
|
||||
|
||||
// Mount host tuxreaperd into container target path (read-only for security)
|
||||
args.push_back("-v");
|
||||
args.push_back(hostReaperPath.string() + ":/usr/local/bin/tuxreaperd:ro");
|
||||
|
||||
// Target image and entry command with tuxreaperd as PID 1
|
||||
args.insert(args.end(), {image, "/usr/local/bin/tuxreaperd", "sleep", "infinity"});
|
||||
|
||||
const bool ok = runProcess(args, message);
|
||||
if (ok) message = "Container created and ready to start when you are ready.";
|
||||
if (ok) {
|
||||
message = "Container created with tuxreaperd PID 1 supervisor and ready to start.";
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
|
|
|
|||
170
tests/test_tuxreaperd.sh
Normal file
170
tests/test_tuxreaperd.sh
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
#!/bin/sh
|
||||
|
||||
# In-container tests for tuxreaperd, the tux-dock micro init system.
|
||||
#
|
||||
# tuxreaperd is container-tailored: tux-dock mounts it into created containers
|
||||
# where it runs as PID 1, acting as a subreaper (PR_SET_CHILD_SUBREAPER) with a
|
||||
# SIGCHLD reaper loop so orphaned descendants never linger as zombies.
|
||||
#
|
||||
# These tests drive tuxreaperd the same way the app does (bind-mounted, PID 1)
|
||||
# and verify zombie reaping, exit-status propagation, and signal forwarding.
|
||||
|
||||
set -eu
|
||||
|
||||
image=${TUX_DOCK_TEST_IMAGE:-debian:forky}
|
||||
reaper_bin=${1:-build/tuxreaperd}
|
||||
reaper_src=${2:-}
|
||||
name="tux-reaper-test-$$-$(date +%s)"
|
||||
cleanup_status=0
|
||||
failures=0
|
||||
|
||||
run_raw() {
|
||||
command_name=$1
|
||||
shift
|
||||
started=$(date +%s)
|
||||
printf '%s\n' "[TEST OUTPUT BEGIN] $command_name"
|
||||
set +e
|
||||
"$@" 2>&1
|
||||
command_status=$?
|
||||
set -e
|
||||
elapsed=$(( $(date +%s) - started ))
|
||||
printf '%s\n' "[TEST OUTPUT END] $command_name"
|
||||
printf '%s\n' "[TEST RESULT] command=$command_name status=$command_status elapsed=${elapsed}s"
|
||||
return "$command_status"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if docker container inspect "$name" >/dev/null 2>&1; then
|
||||
printf '%s\n' "[TUI] Cleanup: Remove Container"
|
||||
if ! docker rm -f "$name" >/dev/null 2>&1; then
|
||||
cleanup_status=1
|
||||
fi
|
||||
printf '%s\n' "[TUI] Complete: Container removed"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Resolve an absolute path for the bind mount (docker requires absolute paths).
|
||||
if [ ! -f "$reaper_bin" ]; then
|
||||
if command -v gcc >/dev/null 2>&1 && [ -n "$reaper_src" ]; then
|
||||
printf '%s\n' "[TUI] tuxreaperd binary not found, building $reaper_bin"
|
||||
mkdir -p "$(dirname "$reaper_bin")"
|
||||
gcc -O2 -static -Wall -Wextra "$reaper_src" -o "$reaper_bin"
|
||||
else
|
||||
printf '%s\n' "tuxreaperd binary not found at $reaper_bin (run ./compile.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
reaper_dir=$(CDPATH= cd "$(dirname "$reaper_bin")" && pwd)
|
||||
reaper_bin=$reaper_dir/$(basename "$reaper_bin")
|
||||
|
||||
if ! docker image inspect "$image" >/dev/null 2>&1; then
|
||||
printf '%s\n' "[TUI] Pull Docker Image: $image"
|
||||
run_raw "docker pull $image" docker pull "$image"
|
||||
else
|
||||
printf '%s\n' "[TUI] Image available: $image"
|
||||
fi
|
||||
|
||||
printf '%s\n' "[TUI] Create container with tuxreaperd as PID 1"
|
||||
run_raw "docker create tuxreaperd container" \
|
||||
docker create --name "$name" -v "$reaper_bin:/usr/local/bin/tuxreaperd:ro" \
|
||||
"$image" /usr/local/bin/tuxreaperd sleep infinity
|
||||
run_raw "docker start $name" docker start "$name"
|
||||
|
||||
printf '%s\n' "[TUI] Verify tuxreaperd is PID 1"
|
||||
if [ "$(docker exec "$name" cat /proc/1/comm 2>/dev/null)" = "tuxreaperd" ]; then
|
||||
printf '%s\n' "[PASS] tuxreaperd is PID 1 in the container"
|
||||
else
|
||||
printf '%s\n' "[FAIL] PID 1 is not tuxreaperd"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
printf '%s\n' "[TUI] Spawn orphaned grandchildren inside the container"
|
||||
# Each inner shell backgrounds a short-lived sleep and exits; the orphaned
|
||||
# sleep reparents to PID 1 (tuxreaperd, the subreaper) and becomes a zombie on
|
||||
# exit unless the SIGCHLD reaper loop reaps it.
|
||||
docker exec "$name" sh -c '
|
||||
i=0
|
||||
while [ "$i" -lt 20 ]; do
|
||||
sh -c "sleep 0.1 &"
|
||||
i=$((i + 1))
|
||||
done
|
||||
sleep 2
|
||||
'
|
||||
printf '%s\n' "[TUI] Count zombie processes under PID 1"
|
||||
zombie_count() {
|
||||
docker exec "$name" sh -c '
|
||||
zombies=0
|
||||
for f in /proc/[0-9]*/stat; do
|
||||
[ -r "$f" ] || continue
|
||||
line=
|
||||
IFS= read -r line < "$f" || continue
|
||||
rest=${line##*) }
|
||||
set -- $rest
|
||||
[ "$1" = Z ] && zombies=$((zombies + 1))
|
||||
done
|
||||
printf "%s\n" "$zombies"
|
||||
'
|
||||
}
|
||||
zombies=999
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 10 ]; do
|
||||
zombies=$(zombie_count)
|
||||
[ "$zombies" -eq 0 ] && break
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
if [ "$zombies" -eq 0 ]; then
|
||||
printf '%s\n' "[PASS] No zombie processes remain under tuxreaperd"
|
||||
else
|
||||
printf '%s\n' "[FAIL] $zombies zombie process(es) not reaped"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
printf '%s\n' "[TUI] Verify exit-status propagation from the reaped child"
|
||||
status=0
|
||||
run_raw "docker run tuxreaperd exit-status" \
|
||||
docker run --rm -v "$reaper_bin:/usr/local/bin/tuxreaperd:ro" \
|
||||
"$image" /usr/local/bin/tuxreaperd sh -c 'exit 42' || status=$?
|
||||
if [ "$status" -eq 42 ]; then
|
||||
printf '%s\n' "[PASS] Exit status 42 propagated from the child workload"
|
||||
else
|
||||
printf '%s\n' "[FAIL] Expected exit status 42, got $status"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
printf '%s\n' "[TUI] Verify SIGTERM forwarding through docker stop"
|
||||
if docker stop "$name" >/dev/null 2>&1; then
|
||||
code=$(docker wait "$name")
|
||||
if [ "$code" = "143" ]; then
|
||||
printf '%s\n' "[PASS] SIGTERM forwarded to workload, clean shutdown (exit 143)"
|
||||
else
|
||||
printf '%s\n' "[FAIL] Expected container exit 143, got $code"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
else
|
||||
printf '%s\n' "[FAIL] docker stop failed (did tuxreaperd fail to forward SIGTERM?)"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
trap - EXIT INT TERM
|
||||
if [ "$failures" -gt 0 ]; then
|
||||
printf '%s\n' "tuxreaperd tests failed: $failures failure(s)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "[TUI] Remove Container: $name"
|
||||
run_raw "docker rm -f $name" docker rm -f "$name"
|
||||
printf '%s\n' "[TUI] Complete: Container removed"
|
||||
|
||||
printf '%s\n' "[TUI] Verify removal: docker container inspect $name"
|
||||
if run_raw "docker container inspect $name" docker container inspect "$name"; then
|
||||
printf '%s\n' "Container still exists after removal" >&2
|
||||
exit 1
|
||||
else
|
||||
printf '%s\n' "[TUI] Complete: Removal verified"
|
||||
fi
|
||||
|
||||
|
||||
printf '%s\n' "tuxreaperd tests passed"
|
||||
exit "$cleanup_status"
|
||||
91
tuxreaperd.c
Normal file
91
tuxreaperd.c
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#define _GNU_SOURCE
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <signal.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/wait.h>
|
||||
#include <errno.h>
|
||||
|
||||
static volatile pid_t g_main_child = 0;
|
||||
static volatile sig_atomic_t g_child_exited = 0;
|
||||
static volatile int g_main_status = 0;
|
||||
|
||||
static void sigchld_handler(int sig) {
|
||||
(void)sig;
|
||||
int status;
|
||||
pid_t pid;
|
||||
|
||||
// Reap all terminated orphans
|
||||
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
|
||||
if (pid == g_main_child) {
|
||||
g_child_exited = 1;
|
||||
g_main_status = status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void forward_signal(int sig) {
|
||||
if (g_main_child > 0) {
|
||||
kill(g_main_child, sig);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
if (argc < 2) {
|
||||
fprintf(stderr, "Usage: %s <command> [args...]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Become a subreaper for any reparented processes down the tree
|
||||
if (prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) < 0) {
|
||||
perror("prctl(PR_SET_CHILD_SUBREAPER)");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Set up signal forwarding
|
||||
struct sigaction sa_forward;
|
||||
sa_forward.sa_handler = forward_signal;
|
||||
sigemptyset(&sa_forward.sa_mask);
|
||||
sa_forward.sa_flags = SA_RESTART;
|
||||
sigaction(SIGTERM, &sa_forward, NULL);
|
||||
sigaction(SIGINT, &sa_forward, NULL);
|
||||
sigaction(SIGHUP, &sa_forward, NULL);
|
||||
|
||||
// Set up SIGCHLD reaper handler
|
||||
struct sigaction sa_chld;
|
||||
sa_chld.sa_handler = sigchld_handler;
|
||||
sigemptyset(&sa_chld.sa_mask);
|
||||
sa_chld.sa_flags = SA_RESTART | SA_NOCLDSTOP;
|
||||
sigaction(SIGCHLD, &sa_chld, NULL);
|
||||
|
||||
// Spawn the primary workload
|
||||
g_main_child = fork();
|
||||
if (g_main_child < 0) {
|
||||
perror("fork");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (g_main_child == 0) {
|
||||
execvp(argv[1], &argv[1]);
|
||||
perror("execvp");
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
// Block until the primary process exits
|
||||
while (!g_child_exited) {
|
||||
pause();
|
||||
}
|
||||
|
||||
// Final sweep of any remaining lingering zombies
|
||||
while (waitpid(-1, NULL, WNOHANG) > 0);
|
||||
|
||||
if (WIFEXITED(g_main_status)) {
|
||||
return WEXITSTATUS(g_main_status);
|
||||
} else if (WIFSIGNALED(g_main_status)) {
|
||||
return 128 + WTERMSIG(g_main_status);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue