Stabilize hot-dev watcher restarts

This commit is contained in:
rcourtman 2026-03-25 11:17:01 +00:00
parent e87c2629ea
commit 3f6c910f3a
3 changed files with 52 additions and 5 deletions

View file

@ -153,6 +153,13 @@ to be listening.
Changes to `scripts/hot-dev.sh` and `scripts/hot-dev-bg.sh` must therefore 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 stay on their own direct dev-runtime orchestration proof path instead of
piggybacking on installer proof coverage for unrelated deployment scripts. piggybacking on installer proof coverage for unrelated deployment scripts.
That same dev-runtime orchestration boundary also owns watcher stability for
the managed local stack: `scripts/hot-dev.sh` may only rebuild the backend for
runtime Go sources, not `*_test.go` churn, and it must suppress `pulse` binary
change events produced by its own successful managed rebuilds or startup build.
Otherwise unrelated parallel test edits or hot-dev's own binary output can
tear down `7655`, produce transient `5173` proxy failures, and undermine the
canonical browser-runtime proof path.
That same launcher boundary also owns its CLI contract: managed commands such That same launcher boundary also owns its CLI contract: managed commands such
as `start --takeover` and `restart --takeover` must preserve the takeover flag as `start --takeover` and `restart --takeover` must preserve the takeover flag
through the actual script entrypoint instead of silently dropping second-arg through the actual script entrypoint instead of silently dropping second-arg

View file

@ -514,8 +514,9 @@ log_info "Starting backend file watcher..."
( (
cd "${ROOT_DIR}" cd "${ROOT_DIR}"
LAST_RESTART_TIME=0
WATCHER_READY_AT=$(date +%s) WATCHER_READY_AT=$(date +%s)
LAST_RESTART_TIME=0
SELF_BUILD_IGNORE_UNTIL=$((WATCHER_READY_AT + HOT_DEV_WATCHER_STARTUP_GRACE_SECONDS + 5))
watcher_within_startup_grace() { watcher_within_startup_grace() {
local now local now
@ -523,6 +524,27 @@ log_info "Starting backend file watcher..."
(( now - WATCHER_READY_AT < HOT_DEV_WATCHER_STARTUP_GRACE_SECONDS )) (( now - WATCHER_READY_AT < HOT_DEV_WATCHER_STARTUP_GRACE_SECONDS ))
} }
mark_self_build_output() {
local now
now=$(date +%s)
SELF_BUILD_IGNORE_UNTIL=$((now + 5))
}
manual_build_event_suppressed() {
local now
now=$(date +%s)
(( now < SELF_BUILD_IGNORE_UNTIL ))
}
should_rebuild_backend_for_change() {
local changed_file=$1
[[ "${changed_file}" == *.go ]] || return 1
[[ "${changed_file}" == *_test.go ]] && return 1
return 0
}
restart_backend() { restart_backend() {
# Debounce: skip if we restarted less than 3 seconds ago # Debounce: skip if we restarted less than 3 seconds ago
local now local now
@ -604,6 +626,7 @@ log_info "Starting backend file watcher..."
# Use the same build logic as the initial build. # Use the same build logic as the initial build.
if build_backend_binary; then if build_backend_binary; then
mark_self_build_output
restart_backend restart_backend
else else
log_error "✗ Build failed; keeping the current backend process running." log_error "✗ Build failed; keeping the current backend process running."
@ -627,9 +650,12 @@ log_info "Starting backend file watcher..."
log_info "🚀 Managed restart requested, restarting backend..." log_info "🚀 Managed restart requested, restarting backend..."
restart_backend restart_backend
elif [[ "$changed_file" == "${ROOT_DIR}/pulse" ]]; then elif [[ "$changed_file" == "${ROOT_DIR}/pulse" ]]; then
if manual_build_event_suppressed; then
continue
fi
log_info "🚀 Manual build detected (pulse binary changed), restarting..." log_info "🚀 Manual build detected (pulse binary changed), restarting..."
restart_backend restart_backend
elif [[ "$changed_file" == *.go ]] || [[ "$event" =~ CREATE|DELETE|MOVED|ATTRIB ]]; then elif should_rebuild_backend_for_change "$changed_file"; then
rebuild_backend "$changed_file" rebuild_backend "$changed_file"
fi fi
done done
@ -651,6 +677,9 @@ log_info "Starting backend file watcher..."
if [[ "$changed_file" == "${HOT_DEV_RESTART_SENTINEL}" ]]; then if [[ "$changed_file" == "${HOT_DEV_RESTART_SENTINEL}" ]]; then
log_info "🚀 Managed restart requested, restarting backend..." log_info "🚀 Managed restart requested, restarting backend..."
else else
if manual_build_event_suppressed; then
continue
fi
log_info "🚀 Manual build detected (pulse binary changed), restarting..." log_info "🚀 Manual build detected (pulse binary changed), restarting..."
fi fi
restart_backend restart_backend
@ -663,8 +692,7 @@ log_info "Starting backend file watcher..."
--include '\.go$' \ --include '\.go$' \
"${ROOT_DIR}/cmd" "${ROOT_DIR}/internal" "${ROOT_DIR}/pkg" 2>/dev/null | \ "${ROOT_DIR}/cmd" "${ROOT_DIR}/internal" "${ROOT_DIR}/pkg" 2>/dev/null | \
while read -r changed_file; do while read -r changed_file; do
# fswatch sends absolute paths, simple check for .go extension if should_rebuild_backend_for_change "$changed_file"; then
if [[ "$changed_file" == *.go ]]; then
rebuild_backend "$changed_file" rebuild_backend "$changed_file"
fi fi
done done

View file

@ -6,6 +6,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
HOT_DEV_BG="${ROOT_DIR}/scripts/hot-dev-bg.sh" HOT_DEV_BG="${ROOT_DIR}/scripts/hot-dev-bg.sh"
HOT_DEV="${ROOT_DIR}/scripts/hot-dev.sh"
CLEAN_MOCK_ALERTS="${ROOT_DIR}/scripts/clean-mock-alerts.sh" CLEAN_MOCK_ALERTS="${ROOT_DIR}/scripts/clean-mock-alerts.sh"
DEV_CHECK="${ROOT_DIR}/scripts/dev-check.sh" DEV_CHECK="${ROOT_DIR}/scripts/dev-check.sh"
PACKAGE_JSON="${ROOT_DIR}/package.json" PACKAGE_JSON="${ROOT_DIR}/package.json"
@ -468,7 +469,7 @@ test_makefile_routes_managed_runtime_through_npm() {
test_hot_dev_script_advertises_foreground_escape_hatch() { test_hot_dev_script_advertises_foreground_escape_hatch() {
local output local output
output="$(sed -n '1,30p' "${ROOT_DIR}/scripts/hot-dev.sh")" output="$(sed -n '1,30p' "${HOT_DEV}")"
assert_contains "hot-dev header identifies foreground escape hatch" "${output}" "hot-dev.sh - Foreground Pulse dev runtime escape hatch" assert_contains "hot-dev header identifies foreground escape hatch" "${output}" "hot-dev.sh - Foreground Pulse dev runtime escape hatch"
assert_contains "hot-dev usage points to managed runtime first" "${output}" "npm run dev # Canonical managed dev runtime" assert_contains "hot-dev usage points to managed runtime first" "${output}" "npm run dev # Canonical managed dev runtime"
@ -476,6 +477,16 @@ test_hot_dev_script_advertises_foreground_escape_hatch() {
assert_not_contains "hot-dev usage no longer claims standard dev mode" "${output}" "Standard dev mode" assert_not_contains "hot-dev usage no longer claims standard dev mode" "${output}" "Standard dev mode"
} }
test_hot_dev_script_ignores_test_only_backend_churn() {
local output
output="$(cat "${HOT_DEV}")"
assert_contains "hot-dev watcher ignores Go test files" "${output}" '[[ "${changed_file}" == *_test.go ]] && return 1'
assert_contains "hot-dev watcher routes rebuild decisions through shared helper" "${output}" 'should_rebuild_backend_for_change "$changed_file"'
assert_contains "hot-dev watcher suppresses self-build binary restart loops" "${output}" 'if manual_build_event_suppressed; then'
assert_contains "hot-dev watcher suppresses startup self-build pulse events" "${output}" 'SELF_BUILD_IGNORE_UNTIL=$((WATCHER_READY_AT + HOT_DEV_WATCHER_STARTUP_GRACE_SECONDS + 5))'
}
test_hot_dev_bg_script_advertises_managed_entrypoint() { test_hot_dev_bg_script_advertises_managed_entrypoint() {
local output local output
output="$(cat "${ROOT_DIR}/scripts/hot-dev-bg.sh")" output="$(cat "${ROOT_DIR}/scripts/hot-dev-bg.sh")"
@ -744,6 +755,7 @@ main() {
test_frontend_package_exposes_managed_runtime_entrypoints test_frontend_package_exposes_managed_runtime_entrypoints
test_makefile_routes_managed_runtime_through_npm test_makefile_routes_managed_runtime_through_npm
test_hot_dev_script_advertises_foreground_escape_hatch test_hot_dev_script_advertises_foreground_escape_hatch
test_hot_dev_script_ignores_test_only_backend_churn
test_hot_dev_bg_script_advertises_managed_entrypoint test_hot_dev_bg_script_advertises_managed_entrypoint
test_hot_dev_bg_usage_prefers_managed_wrappers test_hot_dev_bg_usage_prefers_managed_wrappers
test_integration_readme_uses_managed_backend_restart_wrapper test_integration_readme_uses_managed_backend_restart_wrapper