Harden managed dev runtime ownership

This commit is contained in:
rcourtman 2026-03-24 10:08:15 +00:00
parent 0c7a6ebf31
commit 736c2efa87
3 changed files with 311 additions and 58 deletions

View file

@ -3,7 +3,7 @@
# Run Pulse hot-dev in a managed background session (macOS/Linux). # Run Pulse hot-dev in a managed background session (macOS/Linux).
# #
# Usage: # 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 stop
# ./scripts/hot-dev-bg.sh restart # ./scripts/hot-dev-bg.sh restart
# ./scripts/hot-dev-bg.sh status # ./scripts/hot-dev-bg.sh status
@ -38,13 +38,46 @@ is_port_listening() {
lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1
} }
wait_for_port() { listener_pids() {
local port="$1" 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)) local checks=$((timeout_seconds * 2))
while (( checks > 0 )); do while (( checks > 0 )); do
if is_port_listening "${port}"; then if listener_is_managed "${port}" "${session_pid}"; then
return 0 return 0
fi fi
sleep 0.5 sleep 0.5
@ -64,11 +97,61 @@ is_running() {
kill -0 "${pid}" 2>/dev/null 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() { require_python() {
command -v python3 >/dev/null 2>&1 || fail "python3 is required" command -v python3 >/dev/null 2>&1 || fail "python3 is required"
} }
start_bg() { start_bg() {
local takeover="${1:-false}"
require_python require_python
mkdir -p "$(dirname "${PID_FILE}")" mkdir -p "$(dirname "${PID_FILE}")"
touch "${LOG_FILE}" touch "${LOG_FILE}"
@ -81,6 +164,18 @@ start_bg() {
# Clear stale PID file before fresh start. # Clear stale PID file before fresh start.
rm -f "${PID_FILE}" 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 local pid
pid="$( pid="$(
FRONTEND_DEV_HOST="${FRONTEND_DEV_HOST}" \ FRONTEND_DEV_HOST="${FRONTEND_DEV_HOST}" \
@ -120,15 +215,15 @@ PY
printf "%s\n" "${pid}" > "${PID_FILE}" printf "%s\n" "${pid}" > "${PID_FILE}"
log "Started hot-dev (pid: ${pid})" log "Started hot-dev (pid: ${pid})"
log "Waiting for backend port ${PULSE_DEV_API_PORT}..." log "Waiting for managed backend listener on port ${PULSE_DEV_API_PORT}..."
if ! wait_for_port "${PULSE_DEV_API_PORT}" 90; then if ! wait_for_managed_listener "${PULSE_DEV_API_PORT}" "${pid}" 90; then
log "Backend did not start. Showing recent logs:" log "Backend did not start. Showing recent logs:"
tail -n 80 "${LOG_FILE}" || true tail -n 80 "${LOG_FILE}" || true
fail "hot-dev startup failed" fail "hot-dev startup failed"
fi fi
if ! wait_for_port "${FRONTEND_DEV_PORT}" 90; then if ! wait_for_managed_listener "${FRONTEND_DEV_PORT}" "${pid}" 90; then
log "Backend is up, but frontend port ${FRONTEND_DEV_PORT} is not listening yet." 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" log "Check logs with: ./scripts/hot-dev-bg.sh logs"
fi fi
@ -168,10 +263,10 @@ stop_bg() {
} }
status_bg() { status_bg() {
local managed_pid=""
if is_running; then if is_running; then
local pid managed_pid="$(cat "${PID_FILE}")"
pid="$(cat "${PID_FILE}")" log "Running (pid: ${managed_pid})"
log "Running (pid: ${pid})"
else else
log "Not running" log "Not running"
fi fi
@ -195,6 +290,15 @@ status_bg() {
[[ -n "${backend_code}" ]] || backend_code="000" [[ -n "${backend_code}" ]] || backend_code="000"
log "Frontend HTTP: ${frontend_code}" log "Frontend HTTP: ${frontend_code}"
log "Backend HTTP: ${backend_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() { logs_bg() {
@ -207,7 +311,7 @@ usage() {
Usage: $(basename "$0") <command> Usage: $(basename "$0") <command>
Commands: Commands:
start start [--takeover]
stop stop
restart restart
status status
@ -216,10 +320,31 @@ EOF
} }
main() { main() {
case "${1:-}" in local command="${1:-}"
start) start_bg ;; 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 ;; 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 ;; status) status_bg ;;
logs) logs_bg ;; logs) logs_bg ;;
*) usage; exit 1 ;; *) usage; exit 1 ;;

View file

@ -10,6 +10,7 @@
# Environment Variables: # Environment Variables:
# HOT_DEV_USE_PROD_DATA=true Use /etc/pulse for data (sessions, config, etc.) # 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_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_MOCK_MODE=true Render mock UI data while keeping real metrics history intact
# PULSE_DATA_DIR=/path Override data directory # PULSE_DATA_DIR=/path Override data directory
# PULSE_DEV_API_PORT=7655 Backend API port (default: 7655) # PULSE_DEV_API_PORT=7655 Backend API port (default: 7655)
@ -70,6 +71,41 @@ check_dependencies() {
fi 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 --- # --- Configuration & Environment ---
load_env_file() { load_env_file() {
@ -273,24 +309,10 @@ cd "${ROOT_DIR}"
mkdir -p internal/api/frontend-modern/dist mkdir -p internal/api/frontend-modern/dist
touch internal/api/frontend-modern/dist/index.html 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. # 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}}" if ! build_backend_binary; then
PRO_MODULE_DIR="${PULSE_REPOS_DIR}/pulse-enterprise" exit 1
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
fi fi
FRONTEND_PORT=${PULSE_DEV_API_PORT} 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 "🔄 Change detected: $(basename "$changed_file")"
log_info "Rebuilding backend..." log_info "Rebuilding backend..."
# Use the same build logic as the initial build # Use the same build logic as the initial build.
local build_success=0 if build_backend_binary; then
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
restart_backend restart_backend
else else
log_error "✗ Build failed!" log_error "✗ Build failed; keeping the current backend process running."
fi fi
log_info "Watching for changes..." log_info "Watching for changes..."
} }

129
scripts/tests/test-hot-dev-bg.sh Executable file
View file

@ -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 "$@"