diff options
28 files changed, 2276 insertions, 53 deletions
diff --git a/.gitignore b/.gitignore index 1073d5513..ea99e6f7d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .DS_Store .claude/settings.local.json +.claude/worktrees/ .profile/ .xwin-cache/ diff --git a/repo/packages/m/minio/xmake.lua b/repo/packages/m/minio/xmake.lua index 5d80ae052..d90b6dff1 100644 --- a/repo/packages/m/minio/xmake.lua +++ b/repo/packages/m/minio/xmake.lua @@ -4,36 +4,28 @@ package("minio") set_homepage("https://min.io/") set_description("MinIO is a high-performance S3-compatible object storage server.") - if is_plat("windows") then - add_urls("https://dl.min.io/server/minio/release/windows-amd64/archive/minio.$(version)") - add_versions("RELEASE.2025-07-23T15-54-02Z", "4c7c7b6e52638fbe25997a5e4dbb735c44537397ccc7c503c1cb0d02bb61b517") - elseif is_plat("linux") then - add_urls("https://dl.min.io/server/minio/release/linux-amd64/archive/minio.$(version)") - add_versions("RELEASE.2025-07-23T15-54-02Z", "eef6581f6509f43ece007a6f2eb4c5e3ce41498c8956e919a7ac7b4b170fa431") - elseif is_plat("macosx") then - if is_arch("arm64") then - add_urls("https://dl.min.io/server/minio/release/darwin-arm64/archive/minio.$(version)") - add_versions("RELEASE.2025-07-23T15-54-02Z", "0939ce5553ce9e6451b69e049fbf399794368276b61da8166f84cbd8c7f2d641") - else - add_urls("https://dl.min.io/server/minio/release/darwin-amd64/archive/minio.$(version)") - add_versions("RELEASE.2025-07-23T15-54-02Z", "314cc62269594d291eaca7807522a079d7c246ff15626964d7190fdd05b7ab04") - end - end + set_sourcedir(path.join(os.scriptdir(), "../../../../thirdparty/minio")) on_install(function (package) - -- MinIO distributes bare binaries (not archives), so the downloaded file - -- lives in the package cache root rather than in source/ - local cachedir = package:cachedir() - local files = os.files(path.join(cachedir, "minio*" .. package:version_str() .. "*")) - assert(#files > 0, "minio binary not found in " .. cachedir) - local src = files[1] local bindir = package:installdir("bin") os.mkdir(bindir) if is_plat("windows") then - os.cp(src, path.join(bindir, "minio.exe")) + local src = path.absolute("bin/win-x64/minio.exe.gz") + local dst = path.join(bindir, "minio.exe") + os.vrunv("powershell", { + "-NoProfile", "-NonInteractive", "-Command", + string.format( + "$i=[IO.File]::OpenRead('%s');$d=New-Object IO.Compression.GZipStream($i,[IO.Compression.CompressionMode]::Decompress);$o=[IO.File]::Create('%s');$d.CopyTo($o);$d.Close();$i.Close();$o.Close()", + src:gsub("\\", "\\\\"), dst:gsub("\\", "\\\\") + ) + }) + elseif is_plat("macosx") then + local arch_dir = is_arch("arm64") and "osx-arm64" or "osx-x64" + os.execv("sh", {"-c", "gzip -d -c bin/" .. arch_dir .. "/minio.gz > " .. path.join(bindir, "minio")}) + os.exec("chmod 755 %s", path.join(bindir, "minio")) else - os.cp(src, path.join(bindir, "minio")) - os.run("chmod +x %s", path.join(bindir, "minio")) + os.execv("sh", {"-c", "gzip -d -c bin/linux-x64/minio.gz > " .. path.join(bindir, "minio")}) + os.exec("chmod 755 %s", path.join(bindir, "minio")) end end) diff --git a/scripts/test_scripts/block-clone-test.sh b/scripts/test_linux/block-clone-test.sh index 7c6bf5605..0a74283f2 100755 --- a/scripts/test_scripts/block-clone-test.sh +++ b/scripts/test_linux/block-clone-test.sh @@ -4,7 +4,7 @@ # Requires: root/sudo, btrfs-progs (mkfs.btrfs), xfsprogs (mkfs.xfs) # # Usage: -# sudo ./scripts/test_scripts/block-clone-test.sh [path-to-zencore-test] +# sudo ./scripts/test_linux/block-clone-test.sh [path-to-zencore-test] # # If no path is given, defaults to build/linux/x86_64/debug/zencore-test # relative to the repository root. diff --git a/scripts/test_linux/service-test.sh b/scripts/test_linux/service-test.sh new file mode 100755 index 000000000..f91339e1b --- /dev/null +++ b/scripts/test_linux/service-test.sh @@ -0,0 +1,380 @@ +#!/usr/bin/env bash +# Test zen service command lifecycle on Linux (systemd). +# +# Requires: root/sudo, systemd +# +# Usage: +# sudo ./scripts/test_linux/service-test.sh [path-to-zen] +# +# If no path is given, defaults to build/linux/x86_64/debug/zen +# relative to the repository root. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +ZEN_BINARY="${1:-$REPO_ROOT/build/linux/x86_64/debug/zen}" +ZENSERVER_BINARY="$(dirname "$ZEN_BINARY")/zenserver" +SERVICE_NAME="ZenServerTest-$$" +UNIT_NAME="com.epicgames.unreal.${SERVICE_NAME}" +UNIT_FILE="/etc/systemd/system/${UNIT_NAME}.service" + +PASSED=0 +FAILED=0 +TESTS_RUN=0 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +cleanup() { + local exit_code=$? + set +e + + echo "" + echo "--- Cleanup ---" + + # Stop service if running + if systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then + echo "Stopping test service..." + systemctl stop "$UNIT_NAME" 2>/dev/null + fi + + # Disable and remove unit file + if [ -f "$UNIT_FILE" ]; then + echo "Removing test unit file..." + systemctl disable "$UNIT_NAME" 2>/dev/null || true + rm -f "$UNIT_FILE" + systemctl daemon-reload 2>/dev/null || true + fi + + echo "" + echo "==============================" + printf " Tests run: %d\n" "$TESTS_RUN" + printf " ${GREEN}Passed: %d${NC}\n" "$PASSED" + if [ "$FAILED" -gt 0 ]; then + printf " ${RED}Failed: %d${NC}\n" "$FAILED" + else + printf " Failed: %d\n" "$FAILED" + fi + echo "==============================" + + if [ "$FAILED" -gt 0 ]; then + exit 1 + fi + exit "$exit_code" +} + +trap cleanup EXIT + +pass() { + TESTS_RUN=$((TESTS_RUN + 1)) + PASSED=$((PASSED + 1)) + printf " ${GREEN}PASS${NC}: %s\n" "$1" +} + +fail() { + TESTS_RUN=$((TESTS_RUN + 1)) + FAILED=$((FAILED + 1)) + printf " ${RED}FAIL${NC}: %s\n" "$1" + if [ -n "${2:-}" ]; then + printf " %s\n" "$2" + fi +} + +# ── Preflight checks ────────────────────────────────────────────── + +if [ "$(id -u)" -ne 0 ]; then + echo "Error: this test must be run as root (sudo)." + exit 1 +fi + +if ! command -v systemctl &>/dev/null; then + echo "Error: systemctl not found — this test requires systemd." + exit 1 +fi + +if [ ! -x "$ZEN_BINARY" ]; then + echo "Error: zen binary not found at '$ZEN_BINARY'" + echo "Build with: xmake config -m debug && xmake build zen" + exit 1 +fi + +if [ ! -x "$ZENSERVER_BINARY" ]; then + echo "Error: zenserver binary not found at '$ZENSERVER_BINARY'" + echo "Build with: xmake config -m debug && xmake build zenserver" + exit 1 +fi + +echo "zen binary: $ZEN_BINARY" +echo "zenserver binary: $ZENSERVER_BINARY" +echo "service name: $SERVICE_NAME" +echo "unit name: $UNIT_NAME" +echo "" + +# Determine which user to run the service as (the user who invoked sudo) +SERVICE_USER="${SUDO_USER:-$(whoami)}" + +# ── Test: status before install (should fail) ───────────────────── + +echo "--- Test: status before install ---" + +OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true) +if echo "$OUTPUT" | grep -qi "not installed"; then + pass "status reports 'not installed' for non-existent service" +else + fail "status should report 'not installed'" "got: $OUTPUT" +fi + +# ── Test: install ───────────────────────────────────────────────── + +echo "--- Test: install ---" + +OUTPUT=$("$ZEN_BINARY" service install "$ZENSERVER_BINARY" "$SERVICE_NAME" --user "$SERVICE_USER" 2>&1) +EXIT_CODE=$? + +if [ "$EXIT_CODE" -eq 0 ]; then + pass "install exits with code 0" +else + fail "install exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT" +fi + +if [ -f "$UNIT_FILE" ]; then + pass "unit file created at $UNIT_FILE" +else + fail "unit file created at $UNIT_FILE" +fi + +# Verify unit file contents +if [ -f "$UNIT_FILE" ]; then + if grep -q "ExecStart=.*zenserver" "$UNIT_FILE"; then + pass "unit file contains ExecStart with zenserver" + else + fail "unit file contains ExecStart with zenserver" "$(cat "$UNIT_FILE")" + fi + + if grep -q "User=$SERVICE_USER" "$UNIT_FILE"; then + pass "unit file contains correct User=$SERVICE_USER" + else + fail "unit file contains correct User=$SERVICE_USER" "$(grep User "$UNIT_FILE")" + fi + + if grep -q "Type=notify" "$UNIT_FILE"; then + pass "unit file uses Type=notify" + else + fail "unit file uses Type=notify" + fi + + if grep -q "WantedBy=multi-user.target" "$UNIT_FILE"; then + pass "unit file has WantedBy=multi-user.target" + else + fail "unit file has WantedBy=multi-user.target" + fi + + # Verify the service is enabled + if systemctl is-enabled --quiet "$UNIT_NAME" 2>/dev/null; then + pass "service is enabled after install" + else + fail "service is enabled after install" + fi +fi + +# ── Test: install again (already installed, no --full) ──────────── + +echo "--- Test: install again (idempotent) ---" + +OUTPUT=$("$ZEN_BINARY" service install "$ZENSERVER_BINARY" "$SERVICE_NAME" --user "$SERVICE_USER" 2>&1) +EXIT_CODE=$? + +if [ "$EXIT_CODE" -eq 0 ]; then + pass "re-install exits with code 0" +else + fail "re-install exits with code 0" "got exit code: $EXIT_CODE" +fi + +if echo "$OUTPUT" | grep -qi "already installed"; then + pass "re-install reports service already installed" +else + fail "re-install reports service already installed" "got: $OUTPUT" +fi + +# ── Test: status after install (not yet started) ────────────────── + +echo "--- Test: status after install (stopped) ---" + +OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true) +# The status command throws if not running, so we expect an error message +if echo "$OUTPUT" | grep -qi "not running"; then + pass "status reports 'not running' for stopped service" +else + fail "status reports 'not running' for stopped service" "got: $OUTPUT" +fi + +# ── Test: start ─────────────────────────────────────────────────── + +echo "--- Test: start ---" + +OUTPUT=$("$ZEN_BINARY" service start "$SERVICE_NAME" 2>&1) +EXIT_CODE=$? + +if [ "$EXIT_CODE" -eq 0 ]; then + pass "start exits with code 0" +else + # The TODO comments in the code indicate start may not work perfectly + fail "start exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT" +fi + +# Give the service a moment to start +sleep 1 + +if systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then + pass "service is active after start" + + # ── Test: status while running ──────────────────────────────── + + echo "--- Test: status (running) ---" + + OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true) + if echo "$OUTPUT" | grep -qi "Running"; then + pass "status reports 'Running'" + else + fail "status reports 'Running'" "got: $OUTPUT" + fi + + if echo "$OUTPUT" | grep -qi "zenserver"; then + pass "status shows executable path" + else + fail "status shows executable path" "got: $OUTPUT" + fi + + # ── Test: start again (already running) ─────────────────────── + + echo "--- Test: start again (already running) ---" + + OUTPUT=$("$ZEN_BINARY" service start "$SERVICE_NAME" 2>&1) + if echo "$OUTPUT" | grep -qi "already running"; then + pass "start reports service already running" + else + fail "start reports service already running" "got: $OUTPUT" + fi + + # ── Test: stop ──────────────────────────────────────────────── + + echo "--- Test: stop ---" + + OUTPUT=$("$ZEN_BINARY" service stop "$SERVICE_NAME" 2>&1) + EXIT_CODE=$? + + if [ "$EXIT_CODE" -eq 0 ]; then + pass "stop exits with code 0" + else + fail "stop exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT" + fi + + sleep 1 + + if ! systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then + pass "service is inactive after stop" + else + fail "service is inactive after stop" + fi +else + fail "service is active after start" "(skipping start-dependent tests)" +fi + +# ── Test: stop when already stopped ─────────────────────────────── + +echo "--- Test: stop when already stopped ---" + +# Make sure it's stopped first +systemctl stop "$UNIT_NAME" 2>/dev/null || true +sleep 1 + +OUTPUT=$("$ZEN_BINARY" service stop "$SERVICE_NAME" 2>&1 || true) +if echo "$OUTPUT" | grep -qi "not running"; then + pass "stop reports 'not running' when already stopped" +else + fail "stop reports 'not running' when already stopped" "got: $OUTPUT" +fi + +# ── Test: uninstall while running (should fail) ─────────────────── + +echo "--- Test: uninstall while running (should fail) ---" + +# Start the service so we can test uninstall-while-running +"$ZEN_BINARY" service start "$SERVICE_NAME" 2>&1 || true +sleep 1 + +if systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then + OUTPUT=$("$ZEN_BINARY" service uninstall "$SERVICE_NAME" 2>&1 || true) + EXIT_CODE=$? + + if [ "$EXIT_CODE" -ne 0 ] || echo "$OUTPUT" | grep -qi "running.*stop"; then + pass "uninstall refuses while service is running" + else + fail "uninstall refuses while service is running" "got: exit=$EXIT_CODE, output: $OUTPUT" + fi + + # Stop it for the real uninstall test + "$ZEN_BINARY" service stop "$SERVICE_NAME" 2>&1 || true + systemctl stop "$UNIT_NAME" 2>/dev/null || true + sleep 1 +else + echo " (skipped: could not start service for this test)" +fi + +# ── Test: uninstall ─────────────────────────────────────────────── + +echo "--- Test: uninstall ---" + +# Ensure stopped +systemctl stop "$UNIT_NAME" 2>/dev/null || true +sleep 1 + +OUTPUT=$("$ZEN_BINARY" service uninstall "$SERVICE_NAME" 2>&1) +EXIT_CODE=$? + +if [ "$EXIT_CODE" -eq 0 ]; then + pass "uninstall exits with code 0" +else + fail "uninstall exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT" +fi + +if [ ! -f "$UNIT_FILE" ]; then + pass "unit file removed after uninstall" +else + fail "unit file removed after uninstall" +fi + +# ── Test: status after uninstall ────────────────────────────────── + +echo "--- Test: status after uninstall ---" + +OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true) +if echo "$OUTPUT" | grep -qi "not installed"; then + pass "status reports 'not installed' after uninstall" +else + fail "status reports 'not installed' after uninstall" "got: $OUTPUT" +fi + +# ── Test: uninstall when not installed (idempotent) ─────────────── + +echo "--- Test: uninstall when not installed ---" + +OUTPUT=$("$ZEN_BINARY" service uninstall "$SERVICE_NAME" 2>&1) +EXIT_CODE=$? + +if [ "$EXIT_CODE" -eq 0 ]; then + pass "uninstall of non-existent service exits with code 0" +else + fail "uninstall of non-existent service exits with code 0" "got exit code: $EXIT_CODE" +fi + +if echo "$OUTPUT" | grep -qi "not installed"; then + pass "uninstall reports service not installed" +else + fail "uninstall reports service not installed" "got: $OUTPUT" +fi diff --git a/scripts/test_scripts/block-clone-test-mac.sh b/scripts/test_mac/block-clone-test.sh index a3d3ca4d3..1a9575dcf 100755 --- a/scripts/test_scripts/block-clone-test-mac.sh +++ b/scripts/test_mac/block-clone-test.sh @@ -5,7 +5,7 @@ # clonefile(), so no special setup is needed — just run the tests. # # Usage: -# ./scripts/test_scripts/block-clone-test-mac.sh [path-to-zencore-test] +# ./scripts/test_mac/block-clone-test.sh [path-to-zencore-test] # # If no path is given, defaults to build/macosx/<arch>/debug/zencore-test # relative to the repository root. diff --git a/scripts/test_mac/service-test.sh b/scripts/test_mac/service-test.sh new file mode 100755 index 000000000..da4a966e7 --- /dev/null +++ b/scripts/test_mac/service-test.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# Test zen service command lifecycle on macOS (launchd). +# +# Does NOT require sudo — macOS service commands run as the current user +# via ~/Library/LaunchAgents. +# +# Usage: +# ./scripts/test_mac/service-test.sh [path-to-zen] +# +# If no path is given, defaults to build/macosx/<arch>/debug/zen +# relative to the repository root. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +ARCH="$(uname -m)" +ZEN_BINARY="${1:-$REPO_ROOT/build/macosx/$ARCH/debug/zen}" +ZENSERVER_BINARY="$(dirname "$ZEN_BINARY")/zenserver" +SERVICE_NAME="ZenServerTest-$$" +DAEMON_NAME="com.epicgames.unreal.${SERVICE_NAME}" +PLIST_FILE="$HOME/Library/LaunchAgents/${DAEMON_NAME}.plist" + +PASSED=0 +FAILED=0 +TESTS_RUN=0 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +cleanup() { + local exit_code=$? + set +e + + echo "" + echo "--- Cleanup ---" + + # Bootout (stop) the agent if loaded + if launchctl list "$DAEMON_NAME" &>/dev/null; then + echo "Stopping test service..." + launchctl bootout "gui/$(id -u)" "$PLIST_FILE" 2>/dev/null || true + fi + + # Remove plist file + if [ -f "$PLIST_FILE" ]; then + echo "Removing test plist file..." + rm -f "$PLIST_FILE" + fi + + echo "" + echo "==============================" + printf " Tests run: %d\n" "$TESTS_RUN" + printf " ${GREEN}Passed: %d${NC}\n" "$PASSED" + if [ "$FAILED" -gt 0 ]; then + printf " ${RED}Failed: %d${NC}\n" "$FAILED" + else + printf " Failed: %d\n" "$FAILED" + fi + echo "==============================" + + if [ "$FAILED" -gt 0 ]; then + exit 1 + fi + exit "$exit_code" +} + +trap cleanup EXIT + +pass() { + TESTS_RUN=$((TESTS_RUN + 1)) + PASSED=$((PASSED + 1)) + printf " ${GREEN}PASS${NC}: %s\n" "$1" +} + +fail() { + TESTS_RUN=$((TESTS_RUN + 1)) + FAILED=$((FAILED + 1)) + printf " ${RED}FAIL${NC}: %s\n" "$1" + if [ -n "${2:-}" ]; then + printf " %s\n" "$2" + fi +} + +# ── Preflight checks ────────────────────────────────────────────── + +if [ "$(uname)" != "Darwin" ]; then + echo "Error: this test is for macOS only." + exit 1 +fi + +if ! command -v launchctl &>/dev/null; then + echo "Error: launchctl not found." + exit 1 +fi + +if [ ! -x "$ZEN_BINARY" ]; then + echo "Error: zen binary not found at '$ZEN_BINARY'" + echo "Build with: xmake config -m debug && xmake build zen" + exit 1 +fi + +if [ ! -x "$ZENSERVER_BINARY" ]; then + echo "Error: zenserver binary not found at '$ZENSERVER_BINARY'" + echo "Build with: xmake config -m debug && xmake build zenserver" + exit 1 +fi + +echo "zen binary: $ZEN_BINARY" +echo "zenserver binary: $ZENSERVER_BINARY" +echo "service name: $SERVICE_NAME" +echo "daemon name: $DAEMON_NAME" +echo "plist path: $PLIST_FILE" +echo "" + +# ── Test: status before install (should fail) ───────────────────── + +echo "--- Test: status before install ---" + +OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true) +if echo "$OUTPUT" | grep -qi "not installed"; then + pass "status reports 'not installed' for non-existent service" +else + fail "status should report 'not installed'" "got: $OUTPUT" +fi + +# ── Test: install ───────────────────────────────────────────────── + +echo "--- Test: install ---" + +OUTPUT=$("$ZEN_BINARY" service install "$ZENSERVER_BINARY" "$SERVICE_NAME" 2>&1) +EXIT_CODE=$? + +if [ "$EXIT_CODE" -eq 0 ]; then + pass "install exits with code 0" +else + fail "install exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT" +fi + +if [ -f "$PLIST_FILE" ]; then + pass "plist file created at $PLIST_FILE" +else + fail "plist file created at $PLIST_FILE" +fi + +# Verify plist contents +if [ -f "$PLIST_FILE" ]; then + if grep -q "<string>$ZENSERVER_BINARY</string>" "$PLIST_FILE"; then + pass "plist contains zenserver executable path" + else + fail "plist contains zenserver executable path" "$(cat "$PLIST_FILE")" + fi + + if grep -q "<key>Label</key>" "$PLIST_FILE"; then + pass "plist has Label key" + else + fail "plist has Label key" + fi + + if grep -q "$DAEMON_NAME" "$PLIST_FILE"; then + pass "plist contains correct daemon name" + else + fail "plist contains correct daemon name" + fi + + if grep -q "<key>ProgramArguments</key>" "$PLIST_FILE"; then + pass "plist has ProgramArguments" + else + fail "plist has ProgramArguments" + fi +fi + +# ── Test: install again (already installed, no --full) ──────────── + +echo "--- Test: install again (idempotent) ---" + +OUTPUT=$("$ZEN_BINARY" service install "$ZENSERVER_BINARY" "$SERVICE_NAME" 2>&1) +EXIT_CODE=$? + +if [ "$EXIT_CODE" -eq 0 ]; then + pass "re-install exits with code 0" +else + fail "re-install exits with code 0" "got exit code: $EXIT_CODE" +fi + +if echo "$OUTPUT" | grep -qi "already installed"; then + pass "re-install reports service already installed" +else + fail "re-install reports service already installed" "got: $OUTPUT" +fi + +# ── Test: status after install (not yet started) ────────────────── + +echo "--- Test: status after install (stopped) ---" + +OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true) +if echo "$OUTPUT" | grep -qi "not running"; then + pass "status reports 'not running' for stopped service" +else + fail "status reports 'not running' for stopped service" "got: $OUTPUT" +fi + +# ── Test: start ─────────────────────────────────────────────────── + +echo "--- Test: start ---" + +OUTPUT=$("$ZEN_BINARY" service start "$SERVICE_NAME" 2>&1) +EXIT_CODE=$? + +if [ "$EXIT_CODE" -eq 0 ]; then + pass "start exits with code 0" +else + fail "start exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT" +fi + +# Give the service a moment to start +sleep 2 + +if launchctl list "$DAEMON_NAME" &>/dev/null; then + pass "service is loaded after start" + + # ── Test: status while running ──────────────────────────────── + + echo "--- Test: status (running) ---" + + OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true) + if echo "$OUTPUT" | grep -qi "Running"; then + pass "status reports 'Running'" + else + fail "status reports 'Running'" "got: $OUTPUT" + fi + + if echo "$OUTPUT" | grep -qi "zenserver"; then + pass "status shows executable path" + else + fail "status shows executable path" "got: $OUTPUT" + fi + + # ── Test: start again (already running) ─────────────────────── + + echo "--- Test: start again (already running) ---" + + OUTPUT=$("$ZEN_BINARY" service start "$SERVICE_NAME" 2>&1) + if echo "$OUTPUT" | grep -qi "already running"; then + pass "start reports service already running" + else + fail "start reports service already running" "got: $OUTPUT" + fi + + # ── Test: stop ──────────────────────────────────────────────── + + echo "--- Test: stop ---" + + OUTPUT=$("$ZEN_BINARY" service stop "$SERVICE_NAME" 2>&1) + EXIT_CODE=$? + + if [ "$EXIT_CODE" -eq 0 ]; then + pass "stop exits with code 0" + else + fail "stop exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT" + fi + + sleep 1 + + if ! launchctl list "$DAEMON_NAME" &>/dev/null; then + pass "service is unloaded after stop" + else + fail "service is unloaded after stop" + fi +else + fail "service is loaded after start" "(skipping start-dependent tests)" +fi + +# ── Test: stop when already stopped ─────────────────────────────── + +echo "--- Test: stop when already stopped ---" + +# Make sure it's stopped first +launchctl bootout "gui/$(id -u)" "$PLIST_FILE" 2>/dev/null || true +sleep 1 + +OUTPUT=$("$ZEN_BINARY" service stop "$SERVICE_NAME" 2>&1 || true) +if echo "$OUTPUT" | grep -qi "not running"; then + pass "stop reports 'not running' when already stopped" +else + fail "stop reports 'not running' when already stopped" "got: $OUTPUT" +fi + +# ── Test: uninstall while running (should fail) ─────────────────── + +echo "--- Test: uninstall while running (should fail) ---" + +# Start the service so we can test uninstall-while-running +"$ZEN_BINARY" service start "$SERVICE_NAME" 2>&1 || true +sleep 2 + +if launchctl list "$DAEMON_NAME" &>/dev/null; then + OUTPUT=$("$ZEN_BINARY" service uninstall "$SERVICE_NAME" 2>&1 || true) + EXIT_CODE=$? + + if [ "$EXIT_CODE" -ne 0 ] || echo "$OUTPUT" | grep -qi "running.*stop"; then + pass "uninstall refuses while service is running" + else + fail "uninstall refuses while service is running" "got: exit=$EXIT_CODE, output: $OUTPUT" + fi + + # Stop it for the real uninstall test + "$ZEN_BINARY" service stop "$SERVICE_NAME" 2>&1 || true + launchctl bootout "gui/$(id -u)" "$PLIST_FILE" 2>/dev/null || true + sleep 1 +else + echo " (skipped: could not start service for this test)" +fi + +# ── Test: uninstall ─────────────────────────────────────────────── + +echo "--- Test: uninstall ---" + +# Ensure stopped +launchctl bootout "gui/$(id -u)" "$PLIST_FILE" 2>/dev/null || true +sleep 1 + +OUTPUT=$("$ZEN_BINARY" service uninstall "$SERVICE_NAME" 2>&1) +EXIT_CODE=$? + +if [ "$EXIT_CODE" -eq 0 ]; then + pass "uninstall exits with code 0" +else + fail "uninstall exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT" +fi + +if [ ! -f "$PLIST_FILE" ]; then + pass "plist file removed after uninstall" +else + fail "plist file removed after uninstall" +fi + +# ── Test: status after uninstall ────────────────────────────────── + +echo "--- Test: status after uninstall ---" + +OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true) +if echo "$OUTPUT" | grep -qi "not installed"; then + pass "status reports 'not installed' after uninstall" +else + fail "status reports 'not installed' after uninstall" "got: $OUTPUT" +fi + +# ── Test: uninstall when not installed (idempotent) ─────────────── + +echo "--- Test: uninstall when not installed ---" + +OUTPUT=$("$ZEN_BINARY" service uninstall "$SERVICE_NAME" 2>&1) +EXIT_CODE=$? + +if [ "$EXIT_CODE" -eq 0 ]; then + pass "uninstall of non-existent service exits with code 0" +else + fail "uninstall of non-existent service exits with code 0" "got exit code: $EXIT_CODE" +fi + +if echo "$OUTPUT" | grep -qi "not installed"; then + pass "uninstall reports service not installed" +else + fail "uninstall reports service not installed" "got: $OUTPUT" +fi diff --git a/scripts/test_scripts/block-clone-test-windows.ps1 b/scripts/test_windows/block-clone-test.ps1 index df24831a4..aa6ec3a39 100644 --- a/scripts/test_scripts/block-clone-test-windows.ps1 +++ b/scripts/test_windows/block-clone-test.ps1 @@ -7,7 +7,7 @@ # # Usage: # # From an elevated PowerShell prompt: -# .\scripts\test_scripts\block-clone-test-windows.ps1 [-TestBinary <path>] +# .\scripts\test_windows\block-clone-test.ps1 [-TestBinary <path>] # # If -TestBinary is not given, defaults to build\windows\x64\debug\zencore-test.exe # relative to the repository root. diff --git a/scripts/test_windows/service-test.ps1 b/scripts/test_windows/service-test.ps1 new file mode 100644 index 000000000..4c484c63f --- /dev/null +++ b/scripts/test_windows/service-test.ps1 @@ -0,0 +1,353 @@ +# Test zen service command lifecycle on Windows (SCM). +# +# Requires: Administrator privileges +# +# Usage: +# # From an elevated PowerShell prompt: +# .\scripts\test_windows\service-test.ps1 [-ZenBinary <path>] +# +# If -ZenBinary is not given, defaults to build\windows\x64\debug\zen.exe +# relative to the repository root. + +param( + [string]$ZenBinary +) + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = (Resolve-Path "$ScriptDir\..\..").Path + +if (-not $ZenBinary) { + $ZenBinary = Join-Path $RepoRoot "build\windows\x64\debug\zen.exe" +} +$ZenServerBinary = Join-Path (Split-Path -Parent $ZenBinary) "zenserver.exe" +$ServiceName = "ZenServerTest-$PID" + +$Script:Passed = 0 +$Script:Failed = 0 +$Script:TestsRun = 0 + +function Pass($Message) { + $Script:TestsRun++ + $Script:Passed++ + Write-Host " PASS: $Message" -ForegroundColor Green +} + +function Fail($Message, $Detail) { + $Script:TestsRun++ + $Script:Failed++ + Write-Host " FAIL: $Message" -ForegroundColor Red + if ($Detail) { + Write-Host " $Detail" + } +} + +function Cleanup { + Write-Host "" + Write-Host "--- Cleanup ---" + + # Stop the service if running + $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if ($svc -and $svc.Status -eq "Running") { + Write-Host "Stopping test service..." + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } + + # Delete the service if it exists + if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) { + Write-Host "Removing test service..." + sc.exe delete $ServiceName 2>$null | Out-Null + } + + Write-Host "" + Write-Host "==============================" + Write-Host " Tests run: $Script:TestsRun" + Write-Host " Passed: $Script:Passed" -ForegroundColor Green + if ($Script:Failed -gt 0) { + Write-Host " Failed: $Script:Failed" -ForegroundColor Red + } else { + Write-Host " Failed: $Script:Failed" + } + Write-Host "==============================" +} + +# ── Preflight checks ────────────────────────────────────────────── + +$IsAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + +if (-not $IsAdmin) { + Write-Host "Error: this test must be run from an elevated (Administrator) prompt." -ForegroundColor Red + exit 1 +} + +if (-not (Test-Path $ZenBinary)) { + Write-Host "Error: zen binary not found at '$ZenBinary'" -ForegroundColor Red + Write-Host "Build with: xmake config -m debug && xmake build zen" + exit 1 +} + +if (-not (Test-Path $ZenServerBinary)) { + Write-Host "Error: zenserver binary not found at '$ZenServerBinary'" -ForegroundColor Red + Write-Host "Build with: xmake config -m debug && xmake build zenserver" + exit 1 +} + +Write-Host "zen binary: $ZenBinary" +Write-Host "zenserver binary: $ZenServerBinary" +Write-Host "service name: $ServiceName" +Write-Host "" + +try { + +# ── Test: status before install (should fail) ───────────────────── + +Write-Host "--- Test: status before install ---" + +$Output = & $ZenBinary service status $ServiceName 2>&1 | Out-String +if ($Output -match "not installed") { + Pass "status reports 'not installed' for non-existent service" +} else { + Fail "status should report 'not installed'" "got: $Output" +} + +# ── Test: install ───────────────────────────────────────────────── + +Write-Host "--- Test: install ---" + +$Output = & $ZenBinary service install $ZenServerBinary $ServiceName --allow-elevation 2>&1 | Out-String +$ExitCode = $LASTEXITCODE + +if ($ExitCode -eq 0) { + Pass "install exits with code 0" +} else { + Fail "install exits with code 0" "got exit code: $ExitCode, output: $Output" +} + +$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($svc) { + Pass "service registered in SCM" +} else { + Fail "service registered in SCM" +} + +# Verify service configuration +if ($svc) { + $svcWmi = Get-CimInstance -ClassName Win32_Service -Filter "Name='$ServiceName'" -ErrorAction SilentlyContinue + if ($svcWmi -and $svcWmi.PathName -match "zenserver") { + Pass "service binary path contains zenserver" + } else { + Fail "service binary path contains zenserver" "got: $($svcWmi.PathName)" + } + + if ($svcWmi -and $svcWmi.StartMode -eq "Auto") { + Pass "service is set to auto-start" + } else { + Fail "service is set to auto-start" "got: $($svcWmi.StartMode)" + } +} + +# ── Test: install again (already installed, no --full) ──────────── + +Write-Host "--- Test: install again (idempotent) ---" + +$Output = & $ZenBinary service install $ZenServerBinary $ServiceName --allow-elevation 2>&1 | Out-String +$ExitCode = $LASTEXITCODE + +if ($ExitCode -eq 0) { + Pass "re-install exits with code 0" +} else { + Fail "re-install exits with code 0" "got exit code: $ExitCode" +} + +if ($Output -match "already installed") { + Pass "re-install reports service already installed" +} else { + Fail "re-install reports service already installed" "got: $Output" +} + +# ── Test: status after install (not yet started) ────────────────── + +Write-Host "--- Test: status after install (stopped) ---" + +$Output = & $ZenBinary service status $ServiceName 2>&1 | Out-String +if ($Output -match "not running") { + Pass "status reports 'not running' for stopped service" +} else { + Fail "status reports 'not running' for stopped service" "got: $Output" +} + +# ── Test: start ─────────────────────────────────────────────────── + +Write-Host "--- Test: start ---" + +$Output = & $ZenBinary service start $ServiceName --allow-elevation 2>&1 | Out-String +$ExitCode = $LASTEXITCODE + +if ($ExitCode -eq 0) { + Pass "start exits with code 0" +} else { + Fail "start exits with code 0" "got exit code: $ExitCode, output: $Output" +} + +Start-Sleep -Seconds 2 + +$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($svc -and $svc.Status -eq "Running") { + Pass "service is running after start" + + # ── Test: status while running ──────────────────────────────── + + Write-Host "--- Test: status (running) ---" + + $Output = & $ZenBinary service status $ServiceName 2>&1 | Out-String + if ($Output -match "Running") { + Pass "status reports 'Running'" + } else { + Fail "status reports 'Running'" "got: $Output" + } + + if ($Output -match "zenserver") { + Pass "status shows executable path" + } else { + Fail "status shows executable path" "got: $Output" + } + + # ── Test: start again (already running) ─────────────────────── + + Write-Host "--- Test: start again (already running) ---" + + $Output = & $ZenBinary service start $ServiceName --allow-elevation 2>&1 | Out-String + if ($Output -match "already running") { + Pass "start reports service already running" + } else { + Fail "start reports service already running" "got: $Output" + } + + # ── Test: stop ──────────────────────────────────────────────── + + Write-Host "--- Test: stop ---" + + $Output = & $ZenBinary service stop $ServiceName --allow-elevation 2>&1 | Out-String + $ExitCode = $LASTEXITCODE + + if ($ExitCode -eq 0) { + Pass "stop exits with code 0" + } else { + Fail "stop exits with code 0" "got exit code: $ExitCode, output: $Output" + } + + Start-Sleep -Seconds 2 + + $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if ($svc -and $svc.Status -ne "Running") { + Pass "service is not running after stop" + } else { + Fail "service is not running after stop" + } +} else { + Fail "service is running after start" "(skipping start-dependent tests)" +} + +# ── Test: stop when already stopped ─────────────────────────────── + +Write-Host "--- Test: stop when already stopped ---" + +Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue +Start-Sleep -Seconds 1 + +$Output = & $ZenBinary service stop $ServiceName --allow-elevation 2>&1 | Out-String +if ($Output -match "not running") { + Pass "stop reports 'not running' when already stopped" +} else { + Fail "stop reports 'not running' when already stopped" "got: $Output" +} + +# ── Test: uninstall while running (should fail) ─────────────────── + +Write-Host "--- Test: uninstall while running (should fail) ---" + +& $ZenBinary service start $ServiceName --allow-elevation 2>&1 | Out-Null +Start-Sleep -Seconds 2 + +$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($svc -and $svc.Status -eq "Running") { + $Output = & $ZenBinary service uninstall $ServiceName --allow-elevation 2>&1 | Out-String + $ExitCode = $LASTEXITCODE + + if ($ExitCode -ne 0 -or $Output -match "running.*stop") { + Pass "uninstall refuses while service is running" + } else { + Fail "uninstall refuses while service is running" "got: exit=$ExitCode, output: $Output" + } + + # Stop it for the real uninstall test + & $ZenBinary service stop $ServiceName --allow-elevation 2>&1 | Out-Null + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 +} else { + Write-Host " (skipped: could not start service for this test)" +} + +# ── Test: uninstall ─────────────────────────────────────────────── + +Write-Host "--- Test: uninstall ---" + +Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue +Start-Sleep -Seconds 1 + +$Output = & $ZenBinary service uninstall $ServiceName --allow-elevation 2>&1 | Out-String +$ExitCode = $LASTEXITCODE + +if ($ExitCode -eq 0) { + Pass "uninstall exits with code 0" +} else { + Fail "uninstall exits with code 0" "got exit code: $ExitCode, output: $Output" +} + +$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if (-not $svc) { + Pass "service removed from SCM after uninstall" +} else { + Fail "service removed from SCM after uninstall" +} + +# ── Test: status after uninstall ────────────────────────────────── + +Write-Host "--- Test: status after uninstall ---" + +$Output = & $ZenBinary service status $ServiceName 2>&1 | Out-String +if ($Output -match "not installed") { + Pass "status reports 'not installed' after uninstall" +} else { + Fail "status reports 'not installed' after uninstall" "got: $Output" +} + +# ── Test: uninstall when not installed (idempotent) ─────────────── + +Write-Host "--- Test: uninstall when not installed ---" + +$Output = & $ZenBinary service uninstall $ServiceName --allow-elevation 2>&1 | Out-String +$ExitCode = $LASTEXITCODE + +if ($ExitCode -eq 0) { + Pass "uninstall of non-existent service exits with code 0" +} else { + Fail "uninstall of non-existent service exits with code 0" "got exit code: $ExitCode" +} + +if ($Output -match "not installed") { + Pass "uninstall reports service not installed" +} else { + Fail "uninstall reports service not installed" "got: $Output" +} + +} finally { + Cleanup +} + +if ($Script:Failed -gt 0) { + exit 1 +} diff --git a/src/zencore/include/zencore/logging/sink.h b/src/zencore/include/zencore/logging/sink.h index 172176a4e..3e6a1deed 100644 --- a/src/zencore/include/zencore/logging/sink.h +++ b/src/zencore/include/zencore/logging/sink.h @@ -11,6 +11,11 @@ namespace zen::logging { +/// Base class for log sinks. +/// +/// Log() and Flush() may be called concurrently from multiple threads. +/// Implementations must provide their own synchronization (e.g. a mutex +/// or RwLock) to protect any mutable state including the formatter. class Sink : public RefCounted { public: diff --git a/src/zencore/include/zencore/process.h b/src/zencore/include/zencore/process.h index 96afd5950..75fd7b25a 100644 --- a/src/zencore/include/zencore/process.h +++ b/src/zencore/include/zencore/process.h @@ -54,6 +54,38 @@ private: /** Basic process creation */ +// Platform-agnostic RAII pipe handles for capturing child stdout/stderr. +// The destructor closes any open handles/fds automatically. +struct StdoutPipeHandles +{ + StdoutPipeHandles() = default; + ~StdoutPipeHandles(); + + StdoutPipeHandles(const StdoutPipeHandles&) = delete; + StdoutPipeHandles& operator=(const StdoutPipeHandles&) = delete; + + StdoutPipeHandles(StdoutPipeHandles&& Other) noexcept; + StdoutPipeHandles& operator=(StdoutPipeHandles&& Other) noexcept; + + // Close only the write end (call after child is launched so parent doesn't hold it open). + void CloseWriteEnd(); + + // Close both ends of the pipe. + void Close(); + +#if ZEN_PLATFORM_WINDOWS + void* ReadHandle = nullptr; // HANDLE for reading (parent side) + void* WriteHandle = nullptr; // HANDLE for writing (child side) +#else + int ReadFd = -1; + int WriteFd = -1; +#endif +}; + +// Create a pipe suitable for capturing child process stdout. +// The write end is inheritable; the read end is not. +bool CreateStdoutPipe(StdoutPipeHandles& OutPipe); + struct CreateProcOptions { enum @@ -71,6 +103,8 @@ struct CreateProcOptions const std::filesystem::path* WorkingDirectory = nullptr; uint32_t Flags = 0; std::filesystem::path StdoutFile; + StdoutPipeHandles* StdoutPipe = nullptr; // Mutually exclusive with StdoutFile. Parent reads from ReadHandle after launch. + StdoutPipeHandles* StderrPipe = nullptr; // Optional separate pipe for stderr. When null, stderr shares StdoutPipe. /// Additional environment variables for the child process. These are merged /// with the parent's environment — existing variables are inherited, and diff --git a/src/zencore/process.cpp b/src/zencore/process.cpp index 29de107bd..8a91ab287 100644 --- a/src/zencore/process.cpp +++ b/src/zencore/process.cpp @@ -137,6 +137,144 @@ IsZombieProcess(int pid, std::error_code& OutEc) } #endif // ZEN_PLATFORM_MAC +////////////////////////////////////////////////////////////////////////// +// Pipe creation for child process stdout capture + +#if ZEN_PLATFORM_WINDOWS + +StdoutPipeHandles::~StdoutPipeHandles() +{ + Close(); +} + +StdoutPipeHandles::StdoutPipeHandles(StdoutPipeHandles&& Other) noexcept +: ReadHandle(std::exchange(Other.ReadHandle, nullptr)) +, WriteHandle(std::exchange(Other.WriteHandle, nullptr)) +{ +} + +StdoutPipeHandles& +StdoutPipeHandles::operator=(StdoutPipeHandles&& Other) noexcept +{ + if (this != &Other) + { + Close(); + ReadHandle = std::exchange(Other.ReadHandle, nullptr); + WriteHandle = std::exchange(Other.WriteHandle, nullptr); + } + return *this; +} + +void +StdoutPipeHandles::CloseWriteEnd() +{ + if (WriteHandle) + { + CloseHandle(WriteHandle); + WriteHandle = nullptr; + } +} + +void +StdoutPipeHandles::Close() +{ + if (ReadHandle) + { + CloseHandle(ReadHandle); + ReadHandle = nullptr; + } + CloseWriteEnd(); +} + +bool +CreateStdoutPipe(StdoutPipeHandles& OutPipe) +{ + SECURITY_ATTRIBUTES Sa; + Sa.nLength = sizeof(Sa); + Sa.lpSecurityDescriptor = nullptr; + Sa.bInheritHandle = TRUE; + + HANDLE ReadHandle = nullptr; + HANDLE WriteHandle = nullptr; + if (!::CreatePipe(&ReadHandle, &WriteHandle, &Sa, 0)) + { + return false; + } + + // The read end should not be inherited by the child + SetHandleInformation(ReadHandle, HANDLE_FLAG_INHERIT, 0); + + OutPipe.ReadHandle = ReadHandle; + OutPipe.WriteHandle = WriteHandle; + return true; +} + +#else + +StdoutPipeHandles::~StdoutPipeHandles() +{ + Close(); +} + +StdoutPipeHandles::StdoutPipeHandles(StdoutPipeHandles&& Other) noexcept +: ReadFd(std::exchange(Other.ReadFd, -1)) +, WriteFd(std::exchange(Other.WriteFd, -1)) +{ +} + +StdoutPipeHandles& +StdoutPipeHandles::operator=(StdoutPipeHandles&& Other) noexcept +{ + if (this != &Other) + { + Close(); + ReadFd = std::exchange(Other.ReadFd, -1); + WriteFd = std::exchange(Other.WriteFd, -1); + } + return *this; +} + +void +StdoutPipeHandles::CloseWriteEnd() +{ + if (WriteFd >= 0) + { + close(WriteFd); + WriteFd = -1; + } +} + +void +StdoutPipeHandles::Close() +{ + if (ReadFd >= 0) + { + close(ReadFd); + ReadFd = -1; + } + CloseWriteEnd(); +} + +bool +CreateStdoutPipe(StdoutPipeHandles& OutPipe) +{ + int Fds[2]; + if (pipe(Fds) != 0) + { + return false; + } + OutPipe.ReadFd = Fds[0]; + OutPipe.WriteFd = Fds[1]; + + // Set close-on-exec on the read end so the child doesn't inherit it + fcntl(OutPipe.ReadFd, F_SETFD, FD_CLOEXEC); + return true; +} + +#endif + +////////////////////////////////////////////////////////////////////////// + ProcessHandle::ProcessHandle() = default; #if ZEN_PLATFORM_WINDOWS @@ -309,6 +447,10 @@ ProcessHandle::Reset() { #if ZEN_PLATFORM_WINDOWS CloseHandle(m_ProcessHandle); +#elif ZEN_PLATFORM_LINUX || ZEN_PLATFORM_MAC + // Reap the child if it has already exited to prevent zombies. + // If still running, it will be reparented to init on our exit. + waitpid(m_Pid, nullptr, WNOHANG); #endif m_ProcessHandle = nullptr; m_Pid = 0; @@ -350,17 +492,26 @@ ProcessHandle::Wait(int TimeoutMs, std::error_code& OutEc) timespec SleepTime = {0, SleepMs * 1000 * 1000}; for (int SleepedTimeMS = 0;; SleepedTimeMS += SleepMs) { - int WaitState = 0; - if (waitpid(m_Pid, &WaitState, WNOHANG | WCONTINUED | WUNTRACED) != -1) + int WaitState = 0; + pid_t WaitResult = waitpid(m_Pid, &WaitState, WNOHANG | WCONTINUED | WUNTRACED); + if (WaitResult > 0 && WIFEXITED(WaitState)) { - if (WIFEXITED(WaitState)) - { - m_ExitCode = WEXITSTATUS(WaitState); - } + m_ExitCode = WEXITSTATUS(WaitState); } if (!IsProcessRunning(m_Pid, OutEc)) { + // Process is gone but waitpid(WNOHANG) may have missed the exit status + // due to a TOCTOU race (process became a zombie between waitpid and + // IsProcessRunning). Do a blocking reap now to capture the exit code. + if (WaitResult <= 0) + { + WaitState = 0; + if (waitpid(m_Pid, &WaitState, 0) > 0 && WIFEXITED(WaitState)) + { + m_ExitCode = WEXITSTATUS(WaitState); + } + } return true; } else if (OutEc) @@ -381,6 +532,12 @@ ProcessHandle::Wait(int TimeoutMs, std::error_code& OutEc) else if (IsZombieProcess(m_Pid, OutEc)) { ZEN_INFO("Found process {} in zombie state, treating as not running", m_Pid); + // Reap the zombie to capture its exit code. + WaitState = 0; + if (waitpid(m_Pid, &WaitState, 0) > 0 && WIFEXITED(WaitState)) + { + m_ExitCode = WEXITSTATUS(WaitState); + } return true; } @@ -567,7 +724,40 @@ CreateProcNormal(const std::filesystem::path& Executable, std::string_view Comma ExtendableWideStringBuilder<256> CommandLineZ; CommandLineZ << CommandLine; - if (!Options.StdoutFile.empty()) + bool DuplicatedStdErr = false; + + if (Options.StdoutPipe != nullptr && Options.StdoutPipe->WriteHandle != nullptr) + { + StartupInfo.hStdInput = nullptr; + StartupInfo.hStdOutput = (HANDLE)Options.StdoutPipe->WriteHandle; + + if (Options.StderrPipe != nullptr && Options.StderrPipe->WriteHandle != nullptr) + { + // Use separate pipe for stderr + StartupInfo.hStdError = (HANDLE)Options.StderrPipe->WriteHandle; + StartupInfo.dwFlags |= STARTF_USESTDHANDLES; + InheritHandles = true; + } + else + { + // Duplicate stdout handle for stderr (both go to same pipe) + const BOOL DupSuccess = DuplicateHandle(GetCurrentProcess(), + StartupInfo.hStdOutput, + GetCurrentProcess(), + &StartupInfo.hStdError, + 0, + TRUE, + DUPLICATE_SAME_ACCESS); + + if (DupSuccess) + { + DuplicatedStdErr = true; + StartupInfo.dwFlags |= STARTF_USESTDHANDLES; + InheritHandles = true; + } + } + } + else if (!Options.StdoutFile.empty()) { SECURITY_ATTRIBUTES sa; sa.nLength = sizeof sa; @@ -593,6 +783,7 @@ CreateProcNormal(const std::filesystem::path& Executable, std::string_view Comma if (Success) { + DuplicatedStdErr = true; StartupInfo.dwFlags |= STARTF_USESTDHANDLES; InheritHandles = true; } @@ -616,8 +807,16 @@ CreateProcNormal(const std::filesystem::path& Executable, std::string_view Comma if (StartupInfo.dwFlags & STARTF_USESTDHANDLES) { - CloseHandle(StartupInfo.hStdError); - CloseHandle(StartupInfo.hStdOutput); + // Only close hStdError if we duplicated it (caller-owned pipe handles are not ours to close) + if (DuplicatedStdErr) + { + CloseHandle(StartupInfo.hStdError); + } + // Only close hStdOutput if it was a file handle we created (not a pipe handle owned by caller) + if (Options.StdoutPipe == nullptr || Options.StdoutPipe->WriteHandle == nullptr) + { + CloseHandle(StartupInfo.hStdOutput); + } } if (!Success) @@ -826,7 +1025,25 @@ CreateProc(const std::filesystem::path& Executable, std::string_view CommandLine ZEN_UNUSED(Result); } - if (!Options.StdoutFile.empty()) + if (Options.StdoutPipe != nullptr && Options.StdoutPipe->WriteFd >= 0) + { + dup2(Options.StdoutPipe->WriteFd, STDOUT_FILENO); + + if (Options.StderrPipe != nullptr && Options.StderrPipe->WriteFd >= 0) + { + dup2(Options.StderrPipe->WriteFd, STDERR_FILENO); + close(Options.StderrPipe->WriteFd); + // StderrPipe ReadFd has FD_CLOEXEC so it's auto-closed on exec + } + else + { + dup2(Options.StdoutPipe->WriteFd, STDERR_FILENO); + } + + close(Options.StdoutPipe->WriteFd); + // ReadFd has FD_CLOEXEC so it's auto-closed on exec + } + else if (!Options.StdoutFile.empty()) { int Fd = open(Options.StdoutFile.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); if (Fd >= 0) diff --git a/src/zencore/testing.cpp b/src/zencore/testing.cpp index c6ee5ee6b..9f88a3365 100644 --- a/src/zencore/testing.cpp +++ b/src/zencore/testing.cpp @@ -309,10 +309,6 @@ RunTestMain(int Argc, char* Argv[], const char* ExecutableName, void (*ForceLink ForceLink(); -# if ZEN_PLATFORM_LINUX - zen::IgnoreChildSignals(); -# endif - # if ZEN_WITH_TRACE zen::TraceInit(ExecutableName); zen::TraceOptions TraceCommandlineOptions; diff --git a/src/zenserver-test/process-tests.cpp b/src/zenserver-test/process-tests.cpp new file mode 100644 index 000000000..649f24f54 --- /dev/null +++ b/src/zenserver-test/process-tests.cpp @@ -0,0 +1,298 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include <zencore/zencore.h> + +#if ZEN_WITH_TESTS + +# include "zenserver-test.h" + +# include <zencore/filesystem.h> +# include <zencore/process.h> +# include <zencore/testing.h> + +# if ZEN_PLATFORM_WINDOWS +# include <zencore/windows.h> +# else +# include <unistd.h> +# endif + +namespace zen::tests { + +using namespace std::literals; + +static std::filesystem::path +GetAppStubPath() +{ + return TestEnv.ProgramBaseDir() / ("zentest-appstub" ZEN_EXE_SUFFIX_LITERAL); +} + +// Read all available data from the read end of a StdoutPipeHandles. +// Must be called after CloseWriteEnd() so that the read will see EOF. +static std::string +ReadAllFromPipe(StdoutPipeHandles& Pipe) +{ + std::string Result; + char Buffer[4096]; + +# if ZEN_PLATFORM_WINDOWS + DWORD BytesRead = 0; + while (::ReadFile(Pipe.ReadHandle, Buffer, sizeof(Buffer), &BytesRead, nullptr) && BytesRead > 0) + { + Result.append(Buffer, BytesRead); + } +# else + ssize_t BytesRead = 0; + while ((BytesRead = read(Pipe.ReadFd, Buffer, sizeof(Buffer))) > 0) + { + Result.append(Buffer, static_cast<size_t>(BytesRead)); + } +# endif + + return Result; +} + +TEST_SUITE_BEGIN("server.process"); + +////////////////////////////////////////////////////////////////////////// + +TEST_CASE("pipe.capture_stdout") +{ + StdoutPipeHandles Pipe; + REQUIRE(CreateStdoutPipe(Pipe)); + + const std::string ExpectedOutput = "hello_from_pipe_test"; + std::filesystem::path AppStub = GetAppStubPath(); + std::string CommandLine = fmt::format("zentest-appstub -echo={}", ExpectedOutput); + + CreateProcOptions Options; + Options.StdoutPipe = &Pipe; + + CreateProcResult ProcResult = CreateProc(AppStub, CommandLine, Options); + + ProcessHandle Process; + Process.Initialize(ProcResult); + + // Close the write end, then drain before Wait() to avoid deadlock if output fills the pipe buffer. + Pipe.CloseWriteEnd(); + + std::string Output = ReadAllFromPipe(Pipe); + + Process.Wait(); + + // The appstub also prints "[zentest] exiting with exit code: 0\n" + CHECK(Output.find(ExpectedOutput) != std::string::npos); + CHECK_EQ(Process.GetExitCode(), 0); +} + +TEST_CASE("pipe.capture_multiline") +{ + StdoutPipeHandles Pipe; + REQUIRE(CreateStdoutPipe(Pipe)); + + std::filesystem::path AppStub = GetAppStubPath(); + std::string CommandLine = "zentest-appstub -echo=line1 -echo=line2 -echo=line3"; + + CreateProcOptions Options; + Options.StdoutPipe = &Pipe; + + CreateProcResult ProcResult = CreateProc(AppStub, CommandLine, Options); + + ProcessHandle Process; + Process.Initialize(ProcResult); + + Pipe.CloseWriteEnd(); + + std::string Output = ReadAllFromPipe(Pipe); + + Process.Wait(); + + CHECK(Output.find("line1") != std::string::npos); + CHECK(Output.find("line2") != std::string::npos); + CHECK(Output.find("line3") != std::string::npos); + CHECK_EQ(Process.GetExitCode(), 0); +} + +TEST_CASE("pipe.raii_cleanup") +{ + // Verify that StdoutPipeHandles cleans up handles when it goes out of scope + // (no leaked handles). We can't directly assert on handle counts, but we can + // verify that creating and destroying many pipes doesn't fail. + for (int i = 0; i < 100; ++i) + { + StdoutPipeHandles Pipe; + REQUIRE(CreateStdoutPipe(Pipe)); + // Pipe goes out of scope here — destructor should close both ends + } +} + +TEST_CASE("pipe.move_semantics") +{ + StdoutPipeHandles Original; + REQUIRE(CreateStdoutPipe(Original)); + + // Move-construct a new pipe from Original + StdoutPipeHandles Moved(std::move(Original)); + +# if ZEN_PLATFORM_WINDOWS + CHECK(Moved.ReadHandle != nullptr); + CHECK(Moved.WriteHandle != nullptr); + CHECK(Original.ReadHandle == nullptr); + CHECK(Original.WriteHandle == nullptr); +# else + CHECK(Moved.ReadFd >= 0); + CHECK(Moved.WriteFd >= 0); + CHECK(Original.ReadFd == -1); + CHECK(Original.WriteFd == -1); +# endif + + // Move-assign + StdoutPipeHandles Assigned; + Assigned = std::move(Moved); + +# if ZEN_PLATFORM_WINDOWS + CHECK(Assigned.ReadHandle != nullptr); + CHECK(Assigned.WriteHandle != nullptr); + CHECK(Moved.ReadHandle == nullptr); + CHECK(Moved.WriteHandle == nullptr); +# else + CHECK(Assigned.ReadFd >= 0); + CHECK(Assigned.WriteFd >= 0); + CHECK(Moved.ReadFd == -1); + CHECK(Moved.WriteFd == -1); +# endif + + // Assigned goes out of scope — destructor closes handles +} + +TEST_CASE("pipe.close_is_idempotent") +{ + StdoutPipeHandles Pipe; + REQUIRE(CreateStdoutPipe(Pipe)); + + Pipe.Close(); + // Calling Close again should be safe (no double-close) + Pipe.Close(); + +# if ZEN_PLATFORM_WINDOWS + CHECK(Pipe.ReadHandle == nullptr); + CHECK(Pipe.WriteHandle == nullptr); +# else + CHECK(Pipe.ReadFd == -1); + CHECK(Pipe.WriteFd == -1); +# endif +} + +TEST_CASE("pipe.close_write_end_only") +{ + StdoutPipeHandles Pipe; + REQUIRE(CreateStdoutPipe(Pipe)); + + Pipe.CloseWriteEnd(); + +# if ZEN_PLATFORM_WINDOWS + CHECK(Pipe.ReadHandle != nullptr); + CHECK(Pipe.WriteHandle == nullptr); +# else + CHECK(Pipe.ReadFd >= 0); + CHECK(Pipe.WriteFd == -1); +# endif + + // Remaining read handle cleaned up by destructor +} + +TEST_CASE("pipe.capture_with_nonzero_exit") +{ + StdoutPipeHandles Pipe; + REQUIRE(CreateStdoutPipe(Pipe)); + + std::filesystem::path AppStub = GetAppStubPath(); + std::string CommandLine = "zentest-appstub -echo=before_exit -f=42"; + + CreateProcOptions Options; + Options.StdoutPipe = &Pipe; + + CreateProcResult ProcResult = CreateProc(AppStub, CommandLine, Options); + + ProcessHandle Process; + Process.Initialize(ProcResult); + + Pipe.CloseWriteEnd(); + + std::string Output = ReadAllFromPipe(Pipe); + + Process.Wait(); + + CHECK(Output.find("before_exit") != std::string::npos); + CHECK_EQ(Process.GetExitCode(), 42); +} + +TEST_CASE("pipe.stderr_on_shared_pipe") +{ + StdoutPipeHandles Pipe; + REQUIRE(CreateStdoutPipe(Pipe)); + + std::filesystem::path AppStub = GetAppStubPath(); + std::string CommandLine = "zentest-appstub -echo=from_stdout -echoerr=from_stderr"; + + CreateProcOptions Options; + Options.StdoutPipe = &Pipe; + + CreateProcResult ProcResult = CreateProc(AppStub, CommandLine, Options); + + ProcessHandle Process; + Process.Initialize(ProcResult); + + Pipe.CloseWriteEnd(); + + std::string Output = ReadAllFromPipe(Pipe); + + Process.Wait(); + + // Both stdout and stderr content should appear on the shared pipe + CHECK(Output.find("from_stdout") != std::string::npos); + CHECK(Output.find("from_stderr") != std::string::npos); + CHECK_EQ(Process.GetExitCode(), 0); +} + +TEST_CASE("pipe.separate_stderr") +{ + StdoutPipeHandles StdoutPipe; + StdoutPipeHandles StderrPipe; + REQUIRE(CreateStdoutPipe(StdoutPipe)); + REQUIRE(CreateStdoutPipe(StderrPipe)); + + std::filesystem::path AppStub = GetAppStubPath(); + std::string CommandLine = "zentest-appstub -echo=on_stdout -echoerr=on_stderr"; + + CreateProcOptions Options; + Options.StdoutPipe = &StdoutPipe; + Options.StderrPipe = &StderrPipe; + + CreateProcResult ProcResult = CreateProc(AppStub, CommandLine, Options); + + ProcessHandle Process; + Process.Initialize(ProcResult); + + StdoutPipe.CloseWriteEnd(); + StderrPipe.CloseWriteEnd(); + + std::string StdoutOutput = ReadAllFromPipe(StdoutPipe); + std::string StderrOutput = ReadAllFromPipe(StderrPipe); + + Process.Wait(); + + CHECK(StdoutOutput.find("on_stdout") != std::string::npos); + CHECK(StderrOutput.find("on_stderr") != std::string::npos); + // Verify separation: stderr content should NOT appear in stdout pipe + CHECK(StdoutOutput.find("on_stderr") == std::string::npos); + CHECK(StderrOutput.find("on_stdout") == std::string::npos); + CHECK_EQ(Process.GetExitCode(), 0); +} + +////////////////////////////////////////////////////////////////////////// + +TEST_SUITE_END(); + +} // namespace zen::tests + +#endif diff --git a/src/zenserver-test/zenserver-test.cpp b/src/zenserver-test/zenserver-test.cpp index 42632682b..fff77957d 100644 --- a/src/zenserver-test/zenserver-test.cpp +++ b/src/zenserver-test/zenserver-test.cpp @@ -76,10 +76,6 @@ main(int argc, char** argv) zen::CommandLineConverter ArgConverter(argc, argv); -# if ZEN_PLATFORM_LINUX - IgnoreChildSignals(); -# endif - # if ZEN_WITH_TRACE zen::TraceInit("zenserver-test"); TraceOptions TraceCommandlineOptions; diff --git a/src/zenserver/diag/logging.cpp b/src/zenserver/diag/logging.cpp index 178c3d3b5..38b15480a 100644 --- a/src/zenserver/diag/logging.cpp +++ b/src/zenserver/diag/logging.cpp @@ -13,6 +13,7 @@ #include <zencore/string.h> #include <zenutil/logging.h> #include <zenutil/logging/rotatingfilesink.h> +#include <zenutil/splitconsole/tcplogstreamsink.h> #include "otlphttp.h" @@ -28,6 +29,7 @@ InitializeServerLogging(const ZenServerConfig& InOptions, bool WithCacheService) .IsTest = InOptions.IsTest, .NoConsoleOutput = InOptions.LoggingConfig.NoConsoleOutput, .QuietConsole = InOptions.LoggingConfig.QuietConsole, + .ForceColor = InOptions.LoggingConfig.ForceColor, .AbsLogFile = InOptions.LoggingConfig.AbsLogFile, .LogId = InOptions.LoggingConfig.LogId}; @@ -81,6 +83,27 @@ InitializeServerLogging(const ZenServerConfig& InOptions, bool WithCacheService) } #endif + if (!InOptions.LoggingConfig.LogStreamEndpoint.empty()) + { + std::string Endpoint = InOptions.LoggingConfig.LogStreamEndpoint; + std::string Host = "localhost"; + uint16_t Port = 0; + + auto ColonPos = Endpoint.rfind(':'); + if (ColonPos != std::string::npos) + { + Host = Endpoint.substr(0, ColonPos); + std::optional<uint16_t> P = ParseInt<uint16_t>(std::string_view(Endpoint).substr(ColonPos + 1)); + Port = P.value_or(0); + } + + if (Port > 0) + { + logging::SinkPtr StreamSink(new TcpLogStreamSink(Host, Port, "zenserver")); + zen::logging::Default()->AddSink(std::move(StreamSink)); + } + } + FinishInitializeLogging(LogOptions); const zen::Oid ServerSessionId = zen::GetSessionId(); diff --git a/src/zenserver/main.cpp b/src/zenserver/main.cpp index bf328c499..dff162b1c 100644 --- a/src/zenserver/main.cpp +++ b/src/zenserver/main.cpp @@ -123,10 +123,6 @@ AppMain(int argc, char* argv[]) signal(SIGINT, utils::SignalCallbackHandler); signal(SIGTERM, utils::SignalCallbackHandler); -#if ZEN_PLATFORM_LINUX - IgnoreChildSignals(); -#endif - try { typename Main::Config ServerOptions; diff --git a/src/zentest-appstub/zentest-appstub.cpp b/src/zentest-appstub/zentest-appstub.cpp index 509629739..13c96ebe2 100644 --- a/src/zentest-appstub/zentest-appstub.cpp +++ b/src/zentest-appstub/zentest-appstub.cpp @@ -291,6 +291,20 @@ main(int argc, char* argv[]) std::string_view ErrorArg = SplitArg(argv[i]); ExitCode = ParseIntArg(ErrorArg); } + else if (std::strncmp(argv[i], "-echo=", 6) == 0) + { + // Write a message to stdout. Useful for testing pipe capture. + std::string_view Message = SplitArg(argv[i]); + printf("%.*s", static_cast<int>(Message.size()), Message.data()); + fflush(stdout); + } + else if (std::strncmp(argv[i], "-echoerr=", 9) == 0) + { + // Write a message to stderr. Useful for testing separate stderr pipe capture. + std::string_view Message = SplitArg(argv[i]); + fprintf(stderr, "%.*s", static_cast<int>(Message.size()), Message.data()); + fflush(stderr); + } else if ((_strnicmp(argv[i], "-input=", 7) == 0) || (_strnicmp(argv[i], "-i=", 3) == 0)) { /* mimic DDC2 diff --git a/src/zenutil/config/loggingconfig.cpp b/src/zenutil/config/loggingconfig.cpp index 5092c60aa..e2db31160 100644 --- a/src/zenutil/config/loggingconfig.cpp +++ b/src/zenutil/config/loggingconfig.cpp @@ -29,6 +29,8 @@ ZenLoggingCmdLineOptions::AddCliOptions(cxxopts::Options& options, ZenLoggingCon ("log-critical", "Change selected loggers to level CRITICAL", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::Critical])) ("log-off", "Change selected loggers to level OFF", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::Off])) ("otlp-endpoint", "OpenTelemetry endpoint URI (e.g http://localhost:4318)", cxxopts::value<std::string>(LoggingConfig.OtelEndpointUri)) + ("force-color", "Force colored log output even when stdout is not a terminal", cxxopts::value<bool>(LoggingConfig.ForceColor)->default_value("false")) + ("log-stream", "TCP log stream endpoint (host:port)", cxxopts::value<std::string>(LoggingConfig.LogStreamEndpoint)) ; // clang-format on } diff --git a/src/zenutil/include/zenutil/config/loggingconfig.h b/src/zenutil/include/zenutil/config/loggingconfig.h index b55b2d9f7..33a5eb172 100644 --- a/src/zenutil/include/zenutil/config/loggingconfig.h +++ b/src/zenutil/include/zenutil/config/loggingconfig.h @@ -16,10 +16,12 @@ struct ZenLoggingConfig { bool NoConsoleOutput = false; // Control default use of stdout for diagnostics bool QuietConsole = false; // Configure console logger output to level WARN + bool ForceColor = false; // Force colored output even when stdout is not a terminal std::filesystem::path AbsLogFile; // Absolute path to main log file std::string Loggers[logging::LogLevelCount]; - std::string LogId; // Id for tagging log output - std::string OtelEndpointUri; // OpenTelemetry endpoint URI + std::string LogId; // Id for tagging log output + std::string OtelEndpointUri; // OpenTelemetry endpoint URI + std::string LogStreamEndpoint; // TCP log stream endpoint (host:port) }; void ApplyLoggingOptions(cxxopts::Options& options, ZenLoggingConfig& LoggingConfig); diff --git a/src/zenutil/include/zenutil/logging.h b/src/zenutil/include/zenutil/logging.h index 95419c274..282ae1b9a 100644 --- a/src/zenutil/include/zenutil/logging.h +++ b/src/zenutil/include/zenutil/logging.h @@ -28,7 +28,8 @@ struct LoggingOptions bool AllowAsync = true; bool NoConsoleOutput = false; bool QuietConsole = false; - std::filesystem::path AbsLogFile; // Absolute path to main log file + bool ForceColor = false; // Force colored output even when stdout is not a terminal + std::filesystem::path AbsLogFile; // Absolute path to main log file std::string LogId; }; diff --git a/src/zenutil/include/zenutil/splitconsole/logstreamlistener.h b/src/zenutil/include/zenutil/splitconsole/logstreamlistener.h new file mode 100644 index 000000000..06544308c --- /dev/null +++ b/src/zenutil/include/zenutil/splitconsole/logstreamlistener.h @@ -0,0 +1,66 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include <zencore/zencore.h> + +#include <cstdint> +#include <memory> +#include <string> + +ZEN_THIRD_PARTY_INCLUDES_START +#include <asio/io_context.hpp> +ZEN_THIRD_PARTY_INCLUDES_END + +namespace zen { + +/// Interface for receiving log lines from a LogStreamListener. +/// Clients implement this to route received log messages to their desired output. +class LogStreamHandler +{ +public: + virtual ~LogStreamHandler() = default; + + virtual void AppendLogLine(std::string Line) = 0; +}; + +/// TCP listener that accepts connections from remote processes streaming log messages. +/// Each message is a CbObject with fields: "text" (string), "source" (string), "level" (string, optional). +/// +/// A LogStreamHandler can be set to receive parsed log lines. If no handler is set, +/// received messages are silently discarded. +/// +/// Two modes of operation: +/// - Owned thread: pass only Port; an internal IO thread is created. +/// - External io_context: pass an existing asio::io_context; no thread is created, +/// the caller is responsible for running the io_context. +class LogStreamListener +{ +public: + /// Start listening with an internal IO thread. + explicit LogStreamListener(uint16_t Port = 0); + + /// Start listening on an externally-driven io_context (no thread created). + LogStreamListener(asio::io_context& IoContext, uint16_t Port = 0); + + ~LogStreamListener(); + + LogStreamListener(const LogStreamListener&) = delete; + LogStreamListener& operator=(const LogStreamListener&) = delete; + + /// Set the handler that will receive parsed log lines. May be called at any time. + /// Pass nullptr to stop delivering messages. + void SetHandler(LogStreamHandler* Handler); + + /// Returns the actual port the listener is bound to. + uint16_t GetPort() const; + + /// Gracefully stop accepting new connections and shut down existing sessions. + void Shutdown(); + +private: + struct Impl; + std::unique_ptr<Impl> m_Impl; +}; + +} // namespace zen diff --git a/src/zenutil/include/zenutil/splitconsole/tcplogstreamsink.h b/src/zenutil/include/zenutil/splitconsole/tcplogstreamsink.h new file mode 100644 index 000000000..2ab7d469e --- /dev/null +++ b/src/zenutil/include/zenutil/splitconsole/tcplogstreamsink.h @@ -0,0 +1,209 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include <zencore/compactbinarybuilder.h> +#include <zencore/logging.h> +#include <zencore/logging/sink.h> +#include <zencore/thread.h> + +ZEN_THIRD_PARTY_INCLUDES_START +#include <asio.hpp> +ZEN_THIRD_PARTY_INCLUDES_END + +#include <atomic> +#include <condition_variable> +#include <deque> +#include <mutex> +#include <string> +#include <thread> + +namespace zen { + +/// Logging sink that connects to a LogStreamListener via TCP and sends CbObject-framed log messages. +/// Connection is lazy (on first enqueued message) and silently reconnects on failure. +/// Messages are serialized on the caller thread and written asynchronously from a dedicated IO thread. +/// A bounded queue drops the oldest messages on overflow to prevent unbounded memory growth. +class TcpLogStreamSink : public logging::Sink +{ +public: + TcpLogStreamSink(const std::string& Host, uint16_t Port, std::string Source, uint32_t MaxQueueSize = 4096) + : m_Host(Host) + , m_Port(Port) + , m_Source(std::move(Source)) + , m_MaxQueueSize(MaxQueueSize) + { + m_IoThread = std::thread([this]() { IoThreadMain(); }); + } + + ~TcpLogStreamSink() override + { + { + std::lock_guard<std::mutex> Lock(m_QueueMutex); + m_Stopping = true; + m_DrainDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + } + m_QueueCv.notify_one(); + if (m_IoThread.joinable()) + { + m_IoThread.join(); + } + } + + void Log(const logging::LogMessage& Msg) override + { + logging::MemoryBuffer Formatted; + { + RwLock::SharedLockScope Lock(m_FormatterLock); + if (m_Formatter) + { + m_Formatter->Format(Msg, Formatted); + } + else + { + // Fallback: use raw payload + auto Payload = Msg.GetPayload(); + Formatted.append(Payload.data(), Payload.data() + Payload.size()); + } + } + + std::string_view Text(Formatted.data(), Formatted.size()); + + // Strip trailing newlines + while (!Text.empty() && (Text.back() == '\n' || Text.back() == '\r')) + { + Text.remove_suffix(1); + } + + // Build CbObject with text, source, and level fields + CbObjectWriter Writer; + Writer.AddString("text", Text); + Writer.AddString("source", m_Source); + Writer.AddString("level", ToStringView(Msg.GetLevel())); + CbObject Obj = Writer.Save(); + + // Enqueue for async write + { + std::lock_guard<std::mutex> Lock(m_QueueMutex); + if (m_Queue.size() >= m_MaxQueueSize) + { + m_Queue.pop_front(); + m_DroppedMessages.fetch_add(1, std::memory_order_relaxed); + } + m_Queue.push_back(std::move(Obj)); + } + m_QueueCv.notify_one(); + } + + void Flush() override + { + // Nothing to flush — writes happen asynchronously + } + + void SetFormatter(std::unique_ptr<logging::Formatter> InFormatter) override + { + RwLock::ExclusiveLockScope Lock(m_FormatterLock); + m_Formatter = std::move(InFormatter); + } + +private: + void IoThreadMain() + { + zen::SetCurrentThreadName("TcpLogSink"); + + for (;;) + { + std::deque<CbObject> Batch; + { + std::unique_lock<std::mutex> Lock(m_QueueMutex); + m_QueueCv.wait(Lock, [this]() { return m_Stopping || !m_Queue.empty(); }); + + if (m_Stopping && m_Queue.empty()) + { + break; + } + + if (m_Stopping && std::chrono::steady_clock::now() >= m_DrainDeadline) + { + break; + } + + Batch.swap(m_Queue); + } + + uint32_t Dropped = m_DroppedMessages.exchange(0, std::memory_order_relaxed); + if (Dropped > 0) + { + // We could/should log here, but that could cause a feedback loop which + // would trigger subsequent dropped message warnings + } + + if (!m_Connected && !Connect()) + { + if (m_Stopping) + { + break; // don't retry during shutdown + } + continue; // drop batch — will retry on next batch + } + + for (auto& Obj : Batch) + { + MemoryView View = Obj.GetView(); + asio::error_code Ec; + asio::write(m_Socket, asio::buffer(View.GetData(), View.GetSize()), Ec); + if (Ec) + { + m_Connected = false; + break; // drop remaining messages in batch + } + } + } + } + + bool Connect() + { + try + { + asio::ip::tcp::resolver Resolver(m_IoContext); + auto Endpoints = Resolver.resolve(m_Host, std::to_string(m_Port)); + asio::connect(m_Socket, Endpoints); + m_Connected = true; + return true; + } + catch (const std::exception&) + { + // Reset the socket for next attempt + m_Socket = asio::ip::tcp::socket(m_IoContext); + m_Connected = false; + return false; + } + } + + // IO thread state (only accessed from m_IoThread) + asio::io_context m_IoContext; + asio::ip::tcp::socket m_Socket{m_IoContext}; + bool m_Connected = false; + + // Configuration (immutable after construction) + std::string m_Host; + uint16_t m_Port; + std::string m_Source; + uint32_t m_MaxQueueSize; + + // Formatter (protected by RwLock since Log is called from multiple threads) + RwLock m_FormatterLock; + std::unique_ptr<logging::Formatter> m_Formatter; + + // Queue shared between Log() callers and IO thread + std::mutex m_QueueMutex; + std::condition_variable m_QueueCv; + std::deque<CbObject> m_Queue; + std::atomic<uint32_t> m_DroppedMessages{0}; + bool m_Stopping = false; + std::chrono::steady_clock::time_point m_DrainDeadline; + + std::thread m_IoThread; +}; + +} // namespace zen diff --git a/src/zenutil/splitconsole/logstreamlistener.cpp b/src/zenutil/splitconsole/logstreamlistener.cpp new file mode 100644 index 000000000..9f1d1a02c --- /dev/null +++ b/src/zenutil/splitconsole/logstreamlistener.cpp @@ -0,0 +1,270 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include <zenutil/splitconsole/logstreamlistener.h> + +#include <zencore/compactbinary.h> +#include <zencore/fmtutils.h> +#include <zencore/logging.h> +#include <zencore/thread.h> + +ZEN_THIRD_PARTY_INCLUDES_START +#include <asio.hpp> +ZEN_THIRD_PARTY_INCLUDES_END + +#include <atomic> +#include <vector> + +namespace zen { + +////////////////////////////////////////////////////////////////////////// +// LogStreamSession — reads CbObject-framed messages from a single TCP connection + +class LogStreamSession : public std::enable_shared_from_this<LogStreamSession> +{ +public: + LogStreamSession(asio::ip::tcp::socket Socket, std::atomic<LogStreamHandler*>& Handler) + : m_Socket(std::move(Socket)) + , m_Handler(Handler) + { + } + + void Start() { DoRead(); } + +private: + void DoRead() + { + auto Self = shared_from_this(); + m_Socket.async_read_some(asio::buffer(m_ReadBuf.data() + m_BufferUsed, m_ReadBuf.size() - m_BufferUsed), + [Self](const asio::error_code& Ec, std::size_t BytesRead) { + if (Ec) + { + return; // connection closed or error — session ends + } + Self->m_BufferUsed += BytesRead; + Self->ProcessBuffer(); + Self->DoRead(); + }); + } + + void ProcessBuffer() + { + // Try to consume as many complete CbObject messages as possible + while (m_BufferUsed > 0) + { + MemoryView View = MakeMemoryView(m_ReadBuf.data(), m_BufferUsed); + CbFieldType Type; + uint64_t Size = 0; + + if (!TryMeasureCompactBinary(View, Type, Size)) + { + break; // need more data + } + + if (Size > m_BufferUsed) + { + break; // need more data + } + + // Parse the CbObject + CbObject Obj = CbObject(SharedBuffer::Clone(MakeMemoryView(m_ReadBuf.data(), Size))); + + std::string_view Text = Obj["text"].AsString(); + std::string_view Source = Obj["source"].AsString(); + + // Split multi-line messages into individual AppendLogLine calls so that + // each line gets its own row in the SplitConsole ring buffer. + while (!Text.empty()) + { + std::string_view Line = Text; + auto Pos = Text.find('\n'); + if (Pos != std::string_view::npos) + { + Line = Text.substr(0, Pos); + Text.remove_prefix(Pos + 1); + } + else + { + Text = {}; + } + + // Strip trailing CR from CRLF + if (!Line.empty() && Line.back() == '\r') + { + Line.remove_suffix(1); + } + + if (Line.empty()) + { + continue; + } + + LogStreamHandler* Handler = m_Handler.load(std::memory_order_acquire); + if (Handler) + { + if (!Source.empty()) + { + Handler->AppendLogLine(fmt::format("[{}] {}", Source, Line)); + } + else + { + Handler->AppendLogLine(std::string(Line)); + } + } + } + + // Remove consumed bytes from buffer + std::size_t Consumed = static_cast<std::size_t>(Size); + std::memmove(m_ReadBuf.data(), m_ReadBuf.data() + Consumed, m_BufferUsed - Consumed); + m_BufferUsed -= Consumed; + } + + // If buffer is full and we can't parse a message, the message is too large — drop connection + if (m_BufferUsed == m_ReadBuf.size()) + { + ZEN_WARN("LogStreamSession: buffer full with no complete message, dropping connection"); + asio::error_code Ec; + m_Socket.close(Ec); + m_BufferUsed = 0; + } + } + + asio::ip::tcp::socket m_Socket; + std::atomic<LogStreamHandler*>& m_Handler; + std::array<uint8_t, 65536> m_ReadBuf{}; + std::size_t m_BufferUsed = 0; +}; + +////////////////////////////////////////////////////////////////////////// +// LogStreamListener::Impl + +struct LogStreamListener::Impl +{ + // Owned io_context mode — creates and runs its own thread + explicit Impl(uint16_t Port) : m_OwnedIoContext(std::make_unique<asio::io_context>()), m_Acceptor(*m_OwnedIoContext) + { + SetupAcceptor(Port); + m_IoThread = std::thread([this]() { + zen::SetCurrentThreadName("LogStreamIO"); + m_OwnedIoContext->run(); + }); + } + + // External io_context mode — caller drives the io_context + Impl(asio::io_context& IoContext, uint16_t Port) : m_Acceptor(IoContext) { SetupAcceptor(Port); } + + ~Impl() { Shutdown(); } + + void Shutdown() + { + if (m_Stopped.exchange(true)) + { + return; + } + + asio::error_code Ec; + m_Acceptor.close(Ec); + + if (m_OwnedIoContext) + { + m_OwnedIoContext->stop(); + } + + if (m_IoThread.joinable()) + { + m_IoThread.join(); + } + } + + uint16_t GetPort() const { return m_Port; } + + void SetHandler(LogStreamHandler* Handler) { m_Handler.store(Handler, std::memory_order_release); } + +private: + void SetupAcceptor(uint16_t Port) + { + // Try dual-stack IPv6 first (accepts both IPv4 and IPv6), fall back to IPv4-only + asio::error_code Ec; + m_Acceptor.open(asio::ip::tcp::v6(), Ec); + if (!Ec) + { + m_Acceptor.set_option(asio::ip::v6_only(false), Ec); + if (!Ec) + { + m_Acceptor.bind(asio::ip::tcp::endpoint(asio::ip::tcp::v6(), Port), Ec); + } + } + + if (Ec) + { + // Fall back to IPv4-only + if (m_Acceptor.is_open()) + { + m_Acceptor.close(); + } + m_Acceptor.open(asio::ip::tcp::v4()); + m_Acceptor.bind(asio::ip::tcp::endpoint(asio::ip::tcp::v4(), Port)); + } + + m_Acceptor.listen(); + m_Port = m_Acceptor.local_endpoint().port(); + StartAccept(); + } + + void StartAccept() + { + m_Acceptor.async_accept([this](const asio::error_code& Ec, asio::ip::tcp::socket Socket) { + if (Ec) + { + return; // acceptor closed + } + + auto Session = std::make_shared<LogStreamSession>(std::move(Socket), m_Handler); + Session->Start(); + + if (!m_Stopped.load()) + { + StartAccept(); + } + }); + } + + std::atomic<LogStreamHandler*> m_Handler{nullptr}; + std::unique_ptr<asio::io_context> m_OwnedIoContext; // null when using external io_context + asio::ip::tcp::acceptor m_Acceptor; + std::thread m_IoThread; + uint16_t m_Port = 0; + std::atomic<bool> m_Stopped{false}; +}; + +////////////////////////////////////////////////////////////////////////// +// LogStreamListener + +LogStreamListener::LogStreamListener(uint16_t Port) : m_Impl(std::make_unique<Impl>(Port)) +{ +} + +LogStreamListener::LogStreamListener(asio::io_context& IoContext, uint16_t Port) : m_Impl(std::make_unique<Impl>(IoContext, Port)) +{ +} + +LogStreamListener::~LogStreamListener() = default; + +void +LogStreamListener::SetHandler(LogStreamHandler* Handler) +{ + m_Impl->SetHandler(Handler); +} + +uint16_t +LogStreamListener::GetPort() const +{ + return m_Impl->GetPort(); +} + +void +LogStreamListener::Shutdown() +{ + m_Impl->Shutdown(); +} + +} // namespace zen diff --git a/thirdparty/minio/bin/linux-x64/minio.gz b/thirdparty/minio/bin/linux-x64/minio.gz Binary files differnew file mode 100644 index 000000000..4b438e1ec --- /dev/null +++ b/thirdparty/minio/bin/linux-x64/minio.gz diff --git a/thirdparty/minio/bin/osx-arm64/minio.gz b/thirdparty/minio/bin/osx-arm64/minio.gz Binary files differnew file mode 100644 index 000000000..30aba27da --- /dev/null +++ b/thirdparty/minio/bin/osx-arm64/minio.gz diff --git a/thirdparty/minio/bin/osx-x64/minio.gz b/thirdparty/minio/bin/osx-x64/minio.gz Binary files differnew file mode 100644 index 000000000..7ee1dc441 --- /dev/null +++ b/thirdparty/minio/bin/osx-x64/minio.gz diff --git a/thirdparty/minio/bin/win-x64/minio.exe.gz b/thirdparty/minio/bin/win-x64/minio.exe.gz Binary files differnew file mode 100644 index 000000000..7b34d6d7b --- /dev/null +++ b/thirdparty/minio/bin/win-x64/minio.exe.gz @@ -219,7 +219,7 @@ add_defines("EASTL_STD_ITERATOR_CATEGORY_ENABLED", "EASTL_DEPRECATIONS_FOR_2024_ add_requires("eastl", {system = false}) add_requires("consul", {system = false}) -- for hub tests -add_requires("minio RELEASE.2025-07-23T15-54-02Z", {system = false}) -- for S3 storage tests +add_requires("minio", {system = false}) -- for S3 storage tests add_requires("nomad", {system = false}) -- for nomad provisioner tests add_requires("oidctoken", {system = false}) |