From d31ab6af0748595cbd729e217b8032d363cca044 Mon Sep 17 00:00:00 2001 From: mrkmntal Date: Wed, 26 Aug 2026 17:59:39 -0400 Subject: [PATCH] Add tuxreaperd micro-init and in-container zombie-reaping tests for 0.2 --- .gitignore | 1 + CMakeLists.txt | 5 ++ DEVLOG.md | 17 ++++ README.md | 9 ++- compile.sh | 21 +++++ main.cpp | 2 +- src/docker_manager.cpp | 33 +++++++- tests/test_tuxreaperd.sh | 170 +++++++++++++++++++++++++++++++++++++++ tuxreaperd.c | 91 +++++++++++++++++++++ 9 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 tests/test_tuxreaperd.sh create mode 100644 tuxreaperd.c diff --git a/.gitignore b/.gitignore index 567609b..1bf1e0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ build/ +/tuxreaperd diff --git a/CMakeLists.txt b/CMakeLists.txt index bf82d92..682a191 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/DEVLOG.md b/DEVLOG.md index a464156..636bffb 100644 --- a/DEVLOG.md +++ b/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. diff --git a/README.md b/README.md index 009f04c..7f3cac4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compile.sh b/compile.sh index 8073c53..2e2aa7b 100755 --- a/compile.sh +++ b/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' "$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' "$reaper_output" >"$escaped_reaper" { printf '%s\n' '' printf '%s\n' 'Tux-Dock Test Report' @@ -164,6 +183,8 @@ if [ "$run_tests" -eq 1 ]; then cat "$escaped_raw" printf '%s\n' '

TUI Transcript

'
             cat "$escaped_tui"
+            printf '%s\n' '

Tuxreaperd Zombie Reaping

'
+            cat "$escaped_reaper"
             printf '%s\n' '
' } >"$report" diff --git a/main.cpp b/main.cpp index 66de887..ab1f386 100644 --- a/main.cpp +++ b/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() { diff --git a/src/docker_manager.cpp b/src/docker_manager.cpp index 65243b0..704a00c 100644 --- a/src/docker_manager.cpp +++ b/src/docker_manager.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include @@ -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 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; } diff --git a/tests/test_tuxreaperd.sh b/tests/test_tuxreaperd.sh new file mode 100644 index 0000000..811bd48 --- /dev/null +++ b/tests/test_tuxreaperd.sh @@ -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" diff --git a/tuxreaperd.c b/tuxreaperd.c new file mode 100644 index 0000000..c61da3d --- /dev/null +++ b/tuxreaperd.c @@ -0,0 +1,91 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +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 [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; +} +