@simon-says-build/harbormaster 0.12.0 → 0.13.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 +20 -0
- package/harbormaster +99 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -154,3 +154,23 @@ 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`.
|
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.13.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,11 @@ 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"))
|
|
128
133
|
# Process-name signatures of firebase-tools' own children. firebase-tools spawns
|
|
129
134
|
# these `detached: true` — their OWN process groups — so killpg on the suite can
|
|
130
135
|
# structurally never reach them (the v1 "killpg fixes it" assumption was wrong:
|
|
@@ -459,6 +464,57 @@ def _sim_inventory():
|
|
|
459
464
|
return None
|
|
460
465
|
|
|
461
466
|
|
|
467
|
+
def _etime_s(etime):
|
|
468
|
+
"""ps etime ([[dd-]hh:]mm:ss) in seconds, or None."""
|
|
469
|
+
try:
|
|
470
|
+
days, _, rest = etime.strip().rpartition("-")
|
|
471
|
+
parts = [int(x) for x in rest.split(":")]
|
|
472
|
+
while len(parts) < 3:
|
|
473
|
+
parts.insert(0, 0)
|
|
474
|
+
return (int(days) if days else 0) * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
|
|
475
|
+
except (ValueError, AttributeError):
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
_DEVICE_RE = re.compile(r"/CoreSimulator/Devices/([0-9A-Fa-f-]{36})/")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _booted_sims(ps_text):
|
|
483
|
+
"""{udid: {"upS": seconds its launchd_sim has run, "app": an app is running}}
|
|
484
|
+
for every booted simulator, read off one `ps -axo etime=,command=` listing.
|
|
485
|
+
A device's launchd_sim is its boot; a process under its
|
|
486
|
+
data/Containers/Bundle/Application is an app running in it."""
|
|
487
|
+
out = {}
|
|
488
|
+
for line in ps_text.splitlines():
|
|
489
|
+
etime, _, cmd = line.strip().partition(" ")
|
|
490
|
+
m = _DEVICE_RE.search(cmd)
|
|
491
|
+
if not m:
|
|
492
|
+
continue
|
|
493
|
+
udid = m.group(1).upper()
|
|
494
|
+
d = out.setdefault(udid, {"upS": None, "app": False})
|
|
495
|
+
if "launchd_sim" in cmd.split(" ", 1)[0]:
|
|
496
|
+
up = _etime_s(etime)
|
|
497
|
+
if up is not None:
|
|
498
|
+
d["upS"] = max(d["upS"] or 0, up)
|
|
499
|
+
if "/data/Containers/Bundle/Application/" in cmd:
|
|
500
|
+
d["app"] = True
|
|
501
|
+
return out
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def idle_unleased_sims(booted, leased, names, hours=IDLE_SIM_HOURS):
|
|
505
|
+
"""The booted simulators to shut down: no lease holds them, up longer than
|
|
506
|
+
`hours`, and no app running. Pure. `names` is {udid: name} (for the report)."""
|
|
507
|
+
if not hours or hours <= 0:
|
|
508
|
+
return []
|
|
509
|
+
out = []
|
|
510
|
+
for udid, d in booted.items():
|
|
511
|
+
if udid in leased or d.get("app") or d.get("upS") is None:
|
|
512
|
+
continue
|
|
513
|
+
if d["upS"] >= hours * 3600:
|
|
514
|
+
out.append({"udid": udid, "name": names.get(udid, ""), "upH": round(d["upS"] / 3600, 1)})
|
|
515
|
+
return out
|
|
516
|
+
|
|
517
|
+
|
|
462
518
|
def _group_alive(pgid):
|
|
463
519
|
"""Any member of the process group left? EPERM counts as alive."""
|
|
464
520
|
try:
|
|
@@ -997,7 +1053,10 @@ class Broker:
|
|
|
997
1053
|
"ports": l["ports"], "projectId": l.get("project_id"),
|
|
998
1054
|
"sim": l.get("sim_udid"), "expires": l.get("expires"),
|
|
999
1055
|
"label": l.get("label"), "log": l.get("log")}
|
|
1000
|
-
for l in self.leases.values()]
|
|
1056
|
+
for l in self.leases.values()],
|
|
1057
|
+
# What the idle-simulator sweep last saw and did (SSB's Hosts
|
|
1058
|
+
# card reads it: a leak is a number, not a chart to diagnose).
|
|
1059
|
+
"idleSims": getattr(self, "_idle_sims", None)}
|
|
1001
1060
|
|
|
1002
1061
|
def reap(self):
|
|
1003
1062
|
with self.lock:
|
|
@@ -1143,6 +1202,8 @@ class Broker:
|
|
|
1143
1202
|
and udid not in leased_udids):
|
|
1144
1203
|
findings.append({"kind": "sim", "udid": udid, "name": name,
|
|
1145
1204
|
"evict": udid in mine})
|
|
1205
|
+
if apply:
|
|
1206
|
+
self._sweep_idle_sims(inv)
|
|
1146
1207
|
if apply and findings:
|
|
1147
1208
|
pids = {f["pid"] for f in findings
|
|
1148
1209
|
if f["kind"] in ("listener", "orphan") and f.get("evict")}
|
|
@@ -1178,6 +1239,35 @@ class Broker:
|
|
|
1178
1239
|
+ f" — {f['cmd']}", flush=True)
|
|
1179
1240
|
return findings
|
|
1180
1241
|
|
|
1242
|
+
def _sweep_idle_sims(self, inv):
|
|
1243
|
+
"""Shut down booted simulators no lease holds that have sat idle past
|
|
1244
|
+
IDLE_SIM_HOURS. Shutdown only — the device and its data stay, and its
|
|
1245
|
+
owner boots it again. Recorded for status whatever it finds."""
|
|
1246
|
+
if not IDLE_SIM_HOURS or IDLE_SIM_HOURS <= 0:
|
|
1247
|
+
return
|
|
1248
|
+
try:
|
|
1249
|
+
r = subprocess.run(["ps", "-axo", "etime=,command="], capture_output=True, text=True, timeout=10)
|
|
1250
|
+
except (subprocess.SubprocessError, OSError):
|
|
1251
|
+
return
|
|
1252
|
+
booted = _booted_sims(r.stdout)
|
|
1253
|
+
leased = {str(l.get("sim_udid")).upper() for l in self.leases.values() if l.get("sim_udid")}
|
|
1254
|
+
names = {u.upper(): n for u, n in (inv or {}).items()}
|
|
1255
|
+
idle = idle_unleased_sims(booted, leased, names)
|
|
1256
|
+
done = []
|
|
1257
|
+
for s in idle:
|
|
1258
|
+
try:
|
|
1259
|
+
subprocess.run(["xcrun", "simctl", "shutdown", s["udid"]], capture_output=True, text=True, timeout=60)
|
|
1260
|
+
done.append(s)
|
|
1261
|
+
print(f"idle sim {s['name'] or '?'} ({s['udid'][:8]}) shut down: no lease, "
|
|
1262
|
+
f"booted {s['upH']}h, no app running", flush=True)
|
|
1263
|
+
except (subprocess.SubprocessError, OSError) as e:
|
|
1264
|
+
print(f"idle sim {s['udid'][:8]}: shutdown failed: {e}", flush=True)
|
|
1265
|
+
unleased = [u for u in booted if u not in leased]
|
|
1266
|
+
prev = getattr(self, "_idle_sims", None) or {}
|
|
1267
|
+
self._idle_sims = {"at": time.time(), "booted": len(booted), "unleased": len(unleased),
|
|
1268
|
+
"afterHours": IDLE_SIM_HOURS,
|
|
1269
|
+
"shutDown": (done + (prev.get("shutDown") or []))[:20]}
|
|
1270
|
+
|
|
1181
1271
|
def doctor(self, fix=False):
|
|
1182
1272
|
"""Report (and with fix=True, evict) everything the sweep can see —
|
|
1183
1273
|
the command that would have made 51 orphaned JVMs visible on day one."""
|
|
@@ -1728,7 +1818,13 @@ def main():
|
|
|
1728
1818
|
if not _up():
|
|
1729
1819
|
print("daemon not running.")
|
|
1730
1820
|
return
|
|
1731
|
-
|
|
1821
|
+
st = _call("GET", "/status")[1]
|
|
1822
|
+
leases = st.get("leases", [])
|
|
1823
|
+
idle = st.get("idleSims")
|
|
1824
|
+
if idle:
|
|
1825
|
+
print(f"simulators: {idle['booted']} booted, {idle['unleased']} with no lease"
|
|
1826
|
+
+ (f"; shut down idle: " + ", ".join(f"{x['name'] or x['udid'][:8]} ({x['upH']}h)" for x in idle["shutDown"][:5])
|
|
1827
|
+
if idle.get("shutDown") else ""))
|
|
1732
1828
|
if not leases:
|
|
1733
1829
|
print("nothing out.")
|
|
1734
1830
|
return
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/harbormaster",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|