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