Files
bench/tests/test_pinned_port.py
T
istosandClaude Opus 5 6829136f42 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>
2026-08-01 07:32:54 +02:00

285 lines
12 KiB
Python

"""A pinned port stays pinned. start.sh's port handling, run end to end as
a subprocess against a scratch host whose board.py is a stub that records
how it was launched. Run with: python3 -m unittest discover -s tests
The bug this guards: the free-port probe bound without SO_REUSEADDR while
the board's ThreadingHTTPServer sets it, so a socket the just-stopped board
left in TIME_WAIT read as "taken by something else" — and start.sh then
walked to the next port and wrote that over the user's own BOARD_PORT pin.
The sockets here are real: TIME_WAIT is produced by an actual closed
connection, and a holder is an actual listener.
"""
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
STAGES = ["backlog", "to-do", "in-progress", "review", "done"]
# A board.py that serves nothing and only records the port it was told to
# use — every assertion about "which port did we start on" reads this.
STUB_BOARD = """#!/usr/bin/env python3
import json, pathlib, sys
pathlib.Path(__file__).with_name("launched.json").write_text(
json.dumps(sys.argv[1:]), encoding="utf-8")
"""
ENV_TEMPLATE = """# The port the board serves on.
BOARD_PORT={port}
# Seconds between disk polls of the stage directories.
BOARD_WATCH_INTERVAL=2
BOARD_SYNC=
"""
def make_host(root: Path, port: int) -> Path:
"""A host project whose board is pinned to `port`: the real start.sh and
install.py, a stub board.py, and a local/.env with the pin plus a couple
of other settings, so a rewrite that damages the file is visible."""
host = root / "host"
(host / ".claude").mkdir(parents=True)
tm = host / ".task-manager"
tm.mkdir()
shutil.copy(REPO / "start.sh", tm / "start.sh")
shutil.copy(REPO / "install.py", tm / "install.py")
shutil.copytree(REPO / "manager" / "core" / "adapters" / "claude",
tm / "manager" / "core" / "adapters" / "claude")
shutil.copy(REPO / "manager" / "core" / ".env.example",
tm / "manager" / "core" / ".env.example")
(tm / "manager" / "core" / "board.py").write_text(STUB_BOARD,
encoding="utf-8")
for stage in STAGES + ["archive"]:
d = tm / "tasks" / stage
d.mkdir(parents=True)
(d / ".gitkeep").touch()
local = tm / "manager" / "local"
local.mkdir(parents=True)
# An .env on disk also disarms install.py's first-boot clean, so the
# start under test does nothing but wire, probe and launch.
(local / ".env").write_text(ENV_TEMPLATE.format(port=port),
encoding="utf-8")
return tm
def clean_env() -> dict:
"""No BOARD_* leaking in from the developer's shell: the pin under test
is the one in local/.env. BROWSER keeps the browser-open path silent —
`python3 -m webbrowser` runs it, and a test must not raise a window."""
env = {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")}
env["BROWSER"] = "/usr/bin/true"
return env
def run_start(tm: Path, wait: str = "2", **overrides) -> subprocess.CompletedProcess:
env = clean_env()
env["BOARD_PORT_WAIT"] = wait
env.update(overrides)
return subprocess.run(
["bash", str(tm / "start.sh")], capture_output=True, text=True,
cwd=tm.parent, env=env, stdin=subprocess.DEVNULL, timeout=120)
def launched(tm: Path):
"""The arguments the stub board.py was started with, or None if start.sh
never got that far."""
marker = tm / "manager" / "core" / "launched.json"
return json.loads(marker.read_text(encoding="utf-8")) if marker.is_file() else None
def free_port(count: int = 1) -> int:
"""The first of `count` consecutive ports nothing holds — including
nothing in TIME_WAIT, since the probe here binds without SO_REUSEADDR."""
for base in range(27100, 27600, count):
held, free = [], True
for candidate in range(base, base + count):
s = socket.socket()
try:
s.bind(("127.0.0.1", candidate))
except OSError:
free = False
s.close()
break
held.append(s)
for s in held:
s.close()
if free:
return base
raise AssertionError("no run of free ports to test with")
def leave_time_wait(port: int) -> None:
"""Leave a real socket in TIME_WAIT on `port`: connect to a listener and
close the server's end first, which is exactly what a board shutting
down does to the browser tab still attached to it."""
listener = socket.socket()
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", port))
listener.listen(1)
client = socket.create_connection(("127.0.0.1", port))
conn, _ = listener.accept()
conn.close()
client.close()
listener.close()
def plain_bind_refuses(port: int) -> bool:
"""Would the old probe — a bind with no SO_REUSEADDR — call this port
busy? False means the platform does not reproduce the bug at all, and
the test says so rather than passing on nothing."""
s = socket.socket()
try:
s.bind(("127.0.0.1", port))
return False
except OSError:
return True
finally:
s.close()
class Holder:
"""Something on the port that is not this project's board: an HTTP
server that answers /api/state with `root` (None = answers nothing the
probe recognises, i.e. a stranger)."""
def __init__(self, port: int, root: str | None = None):
payload = json.dumps({"board": {"root": root}}).encode()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/api/state" and root is not None:
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
else:
self.send_error(404)
def log_message(self, *args):
pass
self.server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def stop(self):
self.server.shutdown()
self.server.server_close()
class PinnedPort(unittest.TestCase):
def setUp(self):
self.scratch = Path(tempfile.mkdtemp()).resolve()
self.addCleanup(shutil.rmtree, self.scratch, True)
self.port = free_port(2)
self.tm = make_host(self.scratch, self.port)
self.env_file = self.tm / "manager" / "local" / ".env"
self.env_before = self.env_file.read_text(encoding="utf-8")
def env_values(self) -> dict:
return dict(
line.split("=", 1)
for line in self.env_file.read_text(encoding="utf-8").splitlines()
if "=" in line and not line.lstrip().startswith("#"))
def test_a_socket_in_time_wait_does_not_move_the_pin(self):
"""The reported bug: stop the board, start it again, and the socket
its own shutdown left behind sends it to the next port."""
leave_time_wait(self.port)
if not plain_bind_refuses(self.port):
self.skipTest("this platform lets a plain bind reuse TIME_WAIT")
result = run_start(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(launched(self.tm), ["--port", str(self.port)])
self.assertEqual(self.env_file.read_text(encoding="utf-8"),
self.env_before)
self.assertNotIn("Rewriting BOARD_PORT", result.stdout)
self.assertNotIn("is busy", result.stdout)
def test_the_probe_agrees_with_the_server_it_probes_for(self):
"""The root cause, on its own: start.sh's is_free must call a port
free exactly when the board could bind it — free over TIME_WAIT
(ThreadingHTTPServer sets SO_REUSEADDR), busy under a listener."""
text = (REPO / "start.sh").read_text(encoding="utf-8")
probe = (text.split('python3 - "$1" <<\'PY\'\n', 1)[1]
.split("\nPY\n", 1)[0])
leave_time_wait(self.port)
if not plain_bind_refuses(self.port):
self.skipTest("this platform lets a plain bind reuse TIME_WAIT")
def is_free(port: int) -> bool:
return subprocess.run([sys.executable, "-c", probe, str(port)],
capture_output=True).returncode == 0
self.assertTrue(is_free(self.port), "TIME_WAIT read as occupied")
holder = Holder(self.port + 1)
self.addCleanup(holder.stop)
self.assertFalse(is_free(self.port + 1), "a listener read as free")
def test_a_holder_that_lets_go_during_the_wait_keeps_the_pin(self):
"""A predecessor still shutting down when the new start probes: the
brief retry is what keeps the restart on its own port."""
holder = Holder(self.port)
timer = threading.Timer(1.5, holder.stop)
timer.start()
self.addCleanup(timer.cancel)
result = run_start(self.tm, wait="20")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(f"Port {self.port} is busy — waiting", result.stdout)
self.assertEqual(launched(self.tm), ["--port", str(self.port)])
self.assertEqual(self.env_file.read_text(encoding="utf-8"),
self.env_before)
def test_a_foreign_holder_makes_it_wait_then_walk_and_say_so(self):
"""Genuinely occupied: the hop is right — the hooks and agents read
BOARD_PORT and must reach the live board — but it has to be said in
full, since it overwrites something the user chose."""
holder = Holder(self.port)
self.addCleanup(holder.stop)
result = run_start(self.tm, wait="2")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
moved = self.port + 1
self.assertIn(f"Port {self.port} is busy — waiting up to 2s",
result.stdout)
self.assertIn(f"Port {self.port} is held by another process — "
f"using {moved} instead.", result.stdout)
self.assertIn(f"manager/local/.env: {self.port}{moved}",
result.stdout)
self.assertIn(f"To reclaim {self.port}", result.stdout)
self.assertIn(f"set BOARD_PORT={self.port} in manager/local/.env",
result.stdout)
self.assertNotIn("to manager/.env", result.stdout)
self.assertEqual(launched(self.tm), ["--port", str(moved)])
values = self.env_values()
self.assertEqual(values["BOARD_PORT"], str(moved))
self.assertEqual(values["BOARD_WATCH_INTERVAL"], "2")
self.assertIn("# Seconds between disk polls of the stage directories.",
self.env_file.read_text(encoding="utf-8"))
def test_our_own_board_short_circuits_before_any_probe(self):
"""A board of ours already answering there is the one case that
neither probes nor hops: it opens the tab and stops."""
holder = Holder(self.port, root=str(self.tm / "tasks"))
self.addCleanup(holder.stop)
result = run_start(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(f"Board already running at http://127.0.0.1:{self.port}/",
result.stdout)
self.assertIsNone(launched(self.tm))
self.assertNotIn("is busy", result.stdout)
self.assertEqual(self.env_file.read_text(encoding="utf-8"),
self.env_before)
if __name__ == "__main__":
unittest.main()