start: a pinned port stays pinned across a restart

The free-port probe bound without SO_REUSEADDR while the board's own
ThreadingHTTPServer sets it, so the socket a just-stopped board left in
TIME_WAIT read as "taken by something else": a routine stop/start walked
the board to the next port and wrote that over the user's BOARD_PORT pin.
The probe now binds exactly as the server does, which is the whole race.

Behind it, a held port gets a few seconds (BOARD_PORT_WAIT, 5s) to clear
before the walk, re-asking is_our_board each beat — a restart races its
own predecessor far more often than a stranger takes the port. Walking
off a pinned port still persists, since the hooks and agents read
BOARD_PORT and must reach the live board, but it now says so in full:
the right file (manager/local/.env, not manager/.env), old → new, and
how to reclaim the pin.

Tested end to end against a scratch host with a stub board.py, over real
sockets: a genuine TIME_WAIT remnant, a listener that lets go mid-wait,
a listener that does not, and our own board answering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-08-01 07:32:54 +02:00
co-authored by Claude Opus 5
parent adec6a3c37
commit 6829136f42
4 changed files with 346 additions and 13 deletions
+45 -8
View File
@@ -4,12 +4,19 @@
# ./.task-manager/start.sh # foreground; Ctrl-C stops the board
# ./.task-manager/start.sh --no-open # extra args pass through to board.py
#
# Port logic (BOARD_PORT from env, else manager/.env, else 26071):
# Port logic (BOARD_PORT from env, else manager/local/.env, else 26071):
# - our own board already answering there -> just open the browser
# - port free -> start on it
# - something else squatting on it -> take the next free port AND
# persist it to manager/.env, so the hooks and agents (which read the same
# file) follow the board to its new port instead of reporting into the void.
# - held by something else -> wait a few seconds for it to
# clear (a restart races its own predecessor's shutdown far more often
# than a stranger takes the port), and only then take the next free port
# AND persist it to manager/local/.env, so the hooks and agents (which
# read the same file) follow the board instead of reporting into the void.
#
# The probe binds exactly as the board does — 127.0.0.1 with SO_REUSEADDR,
# which is what ThreadingHTTPServer sets — because a probe stricter than the
# server lies: a socket the just-stopped board left in TIME_WAIT would read
# as occupied and hop the board off a port its user deliberately pinned.
set -euo pipefail
TM="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -17,6 +24,13 @@ MANAGER="$TM/manager"
CORE="$MANAGER/core"
ENV_FILE="$MANAGER/local/.env"
# Seconds to let a held port clear before walking off it. Shutdown is quick;
# this is grace for a predecessor, not patience for a squatter. Anything
# that isn't a number falls back to the default rather than failing a start
# on an arithmetic comparison.
busy_wait="${BOARD_PORT_WAIT:-5}"
case "$busy_wait" in ''|*[!0-9]*) busy_wait=5 ;; esac
port="${BOARD_PORT:-}"
if [ -z "$port" ] && [ -f "$ENV_FILE" ]; then
port="$(sed -n 's/^[[:space:]]*BOARD_PORT[[:space:]]*=[[:space:]]*//p' "$ENV_FILE" | tail -1 | tr -d "'\"")"
@@ -31,6 +45,9 @@ is_free() {
python3 - "$1" <<'PY'
import socket, sys
s = socket.socket()
# The board's ThreadingHTTPServer sets this; a probe without it would call a
# TIME_WAIT remnant "busy" on a port the server itself could bind.
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("127.0.0.1", int(sys.argv[1])))
except OSError:
@@ -52,12 +69,31 @@ sys.exit(0 if data.get("board", {}).get("root") == sys.argv[2] else 1)
' "$1" "$TM/tasks"
}
open_board() {
echo "Board already running at http://127.0.0.1:$1/ — opening it."
python3 -m webbrowser -t "http://127.0.0.1:$1/" >/dev/null
}
if is_our_board "$port"; then
echo "Board already running at http://127.0.0.1:$port/ — opening it."
python3 -m webbrowser -t "http://127.0.0.1:$port/" >/dev/null
open_board "$port"
exit 0
fi
if ! is_free "$port"; then
# Held by someone. Give it a moment: the usual holder is the board this
# start is replacing, and it lets go within a second or two.
echo "Port $port is busy — waiting up to ${busy_wait}s for it to clear."
waited=0
while [ "$waited" -lt "$busy_wait" ] && ! is_free "$port"; do
sleep 1
waited=$((waited + 1))
if is_our_board "$port"; then # a board came up during the wait
open_board "$port"
exit 0
fi
done
fi
if ! is_free "$port"; then
original=$port
for offset in $(seq 1 20); do
@@ -68,8 +104,9 @@ if ! is_free "$port"; then
echo "error: ports $original-$((original + 20)) all busy — set BOARD_PORT yourself." >&2
exit 1
fi
echo "Port $original is taken by something else — using $port instead."
echo "Persisting BOARD_PORT=$port to manager/.env so hooks and agents follow."
echo "Port $original is held by another process — using $port instead."
echo "Rewriting BOARD_PORT in manager/local/.env: $original$port, so the hooks and agents follow the live board."
echo "To reclaim $original: free it, then set BOARD_PORT=$original in manager/local/.env."
python3 - "$ENV_FILE" "$port" <<'PY'
import pathlib, sys
path, port = pathlib.Path(sys.argv[1]), sys.argv[2]