@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.
Files changed (3) hide show
  1. package/README.md +37 -0
  2. package/harbormaster +144 -5
  3. 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.11.1"
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
- rc = subprocess.call(run, cwd=cwd)
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
- leases = _call("GET", "/status")[1].get("leases", [])
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.11.1",
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",