Fix hot-dev managed takeover

This commit is contained in:
rcourtman 2026-03-24 12:00:19 +00:00
parent 584aa8e5d4
commit 848ec63c0e
3 changed files with 288 additions and 22 deletions

View file

@ -134,6 +134,22 @@ to be listening.
Changes to `scripts/hot-dev.sh` and `scripts/hot-dev-bg.sh` must therefore
stay on their own direct dev-runtime orchestration proof path instead of
piggybacking on installer proof coverage for unrelated deployment scripts.
That same launcher boundary also owns its CLI contract: managed commands such
as `start --takeover` and `restart --takeover` must preserve the takeover flag
through the actual script entrypoint instead of silently dropping second-arg
control flow and falling back to refusal behavior that contradicts the command
the operator just ran.
That takeover contract also has to reclaim the old dev runtime, not merely
launch another wrapper beside it. When takeover is requested, the launcher
must stop the prior port-owning hot-dev session or direct listeners before the
new managed session starts, otherwise stale watchers can immediately respawn
on `5173` or `7655` and leave split ownership behind.
On macOS that same takeover boundary also includes the optional
`com.pulse.hot-dev` LaunchAgent installed by the local dev launchd helper:
managed takeover must surface that competing job in diagnostics and boot it
out before starting a new managed session, otherwise launchd can immediately
recreate the legacy `0.0.0.0` dev runtime beside the managed `127.0.0.1`
session.
That shared `scripts/install.sh` boundary must also keep one canonical service
argument builder for the runtime flags it persists. Token-bearing install
paths, token-file systemd paths, wrapper-script launches, and later service

View file

@ -14,8 +14,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PID_FILE="${ROOT_DIR}/tmp/hot-dev.bg.pid"
LOG_FILE="${ROOT_DIR}/tmp/hot-dev.bg.log"
PID_FILE="${HOT_DEV_BG_PID_FILE:-${ROOT_DIR}/tmp/hot-dev.bg.pid}"
LOG_FILE="${HOT_DEV_BG_LOG_FILE:-${ROOT_DIR}/tmp/hot-dev.bg.log}"
FRONTEND_DEV_HOST="${FRONTEND_DEV_HOST:-127.0.0.1}"
FRONTEND_DEV_PORT="${FRONTEND_DEV_PORT:-5173}"
@ -23,6 +23,7 @@ PULSE_DEV_API_HOST="${PULSE_DEV_API_HOST:-127.0.0.1}"
PULSE_DEV_API_PORT="${PULSE_DEV_API_PORT:-7655}"
PULSE_DEV_API_URL="${PULSE_DEV_API_URL:-http://${PULSE_DEV_API_HOST}:${PULSE_DEV_API_PORT}}"
PULSE_DEV_WS_URL="${PULSE_DEV_WS_URL:-ws://${PULSE_DEV_API_HOST}:${PULSE_DEV_API_PORT}}"
MACOS_HOT_DEV_LABEL="com.pulse.hot-dev"
log() {
printf "[hot-dev-bg] %s\n" "$*"
@ -66,16 +67,57 @@ process_command() {
ps -o command= -p "${pid}" 2>/dev/null | sed 's/^[[:space:]]*//'
}
listener_is_managed() {
process_parent_id() {
local pid="$1"
ps -o ppid= -p "${pid}" 2>/dev/null | tr -d '[:space:]'
}
pid_is_managed() {
local pid="$1"
local session_pid="$2"
local current_pid="${pid}"
local parent_pid
[[ -n "${session_pid}" ]] || return 1
while [[ -n "${current_pid}" && "${current_pid}" != "1" ]]; do
if [[ "${current_pid}" == "${session_pid}" ]]; then
return 0
fi
if [[ "$(process_group_id "${current_pid}")" == "${session_pid}" ]]; then
return 0
fi
parent_pid="$(process_parent_id "${current_pid}")"
[[ -n "${parent_pid}" && "${parent_pid}" != "${current_pid}" ]] || break
current_pid="${parent_pid}"
done
return 1
}
port_has_managed_listener() {
local port="$1"
local session_pid="$2"
local listener_pid
[[ -n "${session_pid}" ]] || return 1
while IFS= read -r listener_pid; do
[[ -n "${listener_pid}" ]] || continue
if pid_is_managed "${listener_pid}" "${session_pid}"; then
return 0
fi
done < <(listener_pids "${port}")
return 1
}
port_has_unmanaged_listener() {
local port="$1"
local session_pid="$2"
local listener_pid
while IFS= read -r listener_pid; do
[[ -n "${listener_pid}" ]] || continue
if [[ "$(process_group_id "${listener_pid}")" == "${session_pid}" ]]; then
if ! pid_is_managed "${listener_pid}" "${session_pid}"; then
return 0
fi
done < <(listener_pids "${port}")
@ -90,7 +132,7 @@ wait_for_managed_listener() {
local checks=$((timeout_seconds * 2))
while (( checks > 0 )); do
if listener_is_managed "${port}" "${session_pid}"; then
if port_has_managed_listener "${port}" "${session_pid}"; then
return 0
fi
sleep 0.5
@ -121,7 +163,7 @@ describe_listener() {
found=1
pgid="$(process_group_id "${listener_pid}")"
owner="unmanaged"
if [[ -n "${session_pid}" && "${pgid}" == "${session_pid}" ]]; then
if pid_is_managed "${listener_pid}" "${session_pid}"; then
owner="managed"
fi
command="$(process_command "${listener_pid}")"
@ -151,7 +193,7 @@ has_unmanaged_listeners() {
local port
for port in "${FRONTEND_DEV_PORT}" "${PULSE_DEV_API_PORT}"; do
if has_any_listener "${port}" && ! listener_is_managed "${port}" "${session_pid}"; then
if has_any_listener "${port}" && port_has_unmanaged_listener "${port}" "${session_pid}"; then
return 0
fi
done
@ -159,6 +201,128 @@ has_unmanaged_listeners() {
return 1
}
find_hot_dev_ancestor() {
local pid="$1"
local current_pid="${pid}"
local command parent_pid
while [[ -n "${current_pid}" && "${current_pid}" != "1" ]]; do
command="$(process_command "${current_pid}")"
if [[ "${command}" == *"/scripts/hot-dev.sh"* || "${command}" == "bash scripts/hot-dev.sh" ]]; then
printf "%s\n" "${current_pid}"
return 0
fi
parent_pid="$(process_parent_id "${current_pid}")"
[[ -n "${parent_pid}" && "${parent_pid}" != "${current_pid}" ]] || break
current_pid="${parent_pid}"
done
return 1
}
hot_dev_root_pids() {
pgrep -f '(^|/)(hot-dev\.sh|bash scripts/hot-dev\.sh)$|/scripts/hot-dev\.sh' 2>/dev/null | sort -u
}
stop_hot_dev_sessions() {
local signal="${1:-TERM}"
local root_pid root_pgid root_command target_key
declare -A seen=()
while IFS= read -r root_pid; do
[[ -n "${root_pid}" ]] || continue
root_pgid="$(process_group_id "${root_pid}")"
if [[ -n "${root_pgid}" ]]; then
target_key="pgid:${root_pgid}"
[[ -z "${seen[${target_key}]:-}" ]] || continue
seen["${target_key}"]=1
root_command="$(process_command "${root_pid}")"
log "Takeover sending ${signal} to hot-dev session group ${root_pgid} rooted at pid=${root_pid} cmd=${root_command:-unknown}"
kill "-${signal}" "-${root_pgid}" 2>/dev/null || kill "-${signal}" "${root_pid}" 2>/dev/null || true
continue
fi
target_key="pid:${root_pid}"
[[ -z "${seen[${target_key}]:-}" ]] || continue
seen["${target_key}"]=1
root_command="$(process_command "${root_pid}")"
log "Takeover sending ${signal} to hot-dev root pid=${root_pid} cmd=${root_command:-unknown}"
kill "-${signal}" "${root_pid}" 2>/dev/null || true
done < <(hot_dev_root_pids)
}
launchd_hot_dev_target() {
printf "gui/%s/%s\n" "$(id -u)" "${MACOS_HOT_DEV_LABEL}"
}
launchd_hot_dev_active() {
[[ "$(uname -s)" == "Darwin" ]] || return 1
launchctl print "$(launchd_hot_dev_target)" >/dev/null 2>&1
}
stop_launchd_hot_dev_job() {
launchd_hot_dev_active || return 0
local target
target="$(launchd_hot_dev_target)"
log "Takeover booting out launchd job ${MACOS_HOT_DEV_LABEL}"
launchctl bootout "${target}" >/dev/null 2>&1 || launchctl remove "${MACOS_HOT_DEV_LABEL}" >/dev/null 2>&1 || true
}
stop_takeover_targets() {
local signal="${1:-TERM}"
local port listener_pid ancestor_pid target_key target_pid target_pgid target_command
declare -A seen=()
for port in "${FRONTEND_DEV_PORT}" "${PULSE_DEV_API_PORT}"; do
while IFS= read -r listener_pid; do
[[ -n "${listener_pid}" ]] || continue
ancestor_pid="$(find_hot_dev_ancestor "${listener_pid}" || true)"
if [[ -n "${ancestor_pid}" ]]; then
target_pgid="$(process_group_id "${ancestor_pid}")"
[[ -n "${target_pgid}" ]] || continue
target_key="pgid:${target_pgid}"
[[ -z "${seen[${target_key}]:-}" ]] || continue
seen["${target_key}"]=1
target_command="$(process_command "${ancestor_pid}")"
log "Takeover sending ${signal} to hot-dev process group ${target_pgid} rooted at pid=${ancestor_pid} cmd=${target_command:-unknown}"
kill "-${signal}" "-${target_pgid}" 2>/dev/null || kill "-${signal}" "${ancestor_pid}" 2>/dev/null || true
continue
fi
target_key="pid:${listener_pid}"
[[ -z "${seen[${target_key}]:-}" ]] || continue
seen["${target_key}"]=1
target_command="$(process_command "${listener_pid}")"
log "Takeover sending ${signal} to listener pid=${listener_pid} cmd=${target_command:-unknown}"
kill "-${signal}" "${listener_pid}" 2>/dev/null || true
done < <(listener_pids "${port}")
done
}
wait_for_ports_to_clear() {
local timeout_seconds="${1:-10}"
local checks=$((timeout_seconds * 2))
local port
while (( checks > 0 )); do
local any_listeners="false"
for port in "${FRONTEND_DEV_PORT}" "${PULSE_DEV_API_PORT}"; do
if has_any_listener "${port}"; then
any_listeners="true"
break
fi
done
if [[ "${any_listeners}" == "false" ]]; then
return 0
fi
sleep 0.5
checks=$((checks - 1))
done
return 1
}
require_python() {
command -v python3 >/dev/null 2>&1 || fail "python3 is required"
}
@ -187,6 +351,20 @@ start_bg() {
log "Taking over existing unmanaged listeners on the dev ports."
describe_listener "${FRONTEND_DEV_PORT}" "" || true
describe_listener "${PULSE_DEV_API_PORT}" "" || true
stop_launchd_hot_dev_job
stop_hot_dev_sessions TERM
stop_takeover_targets TERM
if ! wait_for_ports_to_clear 10; then
log "Takeover escalation: dev ports are still occupied after TERM."
stop_hot_dev_sessions KILL
stop_takeover_targets KILL
if ! wait_for_ports_to_clear 5; then
log "Remaining listeners after takeover escalation:"
describe_listener "${FRONTEND_DEV_PORT}" "" || true
describe_listener "${PULSE_DEV_API_PORT}" "" || true
fail "Unable to reclaim the dev ports for managed takeover"
fi
fi
fi
local pid
@ -314,6 +492,10 @@ status_bg() {
describe_listener "${FRONTEND_DEV_PORT}" "${managed_pid}" || true
describe_listener "${PULSE_DEV_API_PORT}" "${managed_pid}" || true
if launchd_hot_dev_active; then
log "LaunchAgent ${MACOS_HOT_DEV_LABEL} is active"
fi
if [[ -z "${managed_pid}" ]] && has_unmanaged_listeners ""; then
log "Detected unmanaged dev listeners. hot-dev-bg is not managing the current runtime."
elif [[ -n "${managed_pid}" ]] && has_unmanaged_listeners "${managed_pid}"; then
@ -351,29 +533,49 @@ Commands:
EOF
}
main() {
parse_takeover_flag() {
local command="${1:-}"
local flag="${2:-}"
local takeover="false"
if [[ "${flag}" == "--takeover" ]]; then
takeover="true"
fi
case "${command}" in
start|restart)
if [[ -z "${flag}" ]]; then
printf "false\n"
return 0
fi
if [[ "${flag}" == "--takeover" ]]; then
printf "true\n"
return 0
fi
usage
return 1
;;
stop|status|logs)
if [[ -n "${flag}" ]]; then
usage
return 1
fi
printf "false\n"
return 0
;;
*)
printf "false\n"
return 0
;;
esac
}
main() {
local command="${1:-}"
local takeover
takeover="$(parse_takeover_flag "$@")" || exit 1
case "${command}" in
start)
if [[ -n "${flag}" && "${flag}" != "--takeover" ]]; then
usage
exit 1
fi
start_bg "${takeover}"
;;
stop) stop_bg ;;
restart)
if [[ -n "${flag}" && "${flag}" != "--takeover" ]]; then
usage
exit 1
fi
stop_bg
start_bg "${takeover}"
;;
@ -383,4 +585,6 @@ main() {
esac
}
main "${1:-}"
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi

View file

@ -14,12 +14,17 @@ fi
failures=0
server_pids=()
temp_dirs=()
cleanup() {
local pid
for pid in "${server_pids[@]:-}"; do
kill "${pid}" 2>/dev/null || true
done
local dir
for dir in "${temp_dirs[@]:-}"; do
rm -rf "${dir}" 2>/dev/null || true
done
}
trap cleanup EXIT
@ -56,15 +61,26 @@ start_http_server() {
sleep 1
}
make_isolated_hot_dev_bg_state() {
local dir
dir="$(mktemp -d)"
temp_dirs+=("${dir}")
printf "%s\n" "${dir}"
}
test_status_without_runtime() {
local frontend_port backend_port output
local state_dir
frontend_port="$(pick_free_port)"
backend_port="$(pick_free_port)"
if [[ "${backend_port}" == "${frontend_port}" ]]; then
backend_port="$(pick_free_port)"
fi
state_dir="$(make_isolated_hot_dev_bg_state)"
output="$(
HOT_DEV_BG_PID_FILE="${state_dir}/hot-dev-bg.pid" \
HOT_DEV_BG_LOG_FILE="${state_dir}/hot-dev-bg.log" \
FRONTEND_DEV_HOST=127.0.0.1 \
FRONTEND_DEV_PORT="${frontend_port}" \
PULSE_DEV_API_HOST=127.0.0.1 \
@ -79,18 +95,45 @@ test_status_without_runtime() {
assert_contains "status reports proxy health probe" "${output}" "[hot-dev-bg] Frontend proxy /api/health: 000"
}
test_cli_parses_takeover_flag() {
local output
output="$(
HOT_DEV_BG_PATH="${HOT_DEV_BG}" \
bash -lc '
source "${HOT_DEV_BG_PATH}"
printf "start=%s\n" "$(parse_takeover_flag start --takeover)"
printf "restart=%s\n" "$(parse_takeover_flag restart --takeover)"
printf "plain=%s\n" "$(parse_takeover_flag start)"
if parse_takeover_flag status --takeover >/tmp/hot-dev-bg.invalid 2>&1; then
printf "invalid=accepted\n"
else
printf "invalid=rejected\n"
fi
'
)"
assert_contains "takeover parsing enables start" "${output}" "start=true"
assert_contains "takeover parsing enables restart" "${output}" "restart=true"
assert_contains "start without flag stays false" "${output}" "plain=false"
assert_contains "unexpected status flag is rejected" "${output}" "invalid=rejected"
}
test_detects_unmanaged_listeners() {
local frontend_port backend_port status_output start_output
local state_dir
frontend_port="$(pick_free_port)"
backend_port="$(pick_free_port)"
if [[ "${backend_port}" == "${frontend_port}" ]]; then
backend_port="$(pick_free_port)"
fi
state_dir="$(make_isolated_hot_dev_bg_state)"
start_http_server "${frontend_port}"
start_http_server "${backend_port}"
status_output="$(
HOT_DEV_BG_PID_FILE="${state_dir}/hot-dev-bg.pid" \
HOT_DEV_BG_LOG_FILE="${state_dir}/hot-dev-bg.log" \
FRONTEND_DEV_HOST=127.0.0.1 \
FRONTEND_DEV_PORT="${frontend_port}" \
PULSE_DEV_API_HOST=127.0.0.1 \
@ -107,6 +150,8 @@ test_detects_unmanaged_listeners() {
start_output="$(
set +e
HOT_DEV_BG_PID_FILE="${state_dir}/hot-dev-bg.pid" \
HOT_DEV_BG_LOG_FILE="${state_dir}/hot-dev-bg.log" \
FRONTEND_DEV_HOST=127.0.0.1 \
FRONTEND_DEV_PORT="${frontend_port}" \
PULSE_DEV_API_HOST=127.0.0.1 \
@ -120,6 +165,7 @@ test_detects_unmanaged_listeners() {
}
main() {
test_cli_parses_takeover_flag
test_status_without_runtime
test_detects_unmanaged_listeners