@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.
- package/README.md +25 -0
- package/harbormaster +97 -2
- 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.
|
|
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.
|
|
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-
|
|
30
|
+
"url": "git+https://github.com/simon-says-studio/harbormaster.git"
|
|
31
31
|
},
|
|
32
|
-
"homepage": "https://github.com/simon-says-
|
|
32
|
+
"homepage": "https://github.com/simon-says-studio/harbormaster#readme",
|
|
33
33
|
"bugs": {
|
|
34
|
-
"url": "https://github.com/simon-says-
|
|
34
|
+
"url": "https://github.com/simon-says-studio/harbormaster/issues"
|
|
35
35
|
},
|
|
36
36
|
"os": [
|
|
37
37
|
"darwin",
|