@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.
- package/README.md +45 -0
- package/harbormaster +194 -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.
|
|
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
|
-
|
|
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.
|
|
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",
|