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:
Jiayuan Zhang
2026-09-08 18:54:25 +08:00
committed by GitHub
co-authored by Lambda multica-agent
parent 461479a9e1
commit 2ed2432a2e
2 changed files with 195 additions and 25 deletions
+80 -25
View File
@@ -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" ]; } \
+115
View File
@@ -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.
# ---------------------------------------------------------------------------