@simon-says-build/harbormaster 0.9.1 → 0.10.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 +22 -0
  2. package/harbormaster +90 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -102,3 +102,25 @@ 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`.
package/harbormaster CHANGED
@@ -71,6 +71,7 @@ import http.server
71
71
  import json
72
72
  import os
73
73
  import signal
74
+ import re
74
75
  import socket
75
76
  import shutil
76
77
  import subprocess
@@ -81,7 +82,7 @@ import uuid
81
82
  from datetime import datetime, timezone
82
83
  from pathlib import Path
83
84
 
84
- VERSION = "0.9.1"
85
+ VERSION = "0.10.0"
85
86
 
86
87
  # Overridable for tests / parallel installs; default is the shared user daemon.
87
88
  # The dir keeps the old "firebase-ports" name: pre-rename binaries still installed
@@ -185,6 +186,33 @@ def _port_open(port, host="127.0.0.1"):
185
186
  return s.connect_ex((host, int(port))) == 0
186
187
 
187
188
 
189
+ # THE PORTS DOCKER PUBLISHES, asked of docker itself. A connect is not enough:
190
+ # on Linux dockerd publishes a container's port through iptables and, with no
191
+ # userland proxy, nothing LISTENS on it — `_port_open` finds the port quiet
192
+ # while a container holds it. Simon Says' local Supabase stacks (20-port blocks,
193
+ # one per job) are containers; on simon-linux-1 (2026-10-09) four jobs in a row
194
+ # were handed a block a stack still held and died on "address already in use".
195
+ # One `docker ps` per allocation; no docker, or one that will not answer, is the
196
+ # empty set — never a failure.
197
+ _PUBLISHED_RE = re.compile(r":(\d+)(?:-(\d+))?->")
198
+
199
+
200
+ def _parse_published(text):
201
+ out = set()
202
+ for m in _PUBLISHED_RE.finditer(text or ""):
203
+ a = int(m.group(1)); b = int(m.group(2)) if m.group(2) else a
204
+ out.update(range(a, min(b, a + 999) + 1))
205
+ return out
206
+
207
+
208
+ def _docker_published_ports():
209
+ try:
210
+ r = subprocess.run(["docker", "ps", "--format", "{{.Ports}}"], capture_output=True, text=True, timeout=10)
211
+ return _parse_published(r.stdout) if r.returncode == 0 else set()
212
+ except (OSError, subprocess.SubprocessError):
213
+ return set()
214
+
215
+
188
216
  def _all_open(ports):
189
217
  return bool(ports) and all(_port_open(p) for p in ports.values())
190
218
 
@@ -627,9 +655,14 @@ class Broker:
627
655
 
628
656
  def _free_block(self):
629
657
  used = {l["block"] for l in self.leases.values() if l.get("block")}
658
+ published = _docker_published_ports()
630
659
  for i in range(POOL_BLOCKS):
631
660
  base = POOL_BASE + i * BLOCK
632
- if base not in used and all(not _port_open(base + off) for off in range(BLOCK)):
661
+ if base in used:
662
+ continue
663
+ if any(base + off in published for off in range(BLOCK)):
664
+ continue
665
+ if all(not _port_open(base + off) for off in range(BLOCK)):
633
666
  return base
634
667
  raise RuntimeError("emulator port pool exhausted")
635
668
 
@@ -1215,6 +1248,9 @@ class _Handler(http.server.BaseHTTPRequestHandler):
1215
1248
 
1216
1249
  def cmd_serve():
1217
1250
  STATE_DIR.mkdir(parents=True, exist_ok=True)
1251
+ dropped = _scrub_environ()
1252
+ if dropped:
1253
+ print(f"harbormaster: scrubbed inherited env before serving: {', '.join(dropped)}", flush=True)
1218
1254
  _Handler.broker = Broker()
1219
1255
  srv = http.server.ThreadingHTTPServer((BROKER_HOST, BROKER_PORT), _Handler)
1220
1256
 
@@ -1267,10 +1303,61 @@ def _ver_tuple(v):
1267
1303
  return (0,)
1268
1304
 
1269
1305
 
1306
+ # THE DAEMON'S ENVIRONMENT IS NOBODY'S TEST ENVIRONMENT.
1307
+ #
1308
+ # The daemon is started by whichever client first needs it, and inherits that
1309
+ # client's shell. Every suite it boots then inherits the daemon's environment
1310
+ # (`_boot`: {**os.environ, ...}), because that is how the functions emulator
1311
+ # runtime gets the env a caller passes with --fn-env. So a variable exported
1312
+ # by the FIRST caller reaches every LATER caller's suite, for the life of the
1313
+ # daemon — and nothing shows it.
1314
+ #
1315
+ # 2026-09-02: a session-test run (TEST_NOW_MS=1784000000000, LLM_CACHE=1
1316
+ # exported) started the daemon at 07:04. Every native e2e suite booted after
1317
+ # that ran its functions emulator on a clock frozen at 2026-07-13; the app
1318
+ # created its onboarding session with that date, decided the session was
1319
+ # seven weeks old and not editable, and hid the composer. The onboarding gate
1320
+ # spec failed on every PR for the rest of the day, and the diagnosis took
1321
+ # hours because the only trace was a date inside one Firestore document.
1322
+ #
1323
+ # So: anything test-only, anything that points at an emulator, and anything a
1324
+ # lease hands out is stripped from the daemon's own environment — when a
1325
+ # client spawns it AND when it starts serving (a daemon started by hand from a
1326
+ # test shell is the same hazard). A caller that wants a suite to see such a
1327
+ # variable passes it explicitly with --fn-env, which rides on top of this.
1328
+ SCRUB_EXACT = {
1329
+ "TEST_NOW_MS",
1330
+ "GCLOUD_PROJECT", "FIREBASE_CONFIG", "FUNCTIONS_EMULATOR",
1331
+ "FBPORTS_LEASE_ID", "FBPORTS_BLOCK_BASE",
1332
+ "METRO_PORT", "DETOX_UDID", "DETOX_SIM_NAME", "AGENT_SIM_UDID",
1333
+ }
1334
+ # LLM_ is the whole family, not LLM_CACHE: 2026-09-13 a session that built
1335
+ # replay-only mode started the daemon with LLM_REPLAY_ONLY=1, and for five days
1336
+ # every native e2e suite threw on its first OpenAI call instead of replying.
1337
+ SCRUB_PREFIXES = ("LLM_", "EMULATOR_")
1338
+ SCRUB_SUFFIXES = ("_EMULATOR_HOST", "_EMULATOR_PORT")
1339
+
1340
+
1341
+ def scrub_key(k):
1342
+ return (k in SCRUB_EXACT or k.startswith(SCRUB_PREFIXES) or k.endswith(SCRUB_SUFFIXES))
1343
+
1344
+
1345
+ def scrubbed_env(env):
1346
+ return {k: v for k, v in env.items() if not scrub_key(k)}
1347
+
1348
+
1349
+ def _scrub_environ():
1350
+ dropped = sorted(k for k in os.environ if scrub_key(k))
1351
+ for k in dropped:
1352
+ del os.environ[k]
1353
+ return dropped
1354
+
1355
+
1270
1356
  def _spawn_daemon():
1271
1357
  STATE_DIR.mkdir(parents=True, exist_ok=True)
1272
1358
  log = open(STATE_DIR / "daemon.log", "a")
1273
- subprocess.Popen([sys.argv[0], "serve"], stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
1359
+ subprocess.Popen([sys.argv[0], "serve"], stdout=log, stderr=subprocess.STDOUT,
1360
+ start_new_session=True, env=scrubbed_env(os.environ))
1274
1361
 
1275
1362
 
1276
1363
  def _ensure():
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.10.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",