From 01fe2375380fdafe577c9d133dd110591b9cc2f4 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 6 Jun 2026 21:26:12 +0800 Subject: [PATCH 1/2] Add opt-in dev port reclaim --- README.md | 17 ++++- docs/development.md | 63 +++++++++++++++- scripts/dev.sh | 180 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1cb53a7..5cdf9c3 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,20 @@ extras Optional workflow, deployment, and support add-ons 8. Run `./scripts/docker-compose.sh up -d postgres redis`. 9. Run `./scripts/dev.sh`. -The port helper prints `WEB_URL` first, followed by `WEB_PORT` and the rest of -the assigned worktree ports, so coding orchestrators that scan startup output -for a URL open the web surface first. +The port helper's `env` command is the print-only URL/port mode. It prints +`WEB_URL` first, followed by `WEB_PORT` and the rest of the assigned worktree +ports, so coding orchestrators that scan startup output for a URL open the web +surface first. + +If a stale same-worktree dev process is still holding the web port, rerun with: + +```bash +./scripts/dev.sh --reclaim-ports +``` + +Port reclaim is opt-in and intentionally narrow: the script checks the listener +with `lsof` and refuses to kill it unless the process chain looks like this +worktree's own JS dev server. ## Worktree And Docker Hygiene diff --git a/docs/development.md b/docs/development.md index a7f3379..2980e10 100644 --- a/docs/development.md +++ b/docs/development.md @@ -12,11 +12,13 @@ Local development follows the pattern used across existing projects: ./scripts/worktree-ports.sh env ./scripts/docker-compose.sh up -d postgres redis ./scripts/dev.sh +./scripts/dev.sh --reclaim-ports ./scripts/check-all.sh ``` -`worktree-ports.sh env` prints `WEB_URL` first, then `WEB_PORT`, then the -remaining assigned ports and derived connection strings. Keep that order when +`worktree-ports.sh env` is the print-only URL/port mode. It prints `WEB_URL` +first, then `WEB_PORT`, then the remaining assigned ports and derived +connection strings, and exits without starting services. Keep that order when adapting the helper so coding workspace tools discover the web surface before API or infrastructure URLs. @@ -38,6 +40,63 @@ worker health, database, cache, and OTEL example ports. When only one public por is present, the helper assigns it to the selected primary target and keeps other ports on the normal deterministic worktree allocation. +## Optional Port Reclaim + +Dev scripts may offer opt-in port reclaim so a developer can rerun the same +worktree script after a stale dev process is left behind: + +```bash +./scripts/dev.sh --reclaim-ports +DEVKIT_RECLAIM_PORTS=1 ./scripts/dev.sh +``` + +Reclaiming must stay conservative. Before killing a process, inspect the port +owner with `lsof`, verify the process cwd or parent process cwd is under the +current worktree, and verify the command looks like the same service type the +script is about to start. Refuse to kill unrelated listeners and print the pid, +cwd, and command so the developer can decide manually. + +The root `scripts/dev.sh` includes a small shell example for single host-run +web-dev processes. It walks a short parent chain from the process listening on +`WEB_PORT` and only sends `SIGTERM` when it finds a same-worktree JS dev command +such as Next, Vite, Astro, webpack, rsbuild, Bun, or pnpm. The shell helper is +generic enough for adapted repos to add other host-run app processes: + +```sh +# Example only: add a service-specific signature before enabling this. +reclaim_service_port api "$API_PORT" +reclaim_service_port worker-health "$WORKER_HEALTH_PORT" +``` + +Each extra service needs its own command matcher, such as a Uvicorn app import +for an API, a queue-worker binary name, or a bot executable. Do not treat all +same-worktree processes as reclaimable; a repo can run unrelated listeners from +the same checkout. + +Avoid reclaiming Docker-owned infrastructure ports from the host dev script. +For Postgres, Redis, MinIO, and similar Compose services, use stable +`COMPOSE_PROJECT_NAME` plus `docker compose up`/`down` so same-worktree runs are +idempotent. If an infrastructure port is held by something else, report the +owner and ask the developer to stop it or change the configured port. + +For multi-service repos, prefer a small service-aware mux helper instead of +copying generic shell globs. A good pattern is: + +```text +scripts/dev.sh web + -> scripts/dev_mux.py --ensure-port web + -> lsof owner pids for the web URL port + -> walk parent/child process tree + -> require same worktree path scope + -> require service signature, such as the uvicorn app import or discord-bot + -> stop the related service process tree + -> verify the port is free before starting +``` + +This avoids killing a different service that happens to run from the same repo, +and avoids prefix-path mistakes such as treating `/tmp/app/foo-bar` as being +inside `/tmp/app/foo`. + ## Worktree Includes Use `.worktreeinclude` to allowlist ignored local files that should be copied into new sibling worktrees. Treat entries as gitignore-style path patterns, not shell globs passed directly to `cp`. diff --git a/scripts/dev.sh b/scripts/dev.sh index 906fb2f..1e8b1bc 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -2,6 +2,45 @@ set -eu cd "$(dirname "$0")/.." +WORKTREE_ROOT="$(pwd -P)" + +usage() { + cat >&2 <<'EOF' +usage: dev.sh [--reclaim-ports] + +Options: + --reclaim-ports Before starting, stop this worktree's own JS dev process + if it is still listening on WEB_PORT. Adapted repos can + call reclaim_service_port for other host-run app services. + --no-reclaim-ports + Disable reclaiming even when DEVKIT_RECLAIM_PORTS=1. + --help Show this help. + +Environment: + DEVKIT_RECLAIM_PORTS=1 Same as --reclaim-ports. +EOF +} + +RECLAIM_PORTS="${DEVKIT_RECLAIM_PORTS:-0}" +while [ "$#" -gt 0 ]; do + case "$1" in + --reclaim-ports) + RECLAIM_PORTS=1 + ;; + --no-reclaim-ports) + RECLAIM_PORTS=0 + ;; + --help|-h) + usage + exit 0 + ;; + *) + usage + exit 2 + ;; + esac + shift +done eval "$(./scripts/worktree-ports.sh export)" export WEB_HOST="${WEB_HOST:-127.0.0.1}" @@ -50,6 +89,147 @@ detect_js_runner() { JS_RUNNER="$(detect_js_runner)" +port_listener_pids() { + port="$1" + if ! command -v lsof >/dev/null 2>&1; then + echo "dev.sh --reclaim-ports requires lsof to inspect port owners." >&2 + return 1 + fi + lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | sort -u +} + +process_cwd() { + pid="$1" + lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | sed -n '1p' +} + +process_command() { + pid="$1" + ps -p "$pid" -o command= 2>/dev/null || true +} + +process_parent_pid() { + pid="$1" + ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ' || true +} + +is_inside_worktree() { + path="$1" + case "$path" in + "$WORKTREE_ROOT"|"$WORKTREE_ROOT"/*) return 0 ;; + *) return 1 ;; + esac +} + +is_expected_service_command() { + service_name="$1" + command_line="$2" + + case "$service_name" in + web) + is_expected_web_dev_command "$command_line" + ;; + *) + return 1 + ;; + esac +} + +is_expected_web_dev_command() { + command_line="$1" + case "$command_line" in + next\ dev*|*" next dev"*|*next-server*|\ + vite\ *|*" vite "*|\ + astro\ dev*|*" astro dev"*|\ + remix\ vite:dev*|*" remix vite:dev"*|\ + webpack\ serve*|*" webpack serve"*|\ + rspack\ serve*|*" rspack serve"*|\ + rsbuild\ dev*|*" rsbuild dev"*|\ + parcel\ serve*|*" parcel serve"*|\ + tanstack\ start*|*" tanstack start"*|\ + tsc\ --noEmit\ --watch*|*" tsc --noEmit --watch"*|\ + bun\ run*|*" bun run"*|\ + pnpm\ *|*" pnpm "*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_expected_service_process() { + service_name="$1" + pid="$2" + depth=0 + + while [ -n "$pid" ] && [ "$pid" -gt 1 ] 2>/dev/null && [ "$depth" -lt 8 ]; do + command_line="$(process_command "$pid")" + cwd="$(process_cwd "$pid")" + + if [ -n "$cwd" ] && is_inside_worktree "$cwd" && is_expected_service_command "$service_name" "$command_line"; then + return 0 + fi + + pid="$(process_parent_pid "$pid")" + depth=$((depth + 1)) + done + + return 1 +} + +wait_for_port_release() { + port="$1" + attempts=0 + while [ "$attempts" -lt 20 ]; do + if [ -z "$(port_listener_pids "$port")" ]; then + return 0 + fi + attempts=$((attempts + 1)) + sleep 0.1 + done + return 1 +} + +reclaim_service_port() { + service_name="$1" + port="$2" + pids="$(port_listener_pids "$port")" + if [ -z "$pids" ]; then + return 0 + fi + + reclaim_pids="" + for pid in $pids; do + if ! is_expected_service_process "$service_name" "$pid"; then + command_line="$(process_command "$pid")" + cwd="$(process_cwd "$pid")" + echo "Refusing to reclaim ${service_name} port ${port}; pid ${pid} does not look like this worktree's ${service_name} process." >&2 + echo " cwd: ${cwd:-unknown}" >&2 + echo " cmd: ${command_line:-unknown}" >&2 + return 1 + fi + + reclaim_pids="${reclaim_pids}${pid} " + done + + for pid in $reclaim_pids; do + echo "Reclaiming ${service_name} port ${port} from pid ${pid}" + kill "$pid" 2>/dev/null || true + done + + if wait_for_port_release "$port"; then + return 0 + fi + + echo "${service_name} port ${port} is still in use after SIGTERM; refusing to force-kill it." >&2 + return 1 +} + +if [ "$RECLAIM_PORTS" = "1" ]; then + reclaim_service_port web "$WEB_PORT" +fi + cleanup() { if [ -n "${WEB_PID:-}" ]; then kill "$WEB_PID" 2>/dev/null || true; fi } From 9cfe6b4ce726a576ab438a0f7c44b01c1c2cecd1 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 6 Jun 2026 21:47:07 +0800 Subject: [PATCH 2/2] Address dev reclaim review comments --- docs/development.md | 8 ++++---- scripts/dev.sh | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/development.md b/docs/development.md index 2980e10..adacf01 100644 --- a/docs/development.md +++ b/docs/development.md @@ -12,7 +12,6 @@ Local development follows the pattern used across existing projects: ./scripts/worktree-ports.sh env ./scripts/docker-compose.sh up -d postgres redis ./scripts/dev.sh -./scripts/dev.sh --reclaim-ports ./scripts/check-all.sh ``` @@ -75,9 +74,10 @@ the same checkout. Avoid reclaiming Docker-owned infrastructure ports from the host dev script. For Postgres, Redis, MinIO, and similar Compose services, use stable -`COMPOSE_PROJECT_NAME` plus `docker compose up`/`down` so same-worktree runs are -idempotent. If an infrastructure port is held by something else, report the -owner and ask the developer to stop it or change the configured port. +`COMPOSE_PROJECT_NAME` with `./scripts/docker-compose.sh up` / `down` so +same-worktree runs are idempotent. If an infrastructure port is held by +something else, report the owner and ask the developer to stop it or change the +configured port. For multi-service repos, prefer a small service-aware mux helper instead of copying generic shell globs. A good pattern is: diff --git a/scripts/dev.sh b/scripts/dev.sh index 1e8b1bc..cbf1de2 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -227,7 +227,9 @@ reclaim_service_port() { } if [ "$RECLAIM_PORTS" = "1" ]; then - reclaim_service_port web "$WEB_PORT" + if ! reclaim_service_port web "$WEB_PORT"; then + echo "Continuing because the root dev script does not bind WEB_PORT." >&2 + fi fi cleanup() {