@simon-says-build/harbormaster 0.13.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 +25 -0
  2. package/harbormaster +97 -2
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -174,3 +174,28 @@ its ten cores with no app installed. Leases stopped only what they created.
174
174
  `status` reports what the sweep last saw and did: `idleSims {booted, unleased,
175
175
  shutDown[]}`. The CLI prints a `simulators:` line. Tested by
176
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.13.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
@@ -130,6 +130,14 @@ SWEEP_INTERVAL = int(os.environ.get("FBPORTS_SWEEP_INTERVAL", "300"))
130
130
  # 2026-10-20: "Impulse-agent", booted outside any lease by an app's pinned
131
131
  # simulator name, held two of the Mac Studio's ten cores for 32 hours.
132
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
133
141
  # Process-name signatures of firebase-tools' own children. firebase-tools spawns
134
142
  # these `detached: true` — their OWN process groups — so killpg on the suite can
135
143
  # structurally never reach them (the v1 "killpg fixes it" assumption was wrong:
@@ -515,6 +523,47 @@ def idle_unleased_sims(booted, leased, names, hours=IDLE_SIM_HOURS):
515
523
  return out
516
524
 
517
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
+
518
567
  def _group_alive(pgid):
519
568
  """Any member of the process group left? EPERM counts as alive."""
520
569
  try:
@@ -1056,7 +1105,8 @@ class Broker:
1056
1105
  for l in self.leases.values()],
1057
1106
  # What the idle-simulator sweep last saw and did (SSB's Hosts
1058
1107
  # card reads it: a leak is a number, not a chart to diagnose).
1059
- "idleSims": getattr(self, "_idle_sims", None)}
1108
+ "idleSims": getattr(self, "_idle_sims", None),
1109
+ "companions": getattr(self, "_companions", None)}
1060
1110
 
1061
1111
  def reap(self):
1062
1112
  with self.lock:
@@ -1204,6 +1254,7 @@ class Broker:
1204
1254
  "evict": udid in mine})
1205
1255
  if apply:
1206
1256
  self._sweep_idle_sims(inv)
1257
+ self._sweep_companions(inv)
1207
1258
  if apply and findings:
1208
1259
  pids = {f["pid"] for f in findings
1209
1260
  if f["kind"] in ("listener", "orphan") and f.get("evict")}
@@ -1268,6 +1319,46 @@ class Broker:
1268
1319
  "afterHours": IDLE_SIM_HOURS,
1269
1320
  "shutDown": (done + (prev.get("shutDown") or []))[:20]}
1270
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
+
1271
1362
  def doctor(self, fix=False):
1272
1363
  """Report (and with fix=True, evict) everything the sweep can see —
1273
1364
  the command that would have made 51 orphaned JVMs visible on day one."""
@@ -1825,6 +1916,10 @@ def main():
1825
1916
  print(f"simulators: {idle['booted']} booted, {idle['unleased']} with no lease"
1826
1917
  + (f"; shut down idle: " + ", ".join(f"{x['name'] or x['udid'][:8]} ({x['upH']}h)" for x in idle["shutDown"][:5])
1827
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")
1828
1923
  if not leases:
1829
1924
  print("nothing out.")
1830
1925
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/harbormaster",
3
- "version": "0.13.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",