@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.
- package/README.md +35 -0
- package/harbormaster +111 -6
- 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.
|
|
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.
|
|
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-
|
|
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",
|