From 49cf6c14f3126f0415897b2ace7fe59659fce391 Mon Sep 17 00:00:00 2001 From: Yehonal <147092+Yehonal@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:43:38 +0200 Subject: [PATCH] fix(apps/startup-scripts): guard against duplicate/too-early server starts (#26149) Co-authored-by: YehonalBot --- apps/startup-scripts/src/starter | 172 +++++++++++++++++- .../test/test_startup_scripts.bats | 80 ++++++++ 2 files changed, 251 insertions(+), 1 deletion(-) diff --git a/apps/startup-scripts/src/starter b/apps/startup-scripts/src/starter index 4e58e8e7b..5585e7eba 100755 --- a/apps/startup-scripts/src/starter +++ b/apps/startup-scripts/src/starter @@ -45,6 +45,174 @@ if [ ! -f "$BINARY" ]; then exit 1 fi +function config_value() { + local key="$1" + + if [ -z "${CONFIG_ABS:-}" ] || [ ! -f "$CONFIG_ABS" ]; then + return 1 + fi + + awk -F= -v key="$key" ' + { + configKey = $1 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", configKey) + } + configKey == key { + value = $0 + sub(/^[^=]*=/, "", value) + sub(/[[:space:]]*[#;].*$/, "", value) + gsub(/"/, "", value) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + print value + exit + } + ' "$CONFIG_ABS" +} + +function command_contains_config() { + local command_line="$1" + + if [ -z "${CONFIG_ABS:-}" ]; then + return 0 + fi + + case "$command_line" in + *" -c $CONFIG_ABS"*|*" -c \"$CONFIG_ABS\""*) + return 0 + ;; + esac + + return 1 +} + +function find_existing_instances() { + local line pid command_line + + while IFS= read -r line; do + pid="${line%% *}" + command_line="${line#* }" + + [ -z "$pid" ] && continue + [ "$pid" = "$$" ] && continue + [ "$pid" = "$PPID" ] && continue + + case "$command_line" in + *"/starter "*|*"pgrep "*) + continue + ;; + esac + + if command_contains_config "$command_line"; then + printf '%s\n' "$line" + fi + done < <(pgrep -fa "$EXECPATH" 2>/dev/null || true) +} + +function listening_on_port() { + local port="$1" + + command -v ss >/dev/null 2>&1 || return 1 + ss -H -ltn "sport = :$port" 2>/dev/null | grep -q . +} + +function guarded_ports() { + local port soap_enabled soap_port + + if [ "$BINFILE" != "worldserver" ]; then + return 0 + fi + + port="$(config_value "WorldServerPort" || true)" + if [ -n "$port" ]; then + printf '%s\n' "$port" + fi + + soap_enabled="$(config_value "SOAP.Enabled" || true)" + soap_port="$(config_value "SOAP.Port" || true)" + if [ "$soap_enabled" = "1" ] && [ -n "$soap_port" ]; then + printf '%s\n' "$soap_port" + fi +} + +function find_listening_guard_ports() { + local port + + while IFS= read -r port; do + [ -z "$port" ] && continue + if listening_on_port "$port"; then + printf '%s\n' "$port" + fi + done < <(guarded_ports) +} + +function wait_for_startup_guard() { + local timeout="${AC_STARTUP_GUARD_TIMEOUT:-90}" + local interval="${AC_STARTUP_GUARD_INTERVAL:-2}" + local elapsed=0 + local instances ports + local sleep_for + local warned=0 + + if [ "${AC_STARTUP_SKIP_GUARD:-0}" = "1" ]; then + return 0 + fi + + case "$timeout" in + ''|*[!0-9]*) + echo "Startup guard: invalid AC_STARTUP_GUARD_TIMEOUT '$timeout', using 90." + timeout=90 + ;; + esac + + case "$interval" in + ''|*[!0-9]*) + echo "Startup guard: invalid AC_STARTUP_GUARD_INTERVAL '$interval', using 2." + interval=2 + ;; + esac + + if [ "$interval" -lt 1 ]; then + echo "Startup guard: AC_STARTUP_GUARD_INTERVAL must be at least 1, using 1." + interval=1 + fi + + while true; do + instances="$(find_existing_instances)" + ports="$(find_listening_guard_ports)" + + if [ -z "$instances" ] && [ -z "$ports" ]; then + return 0 + fi + + if [ "$elapsed" -ge "$timeout" ]; then + echo "Startup guard: refusing to start duplicate $BINFILE after waiting ${timeout}s." + if [ -n "$instances" ]; then + echo "Startup guard: existing matching process(es):" + echo "$instances" + fi + if [ -n "$ports" ]; then + echo "Startup guard: configured port(s) still listening: $(echo "$ports" | paste -sd ',' -)" + fi + exit 0 + fi + + if [ "$warned" -eq 0 ]; then + echo "Startup guard: waiting for existing $BINFILE instance or configured port(s) to clear..." + warned=1 + fi + + sleep_for="$interval" + if [ $((elapsed + sleep_for)) -gt "$timeout" ]; then + sleep_for=$((timeout - elapsed)) + fi + + [ "$sleep_for" -le 0 ] && continue + + sleep "$sleep_for" + elapsed=$((elapsed + sleep_for)) + done +} + # Create crashes directory if it doesn't exist mkdir -p "$CRASHES_PATH" @@ -55,6 +223,8 @@ cd "$BINPATH" || { EXECPATH=$(realpath "$BINFILE") +wait_for_startup_guard + if [ "$GDB_ENABLED" -eq 1 ]; then echo "Starting $EXECPATH with GDB enabled" @@ -148,4 +318,4 @@ else exec "$EXECPATH" ${CONFIG_ABS:+-c "$CONFIG_ABS"} fi fi -fi \ No newline at end of file +fi diff --git a/apps/startup-scripts/test/test_startup_scripts.bats b/apps/startup-scripts/test/test_startup_scripts.bats index c7a90c7f6..82d49eba0 100755 --- a/apps/startup-scripts/test/test_startup_scripts.bats +++ b/apps/startup-scripts/test/test_startup_scripts.bats @@ -50,6 +50,86 @@ teardown() { [[ "$output" =~ "Test server starting" ]] } +@test "starter: should refuse duplicate binary/config start" { + cat > "$TEST_DIR/bin/duplicate-server" << 'EOF' +#!/usr/bin/env bash +sleep 30 +EOF + chmod +x "$TEST_DIR/bin/duplicate-server" + + "$TEST_DIR/bin/duplicate-server" -c "$TEST_DIR/test-server.conf" & + local duplicate_pid=$! + + AC_STARTUP_GUARD_TIMEOUT=1 AC_STARTUP_GUARD_INTERVAL=1 run timeout 5s "$SCRIPT_DIR/starter" "$TEST_DIR/bin" "duplicate-server" "" "$TEST_DIR/test-server.conf" "" "" 0 + kill "$duplicate_pid" 2>/dev/null || true + wait "$duplicate_pid" 2>/dev/null || true + + debug_on_failure + [ "$status" -eq 0 ] + [[ "$output" =~ "Startup guard: refusing to start duplicate duplicate-server" ]] + [[ "$output" =~ "existing matching process" ]] +} + +@test "starter: should refuse occupied configured worldserver ports" { + if ! command -v nc >/dev/null 2>&1; then + skip "nc is required for this test" + fi + + cat > "$TEST_DIR/bin/worldserver" << 'EOF' +#!/usr/bin/env bash +echo "worldserver should not start" +EOF + chmod +x "$TEST_DIR/bin/worldserver" + + local listen_port=40123 + cat > "$TEST_DIR/worldserver.conf" << EOF +WorldServerPort = $listen_port +SOAP.Enabled = 0 +EOF + nc -l 127.0.0.1 "$listen_port" >/dev/null & + local listener_pid=$! + + AC_STARTUP_GUARD_TIMEOUT=1 AC_STARTUP_GUARD_INTERVAL=1 run timeout 5s "$SCRIPT_DIR/starter" "$TEST_DIR/bin" "worldserver" "" "$TEST_DIR/worldserver.conf" "" "" 0 + kill "$listener_pid" 2>/dev/null || true + wait "$listener_pid" 2>/dev/null || true + + debug_on_failure + [ "$status" -eq 0 ] + [[ "$output" =~ "Startup guard: refusing to start duplicate worldserver" ]] + [[ "$output" =~ "configured port(s) still listening: $listen_port" ]] +} + +@test "starter: should parse config keys literally for startup guard" { + if ! command -v nc >/dev/null 2>&1; then + skip "nc is required for this test" + fi + + cat > "$TEST_DIR/bin/worldserver" << 'EOF' +#!/usr/bin/env bash +echo "worldserver started" +EOF + chmod +x "$TEST_DIR/bin/worldserver" + + local listen_port=40124 + cat > "$TEST_DIR/worldserver.conf" << EOF +WorldServerPort = 40125 +SOAPXEnabled = 1 +SOAPXPort = $listen_port +SOAP.Enabled = 0 +EOF + nc -l 127.0.0.1 "$listen_port" >/dev/null & + local listener_pid=$! + + AC_STARTUP_GUARD_TIMEOUT=1 AC_STARTUP_GUARD_INTERVAL=1 run timeout 5s "$SCRIPT_DIR/starter" "$TEST_DIR/bin" "worldserver" "" "$TEST_DIR/worldserver.conf" "" "" 0 + kill "$listener_pid" 2>/dev/null || true + wait "$listener_pid" 2>/dev/null || true + + debug_on_failure + [ "$status" -eq 0 ] + [[ "$output" =~ "worldserver started" ]] + [[ ! "$output" =~ "Startup guard: refusing" ]] +} + # ===== SIMPLE RESTARTER TESTS ===== @test "simple-restarter: should fail with missing parameters" {