mirror of
https://github.com/multica-ai/multica.git
synced 2026-09-28 05:13:45 +08:00
fix(dev): recognize nested listener process groups (#8176)
Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Lambda
multica-agent
parent
461479a9e1
commit
2ed2432a2e
+80
-25
@@ -481,14 +481,52 @@ process_group_id() {
|
||||
ps -p "$1" -o pgid= 2>/dev/null | tr -d ' ' || true
|
||||
}
|
||||
|
||||
listener_belongs_to_component() {
|
||||
local component=$1 port=$2 launcher listener recorded
|
||||
process_parent_id() {
|
||||
ps -p "$1" -o ppid= 2>/dev/null | tr -d ' ' || true
|
||||
}
|
||||
|
||||
# Package runners may put a descendant in a nested process group (for example,
|
||||
# Turbo does this before Next binds its port). Follow PPIDs so ownership still
|
||||
# comes from the launcher tree instead of accepting a weaker command/user match.
|
||||
process_is_descendant_of() {
|
||||
local pid=$1 ancestor=$2 parent depth=0
|
||||
[ -n "$pid" ] && [ -n "$ancestor" ] || return 1
|
||||
while [ "$depth" -lt 64 ]; do
|
||||
[ "$pid" = "$ancestor" ] && return 0
|
||||
[ "$pid" != 1 ] || return 1
|
||||
parent="$(process_parent_id "$pid")"
|
||||
[ -n "$parent" ] && [ "$parent" != "$pid" ] || return 1
|
||||
pid="$parent"
|
||||
depth=$((depth + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
listener_pid_belongs_to_component() {
|
||||
local component=$1 listener=$2 launcher recorded
|
||||
launcher="$(component_pid "$component" || true)"
|
||||
listener="$(port_listener_pid "$port")"
|
||||
[ -n "$launcher" ] && [ -n "$listener" ] || return 1
|
||||
recorded="$(cat "$(listener_pid_file "$component")" 2>/dev/null || true)"
|
||||
[ -n "$recorded" ] && [ "$listener" = "$recorded" ] && return 0
|
||||
[ "$(process_group_id "$listener")" = "$launcher" ]
|
||||
[ "$(process_group_id "$listener")" = "$launcher" ] && return 0
|
||||
process_is_descendant_of "$listener" "$launcher"
|
||||
}
|
||||
|
||||
listener_belongs_to_component() {
|
||||
local component=$1 port=$2 listener
|
||||
listener="$(port_listener_pid "$port")"
|
||||
listener_pid_belongs_to_component "$component" "$listener"
|
||||
}
|
||||
|
||||
# Persist only listeners whose live process tree proves component ownership.
|
||||
# stop_component can then clean up a nested process group after its launcher has
|
||||
# exited and the listener has been reparented.
|
||||
record_component_listener() {
|
||||
local component=$1 port=$2 listener
|
||||
listener="$(port_listener_pid "$port")"
|
||||
listener_pid_belongs_to_component "$component" "$listener" || return 1
|
||||
printf '%s\n' "$listener" > "$(listener_pid_file "$component")"
|
||||
printf '%s' "$listener"
|
||||
}
|
||||
|
||||
health_belongs_to_api() {
|
||||
@@ -513,6 +551,8 @@ start_api() {
|
||||
expected_commit="$(checkout_commit)"
|
||||
if health="$(health_json)" && [ -n "$health" ] && component_pid api >/dev/null; then
|
||||
if api_identity_matches "$health" "$expected_commit"; then
|
||||
record_component_listener api "$BACKEND_PORT" >/dev/null \
|
||||
|| die "The API listener changed while its identity was being verified. Refusing to reuse it."
|
||||
ok "api already running on :$BACKEND_PORT (pid $(json_field "$health" pid), commit $expected_commit)"
|
||||
return 0
|
||||
fi
|
||||
@@ -541,6 +581,10 @@ Run 'make down' here first — a leftover instance answers /health with 200 and
|
||||
stop_component api
|
||||
die "Something else is serving :$BACKEND_PORT, or the launched api did not report pid/commit/started_at for commit $expected_commit."
|
||||
fi
|
||||
if ! record_component_listener api "$BACKEND_PORT" >/dev/null; then
|
||||
stop_component api
|
||||
die "The API listener changed while its identity was being recorded."
|
||||
fi
|
||||
ok "api healthy at http://localhost:$BACKEND_PORT (pid $(json_field "$health" pid), commit $expected_commit)"
|
||||
return 0
|
||||
fi
|
||||
@@ -553,10 +597,12 @@ Run 'make down' here first — a leftover instance answers /health with 200 and
|
||||
|
||||
start_web() {
|
||||
local waited=0 listener
|
||||
if curl -sf --max-time 15 "http://localhost:${FRONTEND_PORT}" >/dev/null 2>&1 \
|
||||
&& listener_belongs_to_component web "$FRONTEND_PORT"; then
|
||||
ok "web already running on :$FRONTEND_PORT"
|
||||
return 0
|
||||
if curl -sf --max-time 15 "http://localhost:${FRONTEND_PORT}" >/dev/null 2>&1; then
|
||||
listener="$(record_component_listener web "$FRONTEND_PORT" || true)"
|
||||
if [ -n "$listener" ]; then
|
||||
ok "web already running on :$FRONTEND_PORT (pid $listener)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if ! port_free "$FRONTEND_PORT"; then
|
||||
die "Port $FRONTEND_PORT is busy: $(describe_port_owner "$FRONTEND_PORT"). Run 'make down' here first."
|
||||
@@ -567,10 +613,10 @@ start_web() {
|
||||
|
||||
while [ "$waited" -lt 300 ]; do
|
||||
if curl -sf --max-time 15 "http://localhost:${FRONTEND_PORT}" >/dev/null 2>&1; then
|
||||
listener="$(port_listener_pid "$FRONTEND_PORT")"
|
||||
if ! listener_belongs_to_component web "$FRONTEND_PORT"; then
|
||||
listener="$(record_component_listener web "$FRONTEND_PORT" || true)"
|
||||
if [ -z "$listener" ]; then
|
||||
stop_component web
|
||||
die "Web on :$FRONTEND_PORT is not owned by the process group this environment launched."
|
||||
die "Web on :$FRONTEND_PORT is not descended from the process this environment launched."
|
||||
fi
|
||||
ok "web serving http://localhost:$FRONTEND_PORT (pid ${listener:-?})"
|
||||
return 0
|
||||
@@ -712,7 +758,9 @@ start_desktop() {
|
||||
&& curl -sf --max-time 10 "http://localhost:${DESKTOP_RENDERER_PORT}" >/dev/null 2>&1 \
|
||||
&& listener_belongs_to_component desktop "$DESKTOP_RENDERER_PORT" \
|
||||
&& desktop_env_matches; then
|
||||
ok "desktop already running (pid $(component_pid desktop), renderer :$DESKTOP_RENDERER_PORT)"
|
||||
listener="$(record_component_listener desktop "$DESKTOP_RENDERER_PORT" || true)"
|
||||
[ -n "$listener" ] || die "The Desktop listener changed while its identity was being recorded."
|
||||
ok "desktop already running (pid $(component_pid desktop), renderer $listener on :$DESKTOP_RENDERER_PORT)"
|
||||
return 0
|
||||
fi
|
||||
if component_pid desktop >/dev/null; then
|
||||
@@ -738,7 +786,7 @@ EOF
|
||||
while [ "$waited" -lt 300 ]; do
|
||||
if curl -sf --max-time 10 "http://localhost:${DESKTOP_RENDERER_PORT}" >/dev/null 2>&1; then
|
||||
listener="$(port_listener_pid "$DESKTOP_RENDERER_PORT")"
|
||||
if ! component_pid desktop >/dev/null || [ -z "$listener" ] || ! desktop_env_matches; then
|
||||
if ! listener_pid_belongs_to_component desktop "$listener" || ! desktop_env_matches; then
|
||||
stop_component desktop
|
||||
die "Desktop renderer on :$DESKTOP_RENDERER_PORT does not belong to this environment."
|
||||
fi
|
||||
@@ -773,7 +821,7 @@ desktop_env_matches() {
|
||||
}
|
||||
|
||||
stop_component() {
|
||||
local name=$1 pid launcher="" status state recorded_listener=""
|
||||
local name=$1 pid launcher="" status state recorded_listener="" port="" listener=""
|
||||
case "$name" in
|
||||
daemon)
|
||||
if [ -x "$MULTICA_BIN" ]; then
|
||||
@@ -802,10 +850,25 @@ stop_component() {
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$name" in
|
||||
api) port="$BACKEND_PORT" ;;
|
||||
web) port="$FRONTEND_PORT" ;;
|
||||
desktop) port="$DESKTOP_RENDERER_PORT" ;;
|
||||
esac
|
||||
recorded_listener="$(cat "$(listener_pid_file "$name")" 2>/dev/null || true)"
|
||||
pid="$(component_pid "$name" || true)"
|
||||
if [ -n "$pid" ]; then
|
||||
launcher="$pid"
|
||||
# Capture an older environment's listener before killing the launcher. A
|
||||
# nested process group may survive that signal and then lose its PPID chain
|
||||
# when the launcher exits, so it must be proven and recorded first.
|
||||
if [ -n "$port" ]; then
|
||||
listener="$(port_listener_pid "$port")"
|
||||
if [ -n "$listener" ] && listener_pid_belongs_to_component "$name" "$listener"; then
|
||||
recorded_listener="$listener"
|
||||
printf '%s\n' "$listener" > "$(listener_pid_file "$name")"
|
||||
fi
|
||||
fi
|
||||
# Negative pid targets the process group, so make → go run → server all go
|
||||
# down together instead of leaving the real listener orphaned.
|
||||
kill -TERM -"$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
|
||||
@@ -825,18 +888,10 @@ stop_component() {
|
||||
info "$name was not running"
|
||||
fi
|
||||
|
||||
# A process group kill can miss a listener that has reparented away from its
|
||||
# launcher. Only kill that listener when its process group still proves it
|
||||
# belongs to the recorded launcher; a stale manifest must never kill an
|
||||
# unrelated process that later reused the port.
|
||||
local port=""
|
||||
case "$name" in
|
||||
api) port="$BACKEND_PORT" ;;
|
||||
web) port="$FRONTEND_PORT" ;;
|
||||
desktop) port="$DESKTOP_RENDERER_PORT" ;;
|
||||
esac
|
||||
# A process group kill can miss a nested listener that has reparented away
|
||||
# from its launcher. Only kill the listener captured above or one still in
|
||||
# the launcher's direct process group; never infer ownership from the port.
|
||||
if [ -n "$port" ]; then
|
||||
local listener
|
||||
listener="$(port_listener_pid "$port")"
|
||||
if [ -n "$listener" ]; then
|
||||
if { [ -n "$recorded_listener" ] && [ "$listener" = "$recorded_listener" ]; } \
|
||||
|
||||
@@ -71,6 +71,107 @@ EOF
|
||||
|
||||
out="$tmp_dir/out"
|
||||
|
||||
assert_listener_ownership() {
|
||||
local case_name=$1 expected=$2 launcher=$3 listener=$4 listener_pgid=$5 recorded=${6:-}
|
||||
(
|
||||
# shellcheck disable=SC1090
|
||||
source "$root_dir/scripts/dev-env.sh"
|
||||
STATE_DIR="$tmp_dir/ownership-$case_name"
|
||||
mkdir -p "$STATE_DIR"
|
||||
[ -z "$recorded" ] || printf '%s\n' "$recorded" > "$(listener_pid_file web)"
|
||||
TEST_LAUNCHER=$launcher
|
||||
TEST_LISTENER=$listener
|
||||
TEST_LISTENER_PGID=$listener_pgid
|
||||
TEST_CASE=$case_name
|
||||
|
||||
component_pid() { printf '%s' "$TEST_LAUNCHER"; }
|
||||
port_listener_pid() { printf '%s' "$TEST_LISTENER"; }
|
||||
process_group_id() { printf '%s' "$TEST_LISTENER_PGID"; }
|
||||
process_parent_id() {
|
||||
case "$TEST_CASE:$1" in
|
||||
nested:420) printf '310' ;;
|
||||
nested:310) printf '200' ;;
|
||||
nested:200) printf '%s' "$TEST_LAUNCHER" ;;
|
||||
*) printf '1' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
local actual=external
|
||||
if listener_belongs_to_component web 13000; then actual=owned; fi
|
||||
[ "$actual" = "$expected" ] \
|
||||
|| fail "$case_name listener ownership = $actual, want $expected"
|
||||
)
|
||||
}
|
||||
|
||||
assert_nested_listener_is_recorded() (
|
||||
# shellcheck disable=SC1090
|
||||
source "$root_dir/scripts/dev-env.sh"
|
||||
STATE_DIR="$tmp_dir/ownership-record"
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
component_pid() { printf '100'; }
|
||||
port_listener_pid() { printf '420'; }
|
||||
process_group_id() { printf '310'; }
|
||||
process_parent_id() {
|
||||
case "$1" in
|
||||
420) printf '310' ;;
|
||||
310) printf '200' ;;
|
||||
200) printf '100' ;;
|
||||
*) printf '1' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
local claimed
|
||||
claimed="$(record_component_listener web 13000)" \
|
||||
|| fail "nested listener was not claimed"
|
||||
[ "$claimed" = 420 ] || fail "claimed listener = $claimed, want 420"
|
||||
[ "$(cat "$(listener_pid_file web)")" = 420 ] \
|
||||
|| fail "nested listener pid was not recorded"
|
||||
)
|
||||
|
||||
assert_stop_handles_listener() {
|
||||
local case_name=$1 listener=$2 listener_pgid=$3 listener_parent=$4 recorded=${5:-}
|
||||
local expected_target=${6:-}
|
||||
(
|
||||
# shellcheck disable=SC1090
|
||||
source "$root_dir/scripts/dev-env.sh"
|
||||
STATE_DIR="$tmp_dir/stop-$case_name"
|
||||
mkdir -p "$STATE_DIR"
|
||||
BACKEND_PORT=18080
|
||||
FRONTEND_PORT=13000
|
||||
DESKTOP_RENDERER_PORT=5174
|
||||
local signals="$STATE_DIR/signals"
|
||||
[ -z "$recorded" ] || printf '%s\n' "$recorded" > "$(listener_pid_file web)"
|
||||
TEST_CASE=$case_name
|
||||
TEST_LISTENER=$listener
|
||||
TEST_LISTENER_PGID=$listener_pgid
|
||||
TEST_LISTENER_PARENT=$listener_parent
|
||||
|
||||
component_pid() { [ "$TEST_CASE" = nested ] && printf '100'; }
|
||||
port_listener_pid() { printf '%s' "$TEST_LISTENER"; }
|
||||
process_group_id() { printf '%s' "$TEST_LISTENER_PGID"; }
|
||||
process_parent_id() {
|
||||
case "$1" in
|
||||
"$TEST_LISTENER") printf '%s' "$TEST_LISTENER_PARENT" ;;
|
||||
"$TEST_LISTENER_PARENT") printf '100' ;;
|
||||
*) printf '1' ;;
|
||||
esac
|
||||
}
|
||||
sleep() { :; }
|
||||
kill() {
|
||||
[ "$1" != -0 ] || return 1
|
||||
printf 'signal=%s target=%s\n' "$1" "${2:-}" >> "$signals"
|
||||
}
|
||||
|
||||
stop_component web > "$out" 2>&1 || fail "$case_name stop failed"
|
||||
if [ -n "$expected_target" ]; then
|
||||
require_contains "$signals" "target=$expected_target"
|
||||
elif [ -s "$signals" ]; then
|
||||
fail "$case_name stop signalled an external listener: $(cat "$signals")"
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# An empty registry is a normal state, not an error.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -174,6 +275,20 @@ if bash -c 'source "$1"; api_started_after '\''{"status":"ok"}'\'' 1' _ "$root_d
|
||||
fail "legacy /health without started_at was accepted as current"
|
||||
fi
|
||||
|
||||
# Listener ownership follows the process tree, not only the launcher's process
|
||||
# group. Turbo/pnpm can create a nested process group for Next while keeping the
|
||||
# listener below the launcher in the PPID chain.
|
||||
assert_listener_ownership same-pgid owned 100 200 100
|
||||
assert_listener_ownership nested owned 100 420 310
|
||||
assert_listener_ownership recorded owned 100 200 200 200
|
||||
assert_listener_ownership external external 100 999 999
|
||||
assert_nested_listener_is_recorded
|
||||
|
||||
# Stopping first records an owned nested listener before killing the launcher's
|
||||
# process group. An unrelated port occupant never receives a signal.
|
||||
assert_stop_handles_listener nested 420 310 200 "" 420
|
||||
assert_stop_handles_listener external 999 999 1 888
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unknown names and components fail loudly instead of doing something else.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user