@simon-says-build/harbormaster 0.12.0 → 0.14.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 +45 -0
  2. package/harbormaster +194 -3
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -154,3 +154,48 @@ 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`.
177
+
178
+ ## An idb companion whose simulator is gone is ended (0.14.0)
179
+
180
+ idb starts one `idb_companion --udid <UDID>` per simulator it drives, with a
181
+ socket at `/tmp/idb/<UDID>_companion.sock`. A lease deletes its simulator, but
182
+ nothing ended that simulator's companion. On 2026-10-20 the Mac Studio had 19
183
+ companions running for devices that no longer existed, some from Oct 19, and
184
+ 109 stale sockets. (A wedged companion also cost a walk2gether run a phone the
185
+ same day; the driver now restarts one that refuses, simon-says #1782.)
186
+
187
+ The world sweep now ends a companion that meets both conditions:
188
+
189
+ - **its UDID is not in `simctl list`**, read again just before ending it;
190
+ - **it is older than 10 minutes**, so a device created after the inventory
191
+ read keeps its companion.
192
+
193
+ It also removes a socket for a gone device when no companion holds it and the
194
+ file is older than 10 minutes. Nothing else in the directory is touched
195
+ (`FBPORTS_IDB_DIR`, default `/tmp/idb`). When `simctl list` fails or comes back
196
+ empty, the sweep skips this step: an unreadable inventory never counts as
197
+ "every device is gone".
198
+
199
+ `status` reports `companions {running, ended, socketsRemoved}`, and the CLI
200
+ prints an `idb companions:` line when the last sweep ended or removed anything.
201
+ Tested by `python3 test/companions.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.14.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,19 @@ 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"))
133
+ # idb's per-simulator companions (0.14.0): one `idb_companion --udid <UDID>`
134
+ # and its /tmp/idb/<UDID>_companion.sock per device it ever drove. A lease
135
+ # deletes its simulator; nothing ended the companion. One older than the grace
136
+ # whose device no longer exists is ended, and a socket for a gone device that no
137
+ # companion holds is removed. The grace keeps a just-created device (newer than
138
+ # the inventory read) out of it.
139
+ IDB_SOCK_DIR = os.environ.get("FBPORTS_IDB_DIR", "/tmp/idb")
140
+ COMPANION_GRACE_S = 600
128
141
  # Process-name signatures of firebase-tools' own children. firebase-tools spawns
129
142
  # these `detached: true` — their OWN process groups — so killpg on the suite can
130
143
  # structurally never reach them (the v1 "killpg fixes it" assumption was wrong:
@@ -459,6 +472,98 @@ def _sim_inventory():
459
472
  return None
460
473
 
461
474
 
475
+ def _etime_s(etime):
476
+ """ps etime ([[dd-]hh:]mm:ss) in seconds, or None."""
477
+ try:
478
+ days, _, rest = etime.strip().rpartition("-")
479
+ parts = [int(x) for x in rest.split(":")]
480
+ while len(parts) < 3:
481
+ parts.insert(0, 0)
482
+ return (int(days) if days else 0) * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
483
+ except (ValueError, AttributeError):
484
+ return None
485
+
486
+
487
+ _DEVICE_RE = re.compile(r"/CoreSimulator/Devices/([0-9A-Fa-f-]{36})/")
488
+
489
+
490
+ def _booted_sims(ps_text):
491
+ """{udid: {"upS": seconds its launchd_sim has run, "app": an app is running}}
492
+ for every booted simulator, read off one `ps -axo etime=,command=` listing.
493
+ A device's launchd_sim is its boot; a process under its
494
+ data/Containers/Bundle/Application is an app running in it."""
495
+ out = {}
496
+ for line in ps_text.splitlines():
497
+ etime, _, cmd = line.strip().partition(" ")
498
+ m = _DEVICE_RE.search(cmd)
499
+ if not m:
500
+ continue
501
+ udid = m.group(1).upper()
502
+ d = out.setdefault(udid, {"upS": None, "app": False})
503
+ if "launchd_sim" in cmd.split(" ", 1)[0]:
504
+ up = _etime_s(etime)
505
+ if up is not None:
506
+ d["upS"] = max(d["upS"] or 0, up)
507
+ if "/data/Containers/Bundle/Application/" in cmd:
508
+ d["app"] = True
509
+ return out
510
+
511
+
512
+ def idle_unleased_sims(booted, leased, names, hours=IDLE_SIM_HOURS):
513
+ """The booted simulators to shut down: no lease holds them, up longer than
514
+ `hours`, and no app running. Pure. `names` is {udid: name} (for the report)."""
515
+ if not hours or hours <= 0:
516
+ return []
517
+ out = []
518
+ for udid, d in booted.items():
519
+ if udid in leased or d.get("app") or d.get("upS") is None:
520
+ continue
521
+ if d["upS"] >= hours * 3600:
522
+ out.append({"udid": udid, "name": names.get(udid, ""), "upH": round(d["upS"] / 3600, 1)})
523
+ return out
524
+
525
+
526
+ _COMPANION_UDID_RE = re.compile(r"--udid[ =]([0-9A-Fa-f-]{36})")
527
+ _SOCK_RE = re.compile(r"^([0-9A-Fa-f-]{36})_companion\.sock$")
528
+
529
+
530
+ def _companions(ps_text):
531
+ """[{pid, udid, upS}] for every idb_companion bound to one simulator, read off
532
+ one `ps -xo pid=,etime=,command=` listing."""
533
+ out = []
534
+ for line in ps_text.splitlines():
535
+ parts = line.split(None, 2)
536
+ if len(parts) < 3 or "idb_companion" not in parts[2].split(" ", 1)[0]:
537
+ continue
538
+ m = _COMPANION_UDID_RE.search(parts[2])
539
+ if not m:
540
+ continue
541
+ try:
542
+ pid = int(parts[0])
543
+ except ValueError:
544
+ continue
545
+ out.append({"pid": pid, "udid": m.group(1).upper(), "upS": _etime_s(parts[1])})
546
+ return out
547
+
548
+
549
+ def dead_companions(companions, known, grace=COMPANION_GRACE_S):
550
+ """The companions to end: their device is not in `known` (every simulator's
551
+ UDID, upper-case) and they are older than `grace`. Pure."""
552
+ return [c for c in companions
553
+ if c["udid"] not in known and c.get("upS") is not None and c["upS"] >= grace]
554
+
555
+
556
+ def stale_companion_sockets(names, known, live):
557
+ """The socket file names to remove: a device not in `known` that no live
558
+ companion (`live`, UDIDs) holds. Pure."""
559
+ out = []
560
+ for n in names:
561
+ m = _SOCK_RE.match(n)
562
+ if m and m.group(1).upper() not in known and m.group(1).upper() not in live:
563
+ out.append(n)
564
+ return out
565
+
566
+
462
567
  def _group_alive(pgid):
463
568
  """Any member of the process group left? EPERM counts as alive."""
464
569
  try:
@@ -997,7 +1102,11 @@ class Broker:
997
1102
  "ports": l["ports"], "projectId": l.get("project_id"),
998
1103
  "sim": l.get("sim_udid"), "expires": l.get("expires"),
999
1104
  "label": l.get("label"), "log": l.get("log")}
1000
- for l in self.leases.values()]}
1105
+ for l in self.leases.values()],
1106
+ # What the idle-simulator sweep last saw and did (SSB's Hosts
1107
+ # card reads it: a leak is a number, not a chart to diagnose).
1108
+ "idleSims": getattr(self, "_idle_sims", None),
1109
+ "companions": getattr(self, "_companions", None)}
1001
1110
 
1002
1111
  def reap(self):
1003
1112
  with self.lock:
@@ -1143,6 +1252,9 @@ class Broker:
1143
1252
  and udid not in leased_udids):
1144
1253
  findings.append({"kind": "sim", "udid": udid, "name": name,
1145
1254
  "evict": udid in mine})
1255
+ if apply:
1256
+ self._sweep_idle_sims(inv)
1257
+ self._sweep_companions(inv)
1146
1258
  if apply and findings:
1147
1259
  pids = {f["pid"] for f in findings
1148
1260
  if f["kind"] in ("listener", "orphan") and f.get("evict")}
@@ -1178,6 +1290,75 @@ class Broker:
1178
1290
  + f" — {f['cmd']}", flush=True)
1179
1291
  return findings
1180
1292
 
1293
+ def _sweep_idle_sims(self, inv):
1294
+ """Shut down booted simulators no lease holds that have sat idle past
1295
+ IDLE_SIM_HOURS. Shutdown only — the device and its data stay, and its
1296
+ owner boots it again. Recorded for status whatever it finds."""
1297
+ if not IDLE_SIM_HOURS or IDLE_SIM_HOURS <= 0:
1298
+ return
1299
+ try:
1300
+ r = subprocess.run(["ps", "-axo", "etime=,command="], capture_output=True, text=True, timeout=10)
1301
+ except (subprocess.SubprocessError, OSError):
1302
+ return
1303
+ booted = _booted_sims(r.stdout)
1304
+ leased = {str(l.get("sim_udid")).upper() for l in self.leases.values() if l.get("sim_udid")}
1305
+ names = {u.upper(): n for u, n in (inv or {}).items()}
1306
+ idle = idle_unleased_sims(booted, leased, names)
1307
+ done = []
1308
+ for s in idle:
1309
+ try:
1310
+ subprocess.run(["xcrun", "simctl", "shutdown", s["udid"]], capture_output=True, text=True, timeout=60)
1311
+ done.append(s)
1312
+ print(f"idle sim {s['name'] or '?'} ({s['udid'][:8]}) shut down: no lease, "
1313
+ f"booted {s['upH']}h, no app running", flush=True)
1314
+ except (subprocess.SubprocessError, OSError) as e:
1315
+ print(f"idle sim {s['udid'][:8]}: shutdown failed: {e}", flush=True)
1316
+ unleased = [u for u in booted if u not in leased]
1317
+ prev = getattr(self, "_idle_sims", None) or {}
1318
+ self._idle_sims = {"at": time.time(), "booted": len(booted), "unleased": len(unleased),
1319
+ "afterHours": IDLE_SIM_HOURS,
1320
+ "shutDown": (done + (prev.get("shutDown") or []))[:20]}
1321
+
1322
+ def _sweep_companions(self, inv):
1323
+ """End idb companions whose simulator is gone and remove their sockets.
1324
+ Skipped when the inventory is indeterminate: an empty or failed
1325
+ `simctl list` must never read as "every device is gone"."""
1326
+ if not inv:
1327
+ return
1328
+ try:
1329
+ r = subprocess.run(["ps", "-xo", "pid=,etime=,command="], capture_output=True, text=True, timeout=10)
1330
+ except (subprocess.SubprocessError, OSError):
1331
+ return
1332
+ comps = _companions(r.stdout)
1333
+ dead = dead_companions(comps, {u.upper() for u in inv})
1334
+ if dead:
1335
+ # Re-read: a device created since the first read keeps its companion.
1336
+ again = _sim_inventory()
1337
+ dead = dead_companions(dead, {u.upper() for u in again}) if again else []
1338
+ killed = sorted(_kill_wave({c["pid"] for c in dead})) if dead else []
1339
+ if killed:
1340
+ print(f"idb companions ended (device gone): {len(killed)} — "
1341
+ + ", ".join(c["udid"][:8] for c in dead[:8]), flush=True)
1342
+ live = {c["udid"] for c in comps if c["pid"] not in set(killed)}
1343
+ removed = 0
1344
+ try:
1345
+ names = os.listdir(IDB_SOCK_DIR)
1346
+ except OSError:
1347
+ names = []
1348
+ now = time.time()
1349
+ for n in stale_companion_sockets(names, {u.upper() for u in inv}, live):
1350
+ path = os.path.join(IDB_SOCK_DIR, n)
1351
+ try:
1352
+ if now - os.lstat(path).st_mtime >= COMPANION_GRACE_S:
1353
+ os.unlink(path)
1354
+ removed += 1
1355
+ except OSError:
1356
+ pass
1357
+ if removed:
1358
+ print(f"idb sockets removed (device gone): {removed}", flush=True)
1359
+ self._companions = {"at": now, "running": len(comps), "ended": len(killed),
1360
+ "socketsRemoved": removed}
1361
+
1181
1362
  def doctor(self, fix=False):
1182
1363
  """Report (and with fix=True, evict) everything the sweep can see —
1183
1364
  the command that would have made 51 orphaned JVMs visible on day one."""
@@ -1728,7 +1909,17 @@ def main():
1728
1909
  if not _up():
1729
1910
  print("daemon not running.")
1730
1911
  return
1731
- leases = _call("GET", "/status")[1].get("leases", [])
1912
+ st = _call("GET", "/status")[1]
1913
+ leases = st.get("leases", [])
1914
+ idle = st.get("idleSims")
1915
+ if idle:
1916
+ print(f"simulators: {idle['booted']} booted, {idle['unleased']} with no lease"
1917
+ + (f"; shut down idle: " + ", ".join(f"{x['name'] or x['udid'][:8]} ({x['upH']}h)" for x in idle["shutDown"][:5])
1918
+ if idle.get("shutDown") else ""))
1919
+ comp = st.get("companions")
1920
+ if comp and (comp.get("ended") or comp.get("socketsRemoved")):
1921
+ print(f"idb companions: {comp['running']} running; last sweep ended {comp['ended']}"
1922
+ f" and removed {comp['socketsRemoved']} socket(s) for gone devices")
1732
1923
  if not leases:
1733
1924
  print("nothing out.")
1734
1925
  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.14.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",
@@ -27,11 +27,11 @@
27
27
  "license": "MIT",
28
28
  "repository": {
29
29
  "type": "git",
30
- "url": "git+https://github.com/simon-says-build/harbormaster.git"
30
+ "url": "git+https://github.com/simon-says-studio/harbormaster.git"
31
31
  },
32
- "homepage": "https://github.com/simon-says-build/harbormaster#readme",
32
+ "homepage": "https://github.com/simon-says-studio/harbormaster#readme",
33
33
  "bugs": {
34
- "url": "https://github.com/simon-says-build/harbormaster/issues"
34
+ "url": "https://github.com/simon-says-studio/harbormaster/issues"
35
35
  },
36
36
  "os": [
37
37
  "darwin",