diff --git a/scripts/hot-dev-bg.sh b/scripts/hot-dev-bg.sh index cf4baeccbe..10114f04a9 100755 --- a/scripts/hot-dev-bg.sh +++ b/scripts/hot-dev-bg.sh @@ -3,7 +3,7 @@ # Run Pulse hot-dev in a managed background session (macOS/Linux). # # Usage: -# ./scripts/hot-dev-bg.sh start +# ./scripts/hot-dev-bg.sh start [--takeover] # ./scripts/hot-dev-bg.sh stop # ./scripts/hot-dev-bg.sh restart # ./scripts/hot-dev-bg.sh status @@ -38,13 +38,46 @@ is_port_listening() { lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 } -wait_for_port() { +listener_pids() { local port="$1" - local timeout_seconds="${2:-60}" + lsof -nP -t -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null | sort -u +} + +process_group_id() { + local pid="$1" + ps -o pgid= -p "${pid}" 2>/dev/null | tr -d '[:space:]' +} + +process_command() { + local pid="$1" + ps -o command= -p "${pid}" 2>/dev/null | sed 's/^[[:space:]]*//' +} + +listener_is_managed() { + 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 [[ "$(process_group_id "${listener_pid}")" == "${session_pid}" ]]; then + return 0 + fi + done < <(listener_pids "${port}") + + return 1 +} + +wait_for_managed_listener() { + local port="$1" + local session_pid="$2" + local timeout_seconds="${3:-60}" local checks=$((timeout_seconds * 2)) while (( checks > 0 )); do - if is_port_listening "${port}"; then + if listener_is_managed "${port}" "${session_pid}"; then return 0 fi sleep 0.5 @@ -64,11 +97,61 @@ is_running() { kill -0 "${pid}" 2>/dev/null } +describe_listener() { + local port="$1" + local session_pid="${2:-}" + local found=0 + local listener_pid pgid owner command + + while IFS= read -r listener_pid; do + [[ -n "${listener_pid}" ]] || continue + found=1 + pgid="$(process_group_id "${listener_pid}")" + owner="unmanaged" + if [[ -n "${session_pid}" && "${pgid}" == "${session_pid}" ]]; then + owner="managed" + fi + command="$(process_command "${listener_pid}")" + log "Port ${port}: ${owner} listener pid=${listener_pid} pgid=${pgid:-unknown} cmd=${command:-unknown}" + done < <(listener_pids "${port}") + + if [[ "${found}" -eq 1 ]]; then + return 0 + fi + return 1 +} + +has_any_listener() { + local port="$1" + local listener_pid + + while IFS= read -r listener_pid; do + [[ -n "${listener_pid}" ]] || continue + return 0 + done < <(listener_pids "${port}") + + return 1 +} + +has_unmanaged_listeners() { + local session_pid="${1:-}" + 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 + return 0 + fi + done + + return 1 +} + require_python() { command -v python3 >/dev/null 2>&1 || fail "python3 is required" } start_bg() { + local takeover="${1:-false}" require_python mkdir -p "$(dirname "${PID_FILE}")" touch "${LOG_FILE}" @@ -81,6 +164,18 @@ start_bg() { # Clear stale PID file before fresh start. rm -f "${PID_FILE}" + if has_unmanaged_listeners ""; then + if [[ "${takeover}" != "true" ]]; then + log "Unmanaged listeners are already using the dev ports." + describe_listener "${FRONTEND_DEV_PORT}" "" || true + describe_listener "${PULSE_DEV_API_PORT}" "" || true + fail "Refusing to take over unmanaged listeners. Stop them first or rerun with: ./scripts/hot-dev-bg.sh start --takeover" + fi + log "Taking over existing unmanaged listeners on the dev ports." + describe_listener "${FRONTEND_DEV_PORT}" "" || true + describe_listener "${PULSE_DEV_API_PORT}" "" || true + fi + local pid pid="$( FRONTEND_DEV_HOST="${FRONTEND_DEV_HOST}" \ @@ -120,15 +215,15 @@ PY printf "%s\n" "${pid}" > "${PID_FILE}" log "Started hot-dev (pid: ${pid})" - log "Waiting for backend port ${PULSE_DEV_API_PORT}..." - if ! wait_for_port "${PULSE_DEV_API_PORT}" 90; then + log "Waiting for managed backend listener on port ${PULSE_DEV_API_PORT}..." + if ! wait_for_managed_listener "${PULSE_DEV_API_PORT}" "${pid}" 90; then log "Backend did not start. Showing recent logs:" tail -n 80 "${LOG_FILE}" || true fail "hot-dev startup failed" fi - if ! wait_for_port "${FRONTEND_DEV_PORT}" 90; then - log "Backend is up, but frontend port ${FRONTEND_DEV_PORT} is not listening yet." + if ! wait_for_managed_listener "${FRONTEND_DEV_PORT}" "${pid}" 90; then + log "Backend is up, but a managed frontend listener on port ${FRONTEND_DEV_PORT} is not ready yet." log "Check logs with: ./scripts/hot-dev-bg.sh logs" fi @@ -168,10 +263,10 @@ stop_bg() { } status_bg() { + local managed_pid="" if is_running; then - local pid - pid="$(cat "${PID_FILE}")" - log "Running (pid: ${pid})" + managed_pid="$(cat "${PID_FILE}")" + log "Running (pid: ${managed_pid})" else log "Not running" fi @@ -195,6 +290,15 @@ status_bg() { [[ -n "${backend_code}" ]] || backend_code="000" log "Frontend HTTP: ${frontend_code}" log "Backend HTTP: ${backend_code}" + + describe_listener "${FRONTEND_DEV_PORT}" "${managed_pid}" || true + describe_listener "${PULSE_DEV_API_PORT}" "${managed_pid}" || true + + 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 + log "Detected split ownership: managed session is running, but one or more dev ports are owned by another process." + fi } logs_bg() { @@ -207,7 +311,7 @@ usage() { Usage: $(basename "$0") Commands: - start + start [--takeover] stop restart status @@ -216,10 +320,31 @@ EOF } main() { - case "${1:-}" in - start) start_bg ;; + local command="${1:-}" + local flag="${2:-}" + local takeover="false" + + if [[ "${flag}" == "--takeover" ]]; then + takeover="true" + fi + + case "${command}" in + start) + if [[ -n "${flag}" && "${flag}" != "--takeover" ]]; then + usage + exit 1 + fi + start_bg "${takeover}" + ;; stop) stop_bg ;; - restart) stop_bg; start_bg ;; + restart) + if [[ -n "${flag}" && "${flag}" != "--takeover" ]]; then + usage + exit 1 + fi + stop_bg + start_bg "${takeover}" + ;; status) status_bg ;; logs) logs_bg ;; *) usage; exit 1 ;; diff --git a/scripts/hot-dev.sh b/scripts/hot-dev.sh index 938b332604..b79f5064ec 100755 --- a/scripts/hot-dev.sh +++ b/scripts/hot-dev.sh @@ -10,6 +10,7 @@ # Environment Variables: # HOT_DEV_USE_PROD_DATA=true Use /etc/pulse for data (sessions, config, etc.) # HOT_DEV_USE_PRO=true Build Pro binary (default: true if module available) +# HOT_DEV_ALLOW_OSS_FALLBACK=true Allow fallback to OSS backend when Pro build fails # PULSE_MOCK_MODE=true Render mock UI data while keeping real metrics history intact # PULSE_DATA_DIR=/path Override data directory # PULSE_DEV_API_PORT=7655 Backend API port (default: 7655) @@ -70,6 +71,41 @@ check_dependencies() { fi } +build_backend_binary() { + local build_output + PULSE_REPOS_DIR="${PULSE_REPOS_DIR:-${DEFAULT_PULSE_REPOS_DIR}}" + PRO_MODULE_DIR="${PULSE_REPOS_DIR}/pulse-enterprise" + + if [[ -d "${PRO_MODULE_DIR}" ]] && [[ ${HOT_DEV_USE_PRO:-true} == "true" ]]; then + log_info "Building Pro binary (includes persistent audit logging)..." + if build_output=$(cd "${PRO_MODULE_DIR}" && go build -buildvcs=false -o "${ROOT_DIR}/pulse" ./cmd/pulse-enterprise 2>&1); then + printf "%s\n" "${build_output}" | grep -v "^#" || true + PRO_BUILD_SUCCESS=true + return 0 + fi + + printf "%s\n" "${build_output}" | grep -v "^#" || true + if [[ ${HOT_DEV_ALLOW_OSS_FALLBACK:-false} != "true" ]]; then + log_error "Pro build failed; refusing to fall back to the OSS backend." + log_error "Fix pulse-enterprise or set HOT_DEV_ALLOW_OSS_FALLBACK=true to override this explicitly." + PRO_BUILD_SUCCESS=false + return 1 + fi + + log_warn "Pro build failed; HOT_DEV_ALLOW_OSS_FALLBACK=true so hot-dev is falling back to the OSS backend." + fi + + if build_output=$(cd "${ROOT_DIR}" && go build -o pulse ./cmd/pulse 2>&1); then + printf "%s\n" "${build_output}" | grep -v "^#" || true + PRO_BUILD_SUCCESS=false + return 0 + fi + + printf "%s\n" "${build_output}" | grep -v "^#" || true + PRO_BUILD_SUCCESS=false + return 1 +} + # --- Configuration & Environment --- load_env_file() { @@ -273,24 +309,10 @@ cd "${ROOT_DIR}" mkdir -p internal/api/frontend-modern/dist touch internal/api/frontend-modern/dist/index.html -# Check if Pro module is available and use it for full audit logging support +# Check if Pro module is available and use it for full audit logging support. # Use PULSE_REPOS_DIR env var or default to the parent directory that contains sibling repos. -PULSE_REPOS_DIR="${PULSE_REPOS_DIR:-${DEFAULT_PULSE_REPOS_DIR}}" -PRO_MODULE_DIR="${PULSE_REPOS_DIR}/pulse-enterprise" -if [[ -d "${PRO_MODULE_DIR}" ]] && [[ ${HOT_DEV_USE_PRO:-true} == "true" ]]; then - log_info "Building Pro binary (includes persistent audit logging)..." - cd "${PRO_MODULE_DIR}" - go build -buildvcs=false -o "${ROOT_DIR}/pulse" ./cmd/pulse-enterprise 2>/dev/null || { - log_warn "Pro build failed, falling back to standard binary" - cd "${ROOT_DIR}" - go build -o pulse ./cmd/pulse - } - cd "${ROOT_DIR}" - # Note: PULSE_AUDIT_DIR is set after PULSE_DATA_DIR is determined below - PRO_BUILD_SUCCESS=true -else - PRO_BUILD_SUCCESS=false - go build -o pulse ./cmd/pulse +if ! build_backend_binary; then + exit 1 fi FRONTEND_PORT=${PULSE_DEV_API_PORT} @@ -565,34 +587,11 @@ log_info "Starting backend file watcher..." log_info "🔄 Change detected: $(basename "$changed_file")" log_info "Rebuilding backend..." - # Use the same build logic as the initial build - local build_success=0 - local build_output - PULSE_REPOS_DIR="${PULSE_REPOS_DIR:-${DEFAULT_PULSE_REPOS_DIR}}" - PRO_MODULE_DIR="${PULSE_REPOS_DIR}/pulse-enterprise" - if [[ -d "${PRO_MODULE_DIR}" ]] && [[ ${HOT_DEV_USE_PRO:-true} == "true" ]]; then - log_info "Building Pro binary..." - if build_output=$(cd "${PRO_MODULE_DIR}" && go build -buildvcs=false -o "${ROOT_DIR}/pulse" ./cmd/pulse-enterprise 2>&1); then - printf "%s\n" "${build_output}" | grep -v "^#" || true - build_success=1 - else - printf "%s\n" "${build_output}" | grep -v "^#" || true - fi - fi - - if [[ $build_success -eq 0 ]]; then - if build_output=$(go build -o pulse ./cmd/pulse 2>&1); then - printf "%s\n" "${build_output}" | grep -v "^#" || true - build_success=1 - else - printf "%s\n" "${build_output}" | grep -v "^#" || true - fi - fi - - if [[ $build_success -eq 1 ]]; then + # Use the same build logic as the initial build. + if build_backend_binary; then restart_backend else - log_error "✗ Build failed!" + log_error "✗ Build failed; keeping the current backend process running." fi log_info "Watching for changes..." } diff --git a/scripts/tests/test-hot-dev-bg.sh b/scripts/tests/test-hot-dev-bg.sh new file mode 100755 index 0000000000..b5acd10e20 --- /dev/null +++ b/scripts/tests/test-hot-dev-bg.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# +# Smoke test for scripts/hot-dev-bg.sh managed ownership diagnostics. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +HOT_DEV_BG="${ROOT_DIR}/scripts/hot-dev-bg.sh" + +if [[ ! -x "${HOT_DEV_BG}" ]]; then + echo "hot-dev-bg.sh not found or not executable at ${HOT_DEV_BG}" >&2 + exit 1 +fi + +failures=0 +server_pids=() + +cleanup() { + local pid + for pid in "${server_pids[@]:-}"; do + kill "${pid}" 2>/dev/null || true + done +} +trap cleanup EXIT + +assert_contains() { + local desc="$1" + local haystack="$2" + local needle="$3" + + if [[ "${haystack}" == *"${needle}"* ]]; then + echo "[PASS] ${desc}" + else + echo "[FAIL] ${desc}" >&2 + echo "Expected to find: ${needle}" >&2 + ((failures++)) + fi +} + +pick_free_port() { + python3 - <<'PY' +import socket + +sock = socket.socket() +sock.bind(("127.0.0.1", 0)) +print(sock.getsockname()[1]) +sock.close() +PY +} + +start_http_server() { + local port="$1" + python3 -m http.server "${port}" --bind 127.0.0.1 >/dev/null 2>&1 & + local pid=$! + server_pids+=("${pid}") + sleep 1 +} + +test_status_without_runtime() { + local frontend_port backend_port output + frontend_port="$(pick_free_port)" + backend_port="$(pick_free_port)" + if [[ "${backend_port}" == "${frontend_port}" ]]; then + backend_port="$(pick_free_port)" + fi + + output="$( + FRONTEND_DEV_HOST=127.0.0.1 \ + FRONTEND_DEV_PORT="${frontend_port}" \ + PULSE_DEV_API_HOST=127.0.0.1 \ + PULSE_DEV_API_PORT="${backend_port}" \ + "${HOT_DEV_BG}" status + )" + + assert_contains "status reports no managed runtime" "${output}" "[hot-dev-bg] Not running" + assert_contains "status reports idle frontend port" "${output}" "[hot-dev-bg] Frontend port ${frontend_port} is not listening" + assert_contains "status reports idle backend port" "${output}" "[hot-dev-bg] Backend port ${backend_port} is not listening" +} + +test_detects_unmanaged_listeners() { + local frontend_port backend_port status_output start_output + frontend_port="$(pick_free_port)" + backend_port="$(pick_free_port)" + if [[ "${backend_port}" == "${frontend_port}" ]]; then + backend_port="$(pick_free_port)" + fi + + start_http_server "${frontend_port}" + start_http_server "${backend_port}" + + status_output="$( + FRONTEND_DEV_HOST=127.0.0.1 \ + FRONTEND_DEV_PORT="${frontend_port}" \ + PULSE_DEV_API_HOST=127.0.0.1 \ + PULSE_DEV_API_PORT="${backend_port}" \ + "${HOT_DEV_BG}" status + )" + + assert_contains "status reports unmanaged frontend listener" "${status_output}" "[hot-dev-bg] Port ${frontend_port}: unmanaged listener" + assert_contains "status reports unmanaged backend listener" "${status_output}" "[hot-dev-bg] Port ${backend_port}: unmanaged listener" + assert_contains "status explains unmanaged runtime ownership" "${status_output}" "[hot-dev-bg] Detected unmanaged dev listeners. hot-dev-bg is not managing the current runtime." + + start_output="$( + set +e + FRONTEND_DEV_HOST=127.0.0.1 \ + FRONTEND_DEV_PORT="${frontend_port}" \ + PULSE_DEV_API_HOST=127.0.0.1 \ + PULSE_DEV_API_PORT="${backend_port}" \ + "${HOT_DEV_BG}" start 2>&1 + printf '\nexit_code=%s' "$?" + )" + + assert_contains "start refuses unmanaged takeover by default" "${start_output}" "Refusing to take over unmanaged listeners." + assert_contains "start returns failure for unmanaged takeover" "${start_output}" "exit_code=1" +} + +main() { + test_status_without_runtime + test_detects_unmanaged_listeners + + if (( failures > 0 )); then + echo "Total failures: ${failures}" >&2 + exit 1 + fi + + echo "hot-dev-bg ownership diagnostics smoke tests passed." +} + +main "$@"