@simon-says-build/harbormaster 0.11.1 → 0.13.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 +37 -0
- package/harbormaster +144 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -137,3 +137,40 @@ stale marker means the service is gone, and clients spawn as before. Without
|
|
|
137
137
|
this, the broker belonged to whichever client happened to start it: on
|
|
138
138
|
simon-linux-1 that was a CI job, whose orphan cleanup killed the broker and
|
|
139
139
|
every emulator it ran for other jobs mid-suite. `python3 test/managed.py`.
|
|
140
|
+
|
|
141
|
+
## A project's own suite env (0.12.0)
|
|
142
|
+
|
|
143
|
+
`--fn-env` is per caller, so a setting every suite of a project needs had to
|
|
144
|
+
be repeated by every caller (session tests, e2e, agent runs), and the one that
|
|
145
|
+
forgot ran against production. A project now names it once, in
|
|
146
|
+
`harbormaster.env` next to its `firebase.json`:
|
|
147
|
+
|
|
148
|
+
# every emulator suite of this project runs under the TEST service account
|
|
149
|
+
GOOGLE_APPLICATION_CREDENTIALS=./impulse-test-bq.json
|
|
150
|
+
|
|
151
|
+
`KEY=VALUE` lines, `#` comments. A value starting with `./` or `../` is made
|
|
152
|
+
absolute against the project, since the emulator runs functions (and
|
|
153
|
+
extensions) in other directories. `acquire`: on top of the daemon's scrubbed
|
|
154
|
+
env, under the caller's explicit `--fn-env`. `exec`: on top of the caller's
|
|
155
|
+
ambient env, so a key exported in some shell can't leak into the suite.
|
|
156
|
+
`python3 test/project_env.py`.
|
|
157
|
+
|
|
158
|
+
## An idle simulator nobody leases is shut down (0.13.0)
|
|
159
|
+
|
|
160
|
+
The world sweep (every `FBPORTS_SWEEP_INTERVAL`, 5 min) now shuts down a
|
|
161
|
+
booted simulator that meets all three conditions:
|
|
162
|
+
|
|
163
|
+
- **no lease holds it**;
|
|
164
|
+
- **booted longer than `FBPORTS_IDLE_SIM_HOURS`** (default 6; `0` turns this
|
|
165
|
+
off), measured by its `launchd_sim`;
|
|
166
|
+
- **no app is running in it**.
|
|
167
|
+
|
|
168
|
+
It is shut down only, never deleted, so whoever booted it can boot it again.
|
|
169
|
+
|
|
170
|
+
On 2026-10-20 "Impulse-agent" had sat on the Mac Studio for 32 hours,
|
|
171
|
+
booted outside any lease by an app's pinned simulator name, holding two of
|
|
172
|
+
its ten cores with no app installed. Leases stopped only what they created.
|
|
173
|
+
|
|
174
|
+
`status` reports what the sweep last saw and did: `idleSims {booted, unleased,
|
|
175
|
+
shutDown[]}`. The CLI prints a `simulators:` line. Tested by
|
|
176
|
+
`python3 test/idle_sims.py`.
|
package/harbormaster
CHANGED
|
@@ -84,7 +84,7 @@ import uuid
|
|
|
84
84
|
from datetime import datetime, timezone
|
|
85
85
|
from pathlib import Path
|
|
86
86
|
|
|
87
|
-
VERSION = "0.
|
|
87
|
+
VERSION = "0.13.0"
|
|
88
88
|
|
|
89
89
|
# Overridable for tests / parallel installs; default is the shared user daemon.
|
|
90
90
|
# The dir keeps the old "firebase-ports" name: pre-rename binaries still installed
|
|
@@ -125,6 +125,11 @@ BOOT_BACKSTOP = 600 # only for a genuinely wedged boot; readiness is li
|
|
|
125
125
|
# How often the daemon sweeps the WORLD for resources that escaped the ledger:
|
|
126
126
|
# listeners on pool ports with no lease, orphaned firebase runtimes, stray sims.
|
|
127
127
|
SWEEP_INTERVAL = int(os.environ.get("FBPORTS_SWEEP_INTERVAL", "300"))
|
|
128
|
+
# A booted simulator NO lease holds, up this long with no app running in it, is
|
|
129
|
+
# shut down (never deleted: its owner can boot it again). 0 turns it off.
|
|
130
|
+
# 2026-10-20: "Impulse-agent", booted outside any lease by an app's pinned
|
|
131
|
+
# simulator name, held two of the Mac Studio's ten cores for 32 hours.
|
|
132
|
+
IDLE_SIM_HOURS = float(os.environ.get("FBPORTS_IDLE_SIM_HOURS", "6"))
|
|
128
133
|
# Process-name signatures of firebase-tools' own children. firebase-tools spawns
|
|
129
134
|
# these `detached: true` — their OWN process groups — so killpg on the suite can
|
|
130
135
|
# structurally never reach them (the v1 "killpg fixes it" assumption was wrong:
|
|
@@ -459,6 +464,57 @@ def _sim_inventory():
|
|
|
459
464
|
return None
|
|
460
465
|
|
|
461
466
|
|
|
467
|
+
def _etime_s(etime):
|
|
468
|
+
"""ps etime ([[dd-]hh:]mm:ss) in seconds, or None."""
|
|
469
|
+
try:
|
|
470
|
+
days, _, rest = etime.strip().rpartition("-")
|
|
471
|
+
parts = [int(x) for x in rest.split(":")]
|
|
472
|
+
while len(parts) < 3:
|
|
473
|
+
parts.insert(0, 0)
|
|
474
|
+
return (int(days) if days else 0) * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
|
|
475
|
+
except (ValueError, AttributeError):
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
_DEVICE_RE = re.compile(r"/CoreSimulator/Devices/([0-9A-Fa-f-]{36})/")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _booted_sims(ps_text):
|
|
483
|
+
"""{udid: {"upS": seconds its launchd_sim has run, "app": an app is running}}
|
|
484
|
+
for every booted simulator, read off one `ps -axo etime=,command=` listing.
|
|
485
|
+
A device's launchd_sim is its boot; a process under its
|
|
486
|
+
data/Containers/Bundle/Application is an app running in it."""
|
|
487
|
+
out = {}
|
|
488
|
+
for line in ps_text.splitlines():
|
|
489
|
+
etime, _, cmd = line.strip().partition(" ")
|
|
490
|
+
m = _DEVICE_RE.search(cmd)
|
|
491
|
+
if not m:
|
|
492
|
+
continue
|
|
493
|
+
udid = m.group(1).upper()
|
|
494
|
+
d = out.setdefault(udid, {"upS": None, "app": False})
|
|
495
|
+
if "launchd_sim" in cmd.split(" ", 1)[0]:
|
|
496
|
+
up = _etime_s(etime)
|
|
497
|
+
if up is not None:
|
|
498
|
+
d["upS"] = max(d["upS"] or 0, up)
|
|
499
|
+
if "/data/Containers/Bundle/Application/" in cmd:
|
|
500
|
+
d["app"] = True
|
|
501
|
+
return out
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def idle_unleased_sims(booted, leased, names, hours=IDLE_SIM_HOURS):
|
|
505
|
+
"""The booted simulators to shut down: no lease holds them, up longer than
|
|
506
|
+
`hours`, and no app running. Pure. `names` is {udid: name} (for the report)."""
|
|
507
|
+
if not hours or hours <= 0:
|
|
508
|
+
return []
|
|
509
|
+
out = []
|
|
510
|
+
for udid, d in booted.items():
|
|
511
|
+
if udid in leased or d.get("app") or d.get("upS") is None:
|
|
512
|
+
continue
|
|
513
|
+
if d["upS"] >= hours * 3600:
|
|
514
|
+
out.append({"udid": udid, "name": names.get(udid, ""), "upH": round(d["upS"] / 3600, 1)})
|
|
515
|
+
return out
|
|
516
|
+
|
|
517
|
+
|
|
462
518
|
def _group_alive(pgid):
|
|
463
519
|
"""Any member of the process group left? EPERM counts as alive."""
|
|
464
520
|
try:
|
|
@@ -699,7 +755,7 @@ class Broker:
|
|
|
699
755
|
# daemon's env — the functions emulator runtime inherits it, which is
|
|
700
756
|
# the only way a CALLER can set per-suite env (e.g. LLM_CACHE_DIR)
|
|
701
757
|
# when the daemon, not the caller, spawns the emulator.
|
|
702
|
-
spawn_env = {**os.environ, "TMPDIR": str(suite_tmp)}
|
|
758
|
+
spawn_env = {**os.environ, **project_env(cwd), "TMPDIR": str(suite_tmp)}
|
|
703
759
|
if extra_env:
|
|
704
760
|
spawn_env.update({str(k): str(v) for k, v in dict(extra_env).items()})
|
|
705
761
|
with open(log, "w") as lf:
|
|
@@ -997,7 +1053,10 @@ class Broker:
|
|
|
997
1053
|
"ports": l["ports"], "projectId": l.get("project_id"),
|
|
998
1054
|
"sim": l.get("sim_udid"), "expires": l.get("expires"),
|
|
999
1055
|
"label": l.get("label"), "log": l.get("log")}
|
|
1000
|
-
for l in self.leases.values()]
|
|
1056
|
+
for l in self.leases.values()],
|
|
1057
|
+
# What the idle-simulator sweep last saw and did (SSB's Hosts
|
|
1058
|
+
# card reads it: a leak is a number, not a chart to diagnose).
|
|
1059
|
+
"idleSims": getattr(self, "_idle_sims", None)}
|
|
1001
1060
|
|
|
1002
1061
|
def reap(self):
|
|
1003
1062
|
with self.lock:
|
|
@@ -1143,6 +1202,8 @@ class Broker:
|
|
|
1143
1202
|
and udid not in leased_udids):
|
|
1144
1203
|
findings.append({"kind": "sim", "udid": udid, "name": name,
|
|
1145
1204
|
"evict": udid in mine})
|
|
1205
|
+
if apply:
|
|
1206
|
+
self._sweep_idle_sims(inv)
|
|
1146
1207
|
if apply and findings:
|
|
1147
1208
|
pids = {f["pid"] for f in findings
|
|
1148
1209
|
if f["kind"] in ("listener", "orphan") and f.get("evict")}
|
|
@@ -1178,6 +1239,35 @@ class Broker:
|
|
|
1178
1239
|
+ f" — {f['cmd']}", flush=True)
|
|
1179
1240
|
return findings
|
|
1180
1241
|
|
|
1242
|
+
def _sweep_idle_sims(self, inv):
|
|
1243
|
+
"""Shut down booted simulators no lease holds that have sat idle past
|
|
1244
|
+
IDLE_SIM_HOURS. Shutdown only — the device and its data stay, and its
|
|
1245
|
+
owner boots it again. Recorded for status whatever it finds."""
|
|
1246
|
+
if not IDLE_SIM_HOURS or IDLE_SIM_HOURS <= 0:
|
|
1247
|
+
return
|
|
1248
|
+
try:
|
|
1249
|
+
r = subprocess.run(["ps", "-axo", "etime=,command="], capture_output=True, text=True, timeout=10)
|
|
1250
|
+
except (subprocess.SubprocessError, OSError):
|
|
1251
|
+
return
|
|
1252
|
+
booted = _booted_sims(r.stdout)
|
|
1253
|
+
leased = {str(l.get("sim_udid")).upper() for l in self.leases.values() if l.get("sim_udid")}
|
|
1254
|
+
names = {u.upper(): n for u, n in (inv or {}).items()}
|
|
1255
|
+
idle = idle_unleased_sims(booted, leased, names)
|
|
1256
|
+
done = []
|
|
1257
|
+
for s in idle:
|
|
1258
|
+
try:
|
|
1259
|
+
subprocess.run(["xcrun", "simctl", "shutdown", s["udid"]], capture_output=True, text=True, timeout=60)
|
|
1260
|
+
done.append(s)
|
|
1261
|
+
print(f"idle sim {s['name'] or '?'} ({s['udid'][:8]}) shut down: no lease, "
|
|
1262
|
+
f"booted {s['upH']}h, no app running", flush=True)
|
|
1263
|
+
except (subprocess.SubprocessError, OSError) as e:
|
|
1264
|
+
print(f"idle sim {s['udid'][:8]}: shutdown failed: {e}", flush=True)
|
|
1265
|
+
unleased = [u for u in booted if u not in leased]
|
|
1266
|
+
prev = getattr(self, "_idle_sims", None) or {}
|
|
1267
|
+
self._idle_sims = {"at": time.time(), "booted": len(booted), "unleased": len(unleased),
|
|
1268
|
+
"afterHours": IDLE_SIM_HOURS,
|
|
1269
|
+
"shutDown": (done + (prev.get("shutDown") or []))[:20]}
|
|
1270
|
+
|
|
1181
1271
|
def doctor(self, fix=False):
|
|
1182
1272
|
"""Report (and with fix=True, evict) everything the sweep can see —
|
|
1183
1273
|
the command that would have made 51 orphaned JVMs visible on day one."""
|
|
@@ -1333,6 +1423,46 @@ def _daemon_version():
|
|
|
1333
1423
|
return None
|
|
1334
1424
|
|
|
1335
1425
|
|
|
1426
|
+
# A PROJECT'S OWN SUITE ENV: <cwd>/harbormaster.env.
|
|
1427
|
+
#
|
|
1428
|
+
# --fn-env is per CALLER, so a setting every suite of a project needs (the
|
|
1429
|
+
# credentials its emulator must run under, say) had to be repeated by every
|
|
1430
|
+
# caller — session tests, e2e, agent runs — and the one that forgot ran
|
|
1431
|
+
# against production. impulse-functions (2026-10-16): its BigQuery export
|
|
1432
|
+
# extensions run under the Extensions emulator and stream to whatever project
|
|
1433
|
+
# the ambient credentials can reach; only a test service account keeps that
|
|
1434
|
+
# test-only. So the project names it once, in the repo:
|
|
1435
|
+
#
|
|
1436
|
+
# # harbormaster.env, next to firebase.json
|
|
1437
|
+
# GOOGLE_APPLICATION_CREDENTIALS=./impulse-test-bq.json
|
|
1438
|
+
#
|
|
1439
|
+
# KEY=VALUE lines; blank lines and `#` comments skipped; a value starting
|
|
1440
|
+
# with ./ or ../ is a path relative to the project, made absolute (the
|
|
1441
|
+
# emulator's functions run in other directories). acquire: on top of the
|
|
1442
|
+
# daemon's (scrubbed) env, UNDER the caller's explicit --fn-env. exec: on top
|
|
1443
|
+
# of the caller's ambient env, which is not a choice. Pure; missing file = {}.
|
|
1444
|
+
PROJECT_ENV_FILE = "harbormaster.env"
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def project_env(cwd):
|
|
1448
|
+
f = Path(cwd) / PROJECT_ENV_FILE
|
|
1449
|
+
if not f.is_file():
|
|
1450
|
+
return {}
|
|
1451
|
+
out = {}
|
|
1452
|
+
for raw in f.read_text().splitlines():
|
|
1453
|
+
line = raw.strip()
|
|
1454
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
1455
|
+
continue
|
|
1456
|
+
k, v = line.split("=", 1)
|
|
1457
|
+
k, v = k.strip(), v.strip()
|
|
1458
|
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in "'\"":
|
|
1459
|
+
v = v[1:-1]
|
|
1460
|
+
if v.startswith("./") or v.startswith("../"):
|
|
1461
|
+
v = str((Path(cwd) / v).resolve())
|
|
1462
|
+
out[k] = v
|
|
1463
|
+
return out
|
|
1464
|
+
|
|
1465
|
+
|
|
1336
1466
|
def _ver_tuple(v):
|
|
1337
1467
|
try:
|
|
1338
1468
|
return tuple(int(x) for x in str(v).split("."))
|
|
@@ -1626,7 +1756,10 @@ def main():
|
|
|
1626
1756
|
if only:
|
|
1627
1757
|
run += ["--only", ",".join(only)]
|
|
1628
1758
|
run.append(script)
|
|
1629
|
-
|
|
1759
|
+
# The project's env beats the caller's AMBIENT env here: a key
|
|
1760
|
+
# exported in some shell for a one-off script is exactly what
|
|
1761
|
+
# harbormaster.env exists to keep out of the suite (project_env).
|
|
1762
|
+
rc = subprocess.call(run, cwd=cwd, env={**os.environ, **project_env(cwd)})
|
|
1630
1763
|
finally:
|
|
1631
1764
|
try:
|
|
1632
1765
|
_call("POST", "/release", {"leaseId": lid})
|
|
@@ -1685,7 +1818,13 @@ def main():
|
|
|
1685
1818
|
if not _up():
|
|
1686
1819
|
print("daemon not running.")
|
|
1687
1820
|
return
|
|
1688
|
-
|
|
1821
|
+
st = _call("GET", "/status")[1]
|
|
1822
|
+
leases = st.get("leases", [])
|
|
1823
|
+
idle = st.get("idleSims")
|
|
1824
|
+
if idle:
|
|
1825
|
+
print(f"simulators: {idle['booted']} booted, {idle['unleased']} with no lease"
|
|
1826
|
+
+ (f"; shut down idle: " + ", ".join(f"{x['name'] or x['udid'][:8]} ({x['upH']}h)" for x in idle["shutDown"][:5])
|
|
1827
|
+
if idle.get("shutDown") else ""))
|
|
1689
1828
|
if not leases:
|
|
1690
1829
|
print("nothing out.")
|
|
1691
1830
|
return
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/harbormaster",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|