@simon-says-build/harbormaster 0.12.0 → 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 +20 -0
  2. package/harbormaster +99 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -154,3 +154,23 @@ extensions) in other directories. `acquire`: on top of the daemon's scrubbed
154
154
  env, under the caller's explicit `--fn-env`. `exec`: on top of the caller's
155
155
  ambient env, so a key exported in some shell can't leak into the suite.
156
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.12.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:
@@ -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."""
@@ -1728,7 +1818,13 @@ def main():
1728
1818
  if not _up():
1729
1819
  print("daemon not running.")
1730
1820
  return
1731
- 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 ""))
1732
1828
  if not leases:
1733
1829
  print("nothing out.")
1734
1830
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/harbormaster",
3
- "version": "0.12.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",