diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 00234aa7e..eeed0f56c 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 diff --git a/scripts/hot-dev-bg.sh b/scripts/hot-dev-bg.sh index 55075c412..7168b3423 100755 --- a/scripts/hot-dev-bg.sh +++ b/scripts/hot-dev-bg.sh @@ -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 diff --git a/scripts/tests/test-hot-dev-bg.sh b/scripts/tests/test-hot-dev-bg.sh index 025e96e12..9a12d4c11 100755 --- a/scripts/tests/test-hot-dev-bg.sh +++ b/scripts/tests/test-hot-dev-bg.sh @@ -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