@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.
- package/README.md +22 -0
- package/harbormaster +113 -5
- 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.
|
|
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
|
|
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 =
|
|
700
|
+
spawn_env = {**os.environ, "TMPDIR": str(suite_tmp)}
|
|
652
701
|
if extra_env:
|
|
653
|
-
spawn_env
|
|
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,
|
|
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.
|
|
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",
|