@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.
- package/README.md +35 -0
- package/harbormaster +142 -7
- 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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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",
|