mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
Fix hot-dev managed takeover
This commit is contained in:
parent
584aa8e5d4
commit
848ec63c0e
3 changed files with 288 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue