@simon-says-build/harbormaster 0.9.0 → 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 +113 -5
  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,7 +71,9 @@ import http.server
71
71
  import json
72
72
  import os
73
73
  import signal
74
+ import re
74
75
  import socket
76
+ import shutil
75
77
  import subprocess
76
78
  import sys
77
79
  import threading
@@ -80,7 +82,7 @@ import uuid
80
82
  from datetime import datetime, timezone
81
83
  from pathlib import Path
82
84
 
83
- VERSION = "0.9.0"
85
+ VERSION = "0.10.0"
84
86
 
85
87
  # Overridable for tests / parallel installs; default is the shared user daemon.
86
88
  # The dir keeps the old "firebase-ports" name: pre-rename binaries still installed
@@ -88,6 +90,9 @@ VERSION = "0.9.0"
88
90
  STATE_DIR = Path(os.environ.get("FBPORTS_STATE_DIR",
89
91
  str(Path.home() / ".local" / "state" / "firebase-ports")))
90
92
  LEDGER = STATE_DIR / "ledger.json"
93
+ # Per-suite TMPDIR for the emulator process (see EmulatorPool._boot).
94
+ def _suite_tmpdir(label):
95
+ return STATE_DIR / "tmp" / str(label)
91
96
  BROKER_HOST, BROKER_PORT = "127.0.0.1", int(os.environ.get("FBPORTS_PORT", "4999"))
92
97
  # Blocks of 20 ports, 11000-14999 by default. Overridable so an isolated test
93
98
  # daemon (FBPORTS_PORT + FBPORTS_STATE_DIR) can also use a DISJOINT pool — its
@@ -181,6 +186,33 @@ def _port_open(port, host="127.0.0.1"):
181
186
  return s.connect_ex((host, int(port))) == 0
182
187
 
183
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
+
184
216
  def _all_open(ports):
185
217
  return bool(ports) and all(_port_open(p) for p in ports.values())
186
218
 
@@ -623,9 +655,14 @@ class Broker:
623
655
 
624
656
  def _free_block(self):
625
657
  used = {l["block"] for l in self.leases.values() if l.get("block")}
658
+ published = _docker_published_ports()
626
659
  for i in range(POOL_BLOCKS):
627
660
  base = POOL_BASE + i * BLOCK
628
- 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)):
629
666
  return base
630
667
  raise RuntimeError("emulator port pool exhausted")
631
668
 
@@ -639,6 +676,18 @@ class Broker:
639
676
  run_ports = {s: all_ports[s] for s in (only or declared) if s in all_ports}
640
677
  check = {s: p for s, p in run_ports.items() if s not in ("ui", "hub", "logging")}
641
678
  log = STATE_DIR / f"{label}.log"
679
+ # Each suite gets its OWN temp dir. firebase-tools keeps the Storage
680
+ # emulator's blobs at `${os.tmpdir()}/firebase/storage/blobs` — one
681
+ # fixed path for every instance on the box — and `stop()` rm -rf's it.
682
+ # So any suite shutting down wiped the blobs of every other suite still
683
+ # running, and their next upload died with ENOENT inside firebase-tools
684
+ # ("An unexpected error has occurred.", exit 2, all emulators gone).
685
+ # On a box that runs several suites at once that is not a corner case:
686
+ # 2026-08-28, three walk2gether e2e runs in a row on the studio, each
687
+ # killed by a daemon verify drive finishing. os.tmpdir() honours
688
+ # TMPDIR, so a per-suite one makes the path private.
689
+ suite_tmp = _suite_tmpdir(label)
690
+ suite_tmp.mkdir(parents=True, exist_ok=True)
642
691
  cmd = ["firebase", "emulators:start", "--config", tmp]
643
692
  if only:
644
693
  cmd += ["--only", ",".join(only)]
@@ -648,9 +697,9 @@ class Broker:
648
697
  # daemon's env — the functions emulator runtime inherits it, which is
649
698
  # the only way a CALLER can set per-suite env (e.g. LLM_CACHE_DIR)
650
699
  # when the daemon, not the caller, spawns the emulator.
651
- spawn_env = None
700
+ spawn_env = {**os.environ, "TMPDIR": str(suite_tmp)}
652
701
  if extra_env:
653
- spawn_env = {**os.environ, **{str(k): str(v) for k, v in dict(extra_env).items()}}
702
+ spawn_env.update({str(k): str(v) for k, v in dict(extra_env).items()})
654
703
  with open(log, "w") as lf:
655
704
  proc = subprocess.Popen(cmd, cwd=cwd, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True, env=spawn_env)
656
705
  if on_spawn:
@@ -770,6 +819,11 @@ class Broker:
770
819
  tmp = rec.get("tmp_config")
771
820
  if tmp and rec.get("cwd") and (Path(rec["cwd"]) / tmp).exists():
772
821
  (Path(rec["cwd"]) / tmp).unlink()
822
+ # The suite's private TMPDIR (see _boot). Derived from the label the
823
+ # same way _boot derived it, so old ledger records need no new field.
824
+ label = rec.get("label") or (Path(rec["log"]).stem if rec.get("log") else None)
825
+ if label:
826
+ shutil.rmtree(_suite_tmpdir(label), ignore_errors=True)
773
827
  if rec.get("sim_udid"):
774
828
  gone = _sim_teardown(rec["sim_udid"])
775
829
  actions.append(f"sim {rec['sim_udid'][:8]} " +
@@ -1194,6 +1248,9 @@ class _Handler(http.server.BaseHTTPRequestHandler):
1194
1248
 
1195
1249
  def cmd_serve():
1196
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)
1197
1254
  _Handler.broker = Broker()
1198
1255
  srv = http.server.ThreadingHTTPServer((BROKER_HOST, BROKER_PORT), _Handler)
1199
1256
 
@@ -1246,10 +1303,61 @@ def _ver_tuple(v):
1246
1303
  return (0,)
1247
1304
 
1248
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
+
1249
1356
  def _spawn_daemon():
1250
1357
  STATE_DIR.mkdir(parents=True, exist_ok=True)
1251
1358
  log = open(STATE_DIR / "daemon.log", "a")
1252
- 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))
1253
1361
 
1254
1362
 
1255
1363
  def _ensure():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/harbormaster",
3
- "version": "0.9.0",
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",