@simon-says-build/harbormaster 0.13.0 → 0.14.1

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 +35 -0
  2. package/harbormaster +111 -6
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -155,6 +155,16 @@ 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
157
 
158
+ ## A lease never reads the hosting config it doesn't start (0.14.1)
159
+
160
+ A lease whose `--only` leaves hosting out boots from a config with no
161
+ `hosting` block. firebase-tools 15.33 reads `hosting` even when it starts no
162
+ hosting emulator, and a web-framework target (`"source": "."`, Next.js)
163
+ makes it refuse every emulator without the `webframeworks` experiment.
164
+ Tapuat's lease on simon-linux-1 died there on 2026-10-21; 15.28 on the
165
+ studio never checked. An `--only` that asks for hosting, or no `--only`,
166
+ keeps it. `python3 test/injected_config.py`.
167
+
158
168
  ## An idle simulator nobody leases is shut down (0.13.0)
159
169
 
160
170
  The world sweep (every `FBPORTS_SWEEP_INTERVAL`, 5 min) now shuts down a
@@ -174,3 +184,28 @@ its ten cores with no app installed. Leases stopped only what they created.
174
184
  `status` reports what the sweep last saw and did: `idleSims {booted, unleased,
175
185
  shutDown[]}`. The CLI prints a `simulators:` line. Tested by
176
186
  `python3 test/idle_sims.py`.
187
+
188
+ ## An idb companion whose simulator is gone is ended (0.14.0)
189
+
190
+ idb starts one `idb_companion --udid <UDID>` per simulator it drives, with a
191
+ socket at `/tmp/idb/<UDID>_companion.sock`. A lease deletes its simulator, but
192
+ nothing ended that simulator's companion. On 2026-10-20 the Mac Studio had 19
193
+ companions running for devices that no longer existed, some from Oct 19, and
194
+ 109 stale sockets. (A wedged companion also cost a walk2gether run a phone the
195
+ same day; the driver now restarts one that refuses, simon-says #1782.)
196
+
197
+ The world sweep now ends a companion that meets both conditions:
198
+
199
+ - **its UDID is not in `simctl list`**, read again just before ending it;
200
+ - **it is older than 10 minutes**, so a device created after the inventory
201
+ read keeps its companion.
202
+
203
+ It also removes a socket for a gone device when no companion holds it and the
204
+ file is older than 10 minutes. Nothing else in the directory is touched
205
+ (`FBPORTS_IDB_DIR`, default `/tmp/idb`). When `simctl list` fails or comes back
206
+ empty, the sweep skips this step: an unreadable inventory never counts as
207
+ "every device is gone".
208
+
209
+ `status` reports `companions {running, ended, socketsRemoved}`, and the CLI
210
+ prints an `idb companions:` line when the last sweep ended or removed anything.
211
+ 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.1"
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:
@@ -353,11 +361,21 @@ def _service_ports(ports):
353
361
  return {s: p for s, p in ports.items() if s not in ("ui", "hub", "logging")}
354
362
 
355
363
 
356
- def _write_injected_config(cwd, ports):
364
+ def _write_injected_config(cwd, ports, only=None):
357
365
  """Set ports on the emulators the portless firebase.json DECLARES (+ hub/logging).
358
- Never adds emulators the project doesn't use. Returns the temp config basename."""
366
+ Never adds emulators the project doesn't use. Returns the temp config basename.
367
+
368
+ With an --only that leaves hosting out, the `hosting` block goes too: no
369
+ emulator a lease starts serves it, and firebase-tools still READS it. A
370
+ web-framework target (`"source": "."`, a Next.js app) makes 15.33 refuse to
371
+ start ANY emulator without the webframeworks experiment — Tapuat's run
372
+ 20261021-130009-exploration-everyday-user died there on simon-linux-1 while
373
+ 15.28 on the studio never checked."""
359
374
  cfg = json.loads((Path(cwd) / "firebase.json").read_text())
360
375
  emu = cfg.setdefault("emulators", {})
376
+ if only and "hosting" not in only:
377
+ cfg.pop("hosting", None)
378
+ emu.pop("hosting", None)
361
379
  for svc in [k for k in emu if k != "singleProjectMode"]:
362
380
  if svc in ports and isinstance(emu[svc], dict):
363
381
  emu[svc]["port"] = ports[svc]
@@ -515,6 +533,47 @@ def idle_unleased_sims(booted, leased, names, hours=IDLE_SIM_HOURS):
515
533
  return out
516
534
 
517
535
 
536
+ _COMPANION_UDID_RE = re.compile(r"--udid[ =]([0-9A-Fa-f-]{36})")
537
+ _SOCK_RE = re.compile(r"^([0-9A-Fa-f-]{36})_companion\.sock$")
538
+
539
+
540
+ def _companions(ps_text):
541
+ """[{pid, udid, upS}] for every idb_companion bound to one simulator, read off
542
+ one `ps -xo pid=,etime=,command=` listing."""
543
+ out = []
544
+ for line in ps_text.splitlines():
545
+ parts = line.split(None, 2)
546
+ if len(parts) < 3 or "idb_companion" not in parts[2].split(" ", 1)[0]:
547
+ continue
548
+ m = _COMPANION_UDID_RE.search(parts[2])
549
+ if not m:
550
+ continue
551
+ try:
552
+ pid = int(parts[0])
553
+ except ValueError:
554
+ continue
555
+ out.append({"pid": pid, "udid": m.group(1).upper(), "upS": _etime_s(parts[1])})
556
+ return out
557
+
558
+
559
+ def dead_companions(companions, known, grace=COMPANION_GRACE_S):
560
+ """The companions to end: their device is not in `known` (every simulator's
561
+ UDID, upper-case) and they are older than `grace`. Pure."""
562
+ return [c for c in companions
563
+ if c["udid"] not in known and c.get("upS") is not None and c["upS"] >= grace]
564
+
565
+
566
+ def stale_companion_sockets(names, known, live):
567
+ """The socket file names to remove: a device not in `known` that no live
568
+ companion (`live`, UDIDs) holds. Pure."""
569
+ out = []
570
+ for n in names:
571
+ m = _SOCK_RE.match(n)
572
+ if m and m.group(1).upper() not in known and m.group(1).upper() not in live:
573
+ out.append(n)
574
+ return out
575
+
576
+
518
577
  def _group_alive(pgid):
519
578
  """Any member of the process group left? EPERM counts as alive."""
520
579
  try:
@@ -726,7 +785,7 @@ class Broker:
726
785
 
727
786
  def _boot(self, cwd, block, only, project_id, label, on_spawn=None, extra_env=None):
728
787
  all_ports = _block_ports(block)
729
- tmp = _write_injected_config(cwd, all_ports)
788
+ tmp = _write_injected_config(cwd, all_ports, only)
730
789
  # Readiness/liveness track ONLY the data services actually started (respect
731
790
  # --only, else the project's declared emulators; skip ui/hub/logging) — else
732
791
  # we'd wait forever for ports (functions, pubsub, …) that never open.
@@ -1056,7 +1115,8 @@ class Broker:
1056
1115
  for l in self.leases.values()],
1057
1116
  # What the idle-simulator sweep last saw and did (SSB's Hosts
1058
1117
  # card reads it: a leak is a number, not a chart to diagnose).
1059
- "idleSims": getattr(self, "_idle_sims", None)}
1118
+ "idleSims": getattr(self, "_idle_sims", None),
1119
+ "companions": getattr(self, "_companions", None)}
1060
1120
 
1061
1121
  def reap(self):
1062
1122
  with self.lock:
@@ -1204,6 +1264,7 @@ class Broker:
1204
1264
  "evict": udid in mine})
1205
1265
  if apply:
1206
1266
  self._sweep_idle_sims(inv)
1267
+ self._sweep_companions(inv)
1207
1268
  if apply and findings:
1208
1269
  pids = {f["pid"] for f in findings
1209
1270
  if f["kind"] in ("listener", "orphan") and f.get("evict")}
@@ -1268,6 +1329,46 @@ class Broker:
1268
1329
  "afterHours": IDLE_SIM_HOURS,
1269
1330
  "shutDown": (done + (prev.get("shutDown") or []))[:20]}
1270
1331
 
1332
+ def _sweep_companions(self, inv):
1333
+ """End idb companions whose simulator is gone and remove their sockets.
1334
+ Skipped when the inventory is indeterminate: an empty or failed
1335
+ `simctl list` must never read as "every device is gone"."""
1336
+ if not inv:
1337
+ return
1338
+ try:
1339
+ r = subprocess.run(["ps", "-xo", "pid=,etime=,command="], capture_output=True, text=True, timeout=10)
1340
+ except (subprocess.SubprocessError, OSError):
1341
+ return
1342
+ comps = _companions(r.stdout)
1343
+ dead = dead_companions(comps, {u.upper() for u in inv})
1344
+ if dead:
1345
+ # Re-read: a device created since the first read keeps its companion.
1346
+ again = _sim_inventory()
1347
+ dead = dead_companions(dead, {u.upper() for u in again}) if again else []
1348
+ killed = sorted(_kill_wave({c["pid"] for c in dead})) if dead else []
1349
+ if killed:
1350
+ print(f"idb companions ended (device gone): {len(killed)} — "
1351
+ + ", ".join(c["udid"][:8] for c in dead[:8]), flush=True)
1352
+ live = {c["udid"] for c in comps if c["pid"] not in set(killed)}
1353
+ removed = 0
1354
+ try:
1355
+ names = os.listdir(IDB_SOCK_DIR)
1356
+ except OSError:
1357
+ names = []
1358
+ now = time.time()
1359
+ for n in stale_companion_sockets(names, {u.upper() for u in inv}, live):
1360
+ path = os.path.join(IDB_SOCK_DIR, n)
1361
+ try:
1362
+ if now - os.lstat(path).st_mtime >= COMPANION_GRACE_S:
1363
+ os.unlink(path)
1364
+ removed += 1
1365
+ except OSError:
1366
+ pass
1367
+ if removed:
1368
+ print(f"idb sockets removed (device gone): {removed}", flush=True)
1369
+ self._companions = {"at": now, "running": len(comps), "ended": len(killed),
1370
+ "socketsRemoved": removed}
1371
+
1271
1372
  def doctor(self, fix=False):
1272
1373
  """Report (and with fix=True, evict) everything the sweep can see —
1273
1374
  the command that would have made 51 orphaned JVMs visible on day one."""
@@ -1750,7 +1851,7 @@ def main():
1750
1851
  me = os.getpid() # this process owns the reservation for the run
1751
1852
  res = ok(_call("POST", "/allocate", {"owner_pid": me, "owner_start": _proc_start(me), "label": "exec"}), "exec")
1752
1853
  base, lid = res["base"], res["leaseId"]
1753
- tmp = _write_injected_config(cwd, _block_ports(base))
1854
+ tmp = _write_injected_config(cwd, _block_ports(base), only)
1754
1855
  try:
1755
1856
  run = ["firebase", "emulators:exec", "--config", tmp]
1756
1857
  if only:
@@ -1825,6 +1926,10 @@ def main():
1825
1926
  print(f"simulators: {idle['booted']} booted, {idle['unleased']} with no lease"
1826
1927
  + (f"; shut down idle: " + ", ".join(f"{x['name'] or x['udid'][:8]} ({x['upH']}h)" for x in idle["shutDown"][:5])
1827
1928
  if idle.get("shutDown") else ""))
1929
+ comp = st.get("companions")
1930
+ if comp and (comp.get("ended") or comp.get("socketsRemoved")):
1931
+ print(f"idb companions: {comp['running']} running; last sweep ended {comp['ended']}"
1932
+ f" and removed {comp['socketsRemoved']} socket(s) for gone devices")
1828
1933
  if not leases:
1829
1934
  print("nothing out.")
1830
1935
  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.1",
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",