@simon-says-build/harbormaster 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +35 -0
  2. package/harbormaster +142 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -102,3 +102,38 @@ See [DESIGN.md](./DESIGN.md) for the full architecture.
102
102
  ## License
103
103
 
104
104
  MIT — see [LICENSE](./LICENSE).
105
+
106
+ ## The daemon's environment is nobody's test environment
107
+
108
+ The daemon is started by whichever client first needs it and would otherwise
109
+ inherit that shell. Suites it boots inherit the daemon's environment (that is
110
+ how `--fn-env` reaches the functions emulator runtime), so a variable exported
111
+ by the first caller would reach every later caller's suite for the life of the
112
+ daemon. Harbormaster therefore strips test-only and emulator-pointing variables
113
+ (`TEST_NOW_MS`, `LLM_CACHE*`, `*_EMULATOR_HOST`, `*_EMULATOR_PORT`,
114
+ `GCLOUD_PROJECT`, and the values a lease hands out) both when spawning the
115
+ daemon and when it starts serving. A suite that needs one of these gets it
116
+ explicitly: `acquire --fn-env TEST_NOW_MS=...`.
117
+
118
+ ## A block docker holds is not free (0.10.0)
119
+
120
+ Free-block selection also asks `docker ps` which host ports are published. On
121
+ Linux, dockerd publishes a container's ports through iptables and, without the
122
+ userland proxy, nothing listens on them, so a connect finds the port quiet
123
+ while a container holds it. Simon Says' local Supabase stacks are containers
124
+ on harbormaster blocks. On simon-linux-1 (2026-10-09), four jobs were handed a
125
+ block a stack still held. No docker means the empty set, never a failure.
126
+ `python3 test/docker_ports.py`.
127
+
128
+ ## A managed broker (0.11.0)
129
+
130
+ `harbormaster serve --managed` is for a broker that a service manager
131
+ (systemd, launchd) runs and restarts. It keeps a marker in the state dir
132
+ fresh, touched every minute. While the marker is fresh, clients never replace
133
+ the broker, even an older one, and never spawn their own. If it is briefly
134
+ down, they wait up to 45 s for the service manager to bring it back. Upgrades
135
+ are the service manager's job: restart it after installing a new version. A
136
+ stale marker means the service is gone, and clients spawn as before. Without
137
+ this, the broker belonged to whichever client happened to start it: on
138
+ simon-linux-1 that was a CI job, whose orphan cleanup killed the broker and
139
+ every emulator it ran for other jobs mid-suite. `python3 test/managed.py`.
package/harbormaster CHANGED
@@ -59,7 +59,9 @@ independent suites, so they never collide on ports or data.
59
59
  JVMs / functions workers re-parented to pid 1), stray run-*/alloc-* sims.
60
60
  --fix evicts them; exits 1 when findings remain unfixed. The daemon runs
61
61
  the same sweep itself at startup and every FBPORTS_SWEEP_INTERVAL (300s).
62
- serve Run the daemon (auto-starts on first command).
62
+ serve [--managed] Run the daemon (auto-starts on first command). --managed: a service
63
+ manager (systemd/launchd) runs and restarts it; clients then never
64
+ replace or spawn a broker while its marker is fresh.
63
65
 
64
66
  NOTE: firebase.json ports are optional — the daemon injects (and overrides) a
65
67
  per-lease pool block, so concurrent suites never collide. Ledger:
@@ -71,6 +73,7 @@ import http.server
71
73
  import json
72
74
  import os
73
75
  import signal
76
+ import re
74
77
  import socket
75
78
  import shutil
76
79
  import subprocess
@@ -81,7 +84,7 @@ import uuid
81
84
  from datetime import datetime, timezone
82
85
  from pathlib import Path
83
86
 
84
- VERSION = "0.9.1"
87
+ VERSION = "0.11.0"
85
88
 
86
89
  # Overridable for tests / parallel installs; default is the shared user daemon.
87
90
  # The dir keeps the old "firebase-ports" name: pre-rename binaries still installed
@@ -185,6 +188,33 @@ def _port_open(port, host="127.0.0.1"):
185
188
  return s.connect_ex((host, int(port))) == 0
186
189
 
187
190
 
191
+ # THE PORTS DOCKER PUBLISHES, asked of docker itself. A connect is not enough:
192
+ # on Linux dockerd publishes a container's port through iptables and, with no
193
+ # userland proxy, nothing LISTENS on it — `_port_open` finds the port quiet
194
+ # while a container holds it. Simon Says' local Supabase stacks (20-port blocks,
195
+ # one per job) are containers; on simon-linux-1 (2026-10-09) four jobs in a row
196
+ # were handed a block a stack still held and died on "address already in use".
197
+ # One `docker ps` per allocation; no docker, or one that will not answer, is the
198
+ # empty set — never a failure.
199
+ _PUBLISHED_RE = re.compile(r":(\d+)(?:-(\d+))?->")
200
+
201
+
202
+ def _parse_published(text):
203
+ out = set()
204
+ for m in _PUBLISHED_RE.finditer(text or ""):
205
+ a = int(m.group(1)); b = int(m.group(2)) if m.group(2) else a
206
+ out.update(range(a, min(b, a + 999) + 1))
207
+ return out
208
+
209
+
210
+ def _docker_published_ports():
211
+ try:
212
+ r = subprocess.run(["docker", "ps", "--format", "{{.Ports}}"], capture_output=True, text=True, timeout=10)
213
+ return _parse_published(r.stdout) if r.returncode == 0 else set()
214
+ except (OSError, subprocess.SubprocessError):
215
+ return set()
216
+
217
+
188
218
  def _all_open(ports):
189
219
  return bool(ports) and all(_port_open(p) for p in ports.values())
190
220
 
@@ -627,9 +657,14 @@ class Broker:
627
657
 
628
658
  def _free_block(self):
629
659
  used = {l["block"] for l in self.leases.values() if l.get("block")}
660
+ published = _docker_published_ports()
630
661
  for i in range(POOL_BLOCKS):
631
662
  base = POOL_BASE + i * BLOCK
632
- if base not in used and all(not _port_open(base + off) for off in range(BLOCK)):
663
+ if base in used:
664
+ continue
665
+ if any(base + off in published for off in range(BLOCK)):
666
+ continue
667
+ if all(not _port_open(base + off) for off in range(BLOCK)):
633
668
  return base
634
669
  raise RuntimeError("emulator port pool exhausted")
635
670
 
@@ -1213,8 +1248,46 @@ class _Handler(http.server.BaseHTTPRequestHandler):
1213
1248
  self._send(500, {"error": str(e)})
1214
1249
 
1215
1250
 
1216
- def cmd_serve():
1251
+ # A MANAGED broker (0.11.0): one a service manager runs and keeps alive —
1252
+ # `harbormaster serve --managed` under systemd or launchd. Its marker in the
1253
+ # state dir is touched every minute; while it is fresh, clients never replace
1254
+ # the broker and never spawn their own: the service manager owns its lifecycle
1255
+ # and its upgrades. Without this, the broker belonged to whichever client
1256
+ # happened to start it. On simon-linux-1 (2026-10-09) that was an SSB job's own
1257
+ # checkout inside the runner's systemd unit, and before it a CI job, whose
1258
+ # orphan cleanup killed the broker — and every emulator it ran for OTHER jobs —
1259
+ # mid-suite. A stale marker means the service is gone: clients spawn as before.
1260
+ MANAGED_MARK = STATE_DIR / "managed"
1261
+ MANAGED_FRESH_S = 300
1262
+
1263
+
1264
+ def _managed_recent():
1265
+ try:
1266
+ return time.time() - MANAGED_MARK.stat().st_mtime < MANAGED_FRESH_S
1267
+ except OSError:
1268
+ return False
1269
+
1270
+
1271
+ def _touch_managed():
1272
+ try:
1273
+ MANAGED_MARK.write_text(json.dumps({"pid": os.getpid(), "version": VERSION, "exe": str(Path(__file__).resolve())}))
1274
+ except OSError:
1275
+ pass
1276
+
1277
+
1278
+ def cmd_serve(managed=False):
1217
1279
  STATE_DIR.mkdir(parents=True, exist_ok=True)
1280
+ if managed:
1281
+ _touch_managed()
1282
+
1283
+ def keep_marked():
1284
+ while True:
1285
+ time.sleep(60)
1286
+ _touch_managed()
1287
+ threading.Thread(target=keep_marked, daemon=True).start()
1288
+ dropped = _scrub_environ()
1289
+ if dropped:
1290
+ print(f"harbormaster: scrubbed inherited env before serving: {', '.join(dropped)}", flush=True)
1218
1291
  _Handler.broker = Broker()
1219
1292
  srv = http.server.ThreadingHTTPServer((BROKER_HOST, BROKER_PORT), _Handler)
1220
1293
 
@@ -1267,16 +1340,70 @@ def _ver_tuple(v):
1267
1340
  return (0,)
1268
1341
 
1269
1342
 
1343
+ # THE DAEMON'S ENVIRONMENT IS NOBODY'S TEST ENVIRONMENT.
1344
+ #
1345
+ # The daemon is started by whichever client first needs it, and inherits that
1346
+ # client's shell. Every suite it boots then inherits the daemon's environment
1347
+ # (`_boot`: {**os.environ, ...}), because that is how the functions emulator
1348
+ # runtime gets the env a caller passes with --fn-env. So a variable exported
1349
+ # by the FIRST caller reaches every LATER caller's suite, for the life of the
1350
+ # daemon — and nothing shows it.
1351
+ #
1352
+ # 2026-09-02: a session-test run (TEST_NOW_MS=1784000000000, LLM_CACHE=1
1353
+ # exported) started the daemon at 07:04. Every native e2e suite booted after
1354
+ # that ran its functions emulator on a clock frozen at 2026-07-13; the app
1355
+ # created its onboarding session with that date, decided the session was
1356
+ # seven weeks old and not editable, and hid the composer. The onboarding gate
1357
+ # spec failed on every PR for the rest of the day, and the diagnosis took
1358
+ # hours because the only trace was a date inside one Firestore document.
1359
+ #
1360
+ # So: anything test-only, anything that points at an emulator, and anything a
1361
+ # lease hands out is stripped from the daemon's own environment — when a
1362
+ # client spawns it AND when it starts serving (a daemon started by hand from a
1363
+ # test shell is the same hazard). A caller that wants a suite to see such a
1364
+ # variable passes it explicitly with --fn-env, which rides on top of this.
1365
+ SCRUB_EXACT = {
1366
+ "TEST_NOW_MS",
1367
+ "GCLOUD_PROJECT", "FIREBASE_CONFIG", "FUNCTIONS_EMULATOR",
1368
+ "FBPORTS_LEASE_ID", "FBPORTS_BLOCK_BASE",
1369
+ "METRO_PORT", "DETOX_UDID", "DETOX_SIM_NAME", "AGENT_SIM_UDID",
1370
+ }
1371
+ # LLM_ is the whole family, not LLM_CACHE: 2026-09-13 a session that built
1372
+ # replay-only mode started the daemon with LLM_REPLAY_ONLY=1, and for five days
1373
+ # every native e2e suite threw on its first OpenAI call instead of replying.
1374
+ SCRUB_PREFIXES = ("LLM_", "EMULATOR_")
1375
+ SCRUB_SUFFIXES = ("_EMULATOR_HOST", "_EMULATOR_PORT")
1376
+
1377
+
1378
+ def scrub_key(k):
1379
+ return (k in SCRUB_EXACT or k.startswith(SCRUB_PREFIXES) or k.endswith(SCRUB_SUFFIXES))
1380
+
1381
+
1382
+ def scrubbed_env(env):
1383
+ return {k: v for k, v in env.items() if not scrub_key(k)}
1384
+
1385
+
1386
+ def _scrub_environ():
1387
+ dropped = sorted(k for k in os.environ if scrub_key(k))
1388
+ for k in dropped:
1389
+ del os.environ[k]
1390
+ return dropped
1391
+
1392
+
1270
1393
  def _spawn_daemon():
1271
1394
  STATE_DIR.mkdir(parents=True, exist_ok=True)
1272
1395
  log = open(STATE_DIR / "daemon.log", "a")
1273
- subprocess.Popen([sys.argv[0], "serve"], stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
1396
+ subprocess.Popen([sys.argv[0], "serve"], stdout=log, stderr=subprocess.STDOUT,
1397
+ start_new_session=True, env=scrubbed_env(os.environ))
1274
1398
 
1275
1399
 
1276
1400
  def _ensure():
1277
1401
  v = _daemon_version()
1402
+ managed = _managed_recent()
1278
1403
  if v is not None:
1279
- if _ver_tuple(v) >= _ver_tuple(VERSION):
1404
+ # A managed broker is never replaced by a client: its service manager
1405
+ # owns upgrades (restart it to pick up a new install).
1406
+ if managed or _ver_tuple(v) >= _ver_tuple(VERSION):
1280
1407
  return
1281
1408
  # A newer client found an older daemon (e.g. a stale node_modules copy
1282
1409
  # started it). Replace it: leases persist in the ledger and every leased
@@ -1294,6 +1421,14 @@ def _ensure():
1294
1421
  raise RuntimeError(
1295
1422
  f"daemon {v} is older than this client ({VERSION}) and did not "
1296
1423
  f"shut down — kill the 'harbormaster serve' process and retry")
1424
+ if managed:
1425
+ # Down, but a service runs it: it is restarting. Wait for it rather than
1426
+ # start one of our own that would die with this client.
1427
+ for _ in range(450):
1428
+ if _up():
1429
+ return
1430
+ time.sleep(0.1)
1431
+ print("harbormaster: the managed broker did not come back in 45s — starting one here", file=sys.stderr)
1297
1432
  _spawn_daemon()
1298
1433
  for _ in range(50):
1299
1434
  if _up():
@@ -1407,7 +1542,7 @@ def main():
1407
1542
  if cmd == "version":
1408
1543
  print(VERSION)
1409
1544
  elif cmd == "serve":
1410
- cmd_serve()
1545
+ cmd_serve(managed="managed" in flags)
1411
1546
  elif cmd in ("acquire", "up"):
1412
1547
  _ensure()
1413
1548
  pid, start = _owner(opts)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/harbormaster",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "A localhost daemon that leases each caller its own port block — isolated Firebase emulator sandboxes, Metro/Expo ports, private iOS simulators — reaped when the caller exits. No port collisions, no shared instances, no pkill.",
5
5
  "bin": {
6
6
  "harbormaster": "harbormaster",