@simon-says-build/harbormaster 0.6.1 → 0.8.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/DESIGN.md +37 -5
- package/README.md +0 -5
- package/harbormaster +383 -29
- package/package.json +1 -1
package/DESIGN.md
CHANGED
|
@@ -99,10 +99,38 @@ A lease is reclaimed when ANY of:
|
|
|
99
99
|
daemon-booted suites and reserved (caller-booted) blocks. A single failed connect
|
|
100
100
|
check on a loaded machine must never kill a healthy suite.
|
|
101
101
|
|
|
102
|
-
Teardown
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
`
|
|
102
|
+
Teardown frees the lease's **resources**, not just the process the daemon spawned —
|
|
103
|
+
because the spawned process's group is provably not enough. v2 shipped assuming
|
|
104
|
+
"`killpg` reaps the JVM children"; it doesn't: firebase-tools spawns its downloadable
|
|
105
|
+
emulators and functions workers with `detached: true`, i.e. **their own process
|
|
106
|
+
groups**, which `killpg` on the suite's group can structurally never reach. That
|
|
107
|
+
false assumption leaked 51 Firestore JVMs and 284 functions workers on one machine
|
|
108
|
+
before it was caught (2026-07-29). Teardown in v0.7 is therefore layered:
|
|
109
|
+
|
|
110
|
+
1. `killpg` the recorded group (TERM → wait on **group** emptiness, not parent
|
|
111
|
+
death → KILL), guarded against PID reuse by the spawn-time start time.
|
|
112
|
+
2. **Port sweep**: anything still LISTENing on any port of the lease's block is
|
|
113
|
+
killed (TERM → KILL). The block *is* the lease — this catches detached JVMs,
|
|
114
|
+
the caller's own Metro on the leased port, and anything else that squatted.
|
|
115
|
+
3. Verify the block's ports are actually free, and log every action taken —
|
|
116
|
+
a teardown that leaves a listener behind says so instead of pretending.
|
|
117
|
+
|
|
118
|
+
Beyond per-lease teardown, the daemon reconciles **both directions**: the ledger
|
|
119
|
+
against the world (as before), and the world against the ledger — at startup and
|
|
120
|
+
every `FBPORTS_SWEEP_INTERVAL` (300s) it evicts listeners on pool ports no lease
|
|
121
|
+
covers, firebase-tools runtimes re-parented to pid 1 (the detached-children
|
|
122
|
+
signature: `functionsEmulatorRuntime`, `cloud-firestore-emulator*.jar`, …), and
|
|
123
|
+
`run-*`/`alloc-*` simulators with no lease. `harbormaster doctor [--fix]` runs the
|
|
124
|
+
same sweep on demand. The daemon owns lifecycle end-to-end; callers use
|
|
125
|
+
`acquire`/`release`, never `pkill` — and the sweep kills only by port ownership,
|
|
126
|
+
pid-1 ancestry with our signatures, or our own sim naming scheme, never by name
|
|
127
|
+
alone.
|
|
128
|
+
|
|
129
|
+
The ledger itself is written atomically (temp + rename) and a corrupt ledger is
|
|
130
|
+
quarantined loudly instead of silently zeroed — with the world sweep adopting
|
|
131
|
+
whatever a lost ledger orphaned. Suites are registered in the ledger at spawn
|
|
132
|
+
time (state `booting`), not after the readiness wait, so a daemon crash mid-boot
|
|
133
|
+
can no longer strand a running suite outside the ledger.
|
|
106
134
|
|
|
107
135
|
## Owning PID is the caller, not the CLI
|
|
108
136
|
|
|
@@ -121,6 +149,7 @@ POST /heartbeat {leaseId, ttl?} -> {ok} # e
|
|
|
121
149
|
GET /status -> {leases:[...]}
|
|
122
150
|
GET /health
|
|
123
151
|
POST /shutdown
|
|
152
|
+
POST /doctor {fix?} -> {findings, fixed, leases}
|
|
124
153
|
```
|
|
125
154
|
|
|
126
155
|
CLI: `acquire` / `up` (alias) / `down` (release your lease) / `release <id>` / `status`
|
|
@@ -141,6 +170,9 @@ eventarc=+8 database=+9` (kept in sync with the run driver's derivation).
|
|
|
141
170
|
## Open items before this goes live
|
|
142
171
|
|
|
143
172
|
- Validate `acquire` boots + owner-death reap + PID-reuse guard on a quiet machine.
|
|
144
|
-
- Confirm `killpg` reaps JVM children (fix the v1 leak if not)
|
|
173
|
+
- ~~Confirm `killpg` reaps JVM children (fix the v1 leak if not).~~ Confirmed NOT
|
|
174
|
+
reaped (detached groups); fixed in v0.7 by the port sweep + world sweep, with
|
|
175
|
+
regression coverage in `test/e2e.sh` (release-path and crash-path both assert
|
|
176
|
+
the detached firestore JVM dies and the ports free).
|
|
145
177
|
- Migrate the agent-qa run driver to pass `owner_pid` to `/allocate`.
|
|
146
178
|
- Migrate the app.config / scrub-script / CLAUDE.md consumers to `acquire --env`.
|
package/README.md
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
# harbormaster
|
|
2
2
|
|
|
3
|
-
> Formerly `firebase-ports`. The scope outgrew the name: leases now cover Metro/Expo
|
|
4
|
-
> ports and private iOS simulators too, not just Firebase emulators. The npm package
|
|
5
|
-
> still installs a `firebase-ports` bin alias, and the daemon state dir is unchanged,
|
|
6
|
-
> so existing consumers keep working.
|
|
7
|
-
|
|
8
3
|
A localhost daemon that leases each **caller** its own isolated port block — a
|
|
9
4
|
Firebase emulator sandbox with fresh data, a Metro port, a private simulator —
|
|
10
5
|
and reclaims it when the caller's process exits.
|
package/harbormaster
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""harbormaster — caller-owned port leases: Firebase emulator sandboxes, Metro
|
|
3
|
-
ports, private simulators.
|
|
3
|
+
ports, private simulators.
|
|
4
4
|
|
|
5
5
|
A localhost daemon hands each CALLER its own emulator sandbox (a pool port block +
|
|
6
6
|
fresh data) and reclaims it when the caller's process exits. No registry, no shared
|
|
7
7
|
per-project instances — two callers that both want "an impulse emulator" get two
|
|
8
8
|
independent suites, so they never collide on ports or data.
|
|
9
9
|
|
|
10
|
-
acquire [--cwd DIR] [--owner PID] [--only a,b] [--ttl N] [--metro] [--sim]
|
|
10
|
+
acquire [--cwd DIR] [--owner PID] [--only a,b] [--ttl N] [--metro] [--sim]
|
|
11
|
+
[--fn-env KEY=VAL ...] [--json|--env]
|
|
11
12
|
Boot a private sandbox for the project in DIR (default: nearest firebase.json
|
|
12
13
|
above $PWD). Owned by --owner (default: the calling process's parent), reaped
|
|
13
14
|
when that process exits. Prints the ports (use --env to eval into your shell).
|
|
14
15
|
--metro also reserves a Metro/Expo port (METRO_PORT). --sim also creates+boots
|
|
15
16
|
a private iOS simulator (DETOX_UDID/DETOX_SIM_NAME) and implies --metro; the
|
|
16
|
-
sim is deleted when the lease is reaped.
|
|
17
|
+
sim is deleted when the lease is reaped. --fn-env KEY=VAL (repeatable)
|
|
18
|
+
injects env into the spawned emulator process — the functions runtime
|
|
19
|
+
inherits it (e.g. LLM_CACHE_DIR for the e2e LLM fixture cache).
|
|
20
|
+
Tune with --sim-device / --sim-runtime
|
|
17
21
|
(or FBPORTS_SIM_DEVICE / FBPORTS_SIM_RUNTIME). This is the e2e dev-env: one
|
|
18
22
|
lease = emulators + metro port + simulator, all torn down when the owner exits.
|
|
19
23
|
up ... Alias for acquire (interactive dev; owner = your shell).
|
|
@@ -50,6 +54,11 @@ independent suites, so they never collide on ports or data.
|
|
|
50
54
|
its process running, its simulator still present, and (for booted suites)
|
|
51
55
|
its ports open. The reuse gate for long-lived sessions:
|
|
52
56
|
harbormaster verify "$FBPORTS_LEASE_ID" || <re-lease>
|
|
57
|
+
doctor [--fix] Everything alive in the world that the ledger doesn't
|
|
58
|
+
account for: pool-port squatters, orphaned firebase runtimes (detached
|
|
59
|
+
JVMs / functions workers re-parented to pid 1), stray run-*/alloc-* sims.
|
|
60
|
+
--fix evicts them; exits 1 when findings remain unfixed. The daemon runs
|
|
61
|
+
the same sweep itself at startup and every FBPORTS_SWEEP_INTERVAL (300s).
|
|
53
62
|
serve Run the daemon (auto-starts on first command).
|
|
54
63
|
|
|
55
64
|
NOTE: firebase.json ports are optional — the daemon injects (and overrides) a
|
|
@@ -71,7 +80,7 @@ import uuid
|
|
|
71
80
|
from datetime import datetime, timezone
|
|
72
81
|
from pathlib import Path
|
|
73
82
|
|
|
74
|
-
VERSION = "0.
|
|
83
|
+
VERSION = "0.8.0"
|
|
75
84
|
|
|
76
85
|
# Overridable for tests / parallel installs; default is the shared user daemon.
|
|
77
86
|
# The dir keeps the old "firebase-ports" name: pre-rename binaries still installed
|
|
@@ -80,7 +89,13 @@ STATE_DIR = Path(os.environ.get("FBPORTS_STATE_DIR",
|
|
|
80
89
|
str(Path.home() / ".local" / "state" / "firebase-ports")))
|
|
81
90
|
LEDGER = STATE_DIR / "ledger.json"
|
|
82
91
|
BROKER_HOST, BROKER_PORT = "127.0.0.1", int(os.environ.get("FBPORTS_PORT", "4999"))
|
|
83
|
-
|
|
92
|
+
# Blocks of 20 ports, 11000-14999 by default. Overridable so an isolated test
|
|
93
|
+
# daemon (FBPORTS_PORT + FBPORTS_STATE_DIR) can also use a DISJOINT pool — its
|
|
94
|
+
# world sweep evicts unledgered pool listeners, which on the shared pool would
|
|
95
|
+
# mean evicting the real daemon's leases.
|
|
96
|
+
POOL_BASE = int(os.environ.get("FBPORTS_POOL_BASE", "11000"))
|
|
97
|
+
POOL_BLOCKS = int(os.environ.get("FBPORTS_POOL_BLOCKS", "200"))
|
|
98
|
+
BLOCK = 20
|
|
84
99
|
BLOCK_OFFSETS = {"auth": 0, "firestore": 1, "functions": 2, "storage": 3, "ui": 4,
|
|
85
100
|
"hub": 5, "logging": 6, "pubsub": 7, "eventarc": 8, "database": 9,
|
|
86
101
|
# Firestore's UI websocket is a SECOND port it needs (CLI default 9150).
|
|
@@ -100,6 +115,18 @@ REAP_INTERVAL = int(os.environ.get("FBPORTS_REAP_INTERVAL", "10"))
|
|
|
100
115
|
# is healthy — sweep it whole rather than leave a half-alive session.
|
|
101
116
|
SIM_CHECK_INTERVAL = int(os.environ.get("FBPORTS_SIM_CHECK_INTERVAL", "60"))
|
|
102
117
|
BOOT_BACKSTOP = 600 # only for a genuinely wedged boot; readiness is liveness-driven
|
|
118
|
+
# How often the daemon sweeps the WORLD for resources that escaped the ledger:
|
|
119
|
+
# listeners on pool ports with no lease, orphaned firebase runtimes, stray sims.
|
|
120
|
+
SWEEP_INTERVAL = int(os.environ.get("FBPORTS_SWEEP_INTERVAL", "300"))
|
|
121
|
+
# Process-name signatures of firebase-tools' own children. firebase-tools spawns
|
|
122
|
+
# these `detached: true` — their OWN process groups — so killpg on the suite can
|
|
123
|
+
# structurally never reach them (the v1 "killpg fixes it" assumption was wrong:
|
|
124
|
+
# it fixed the storage-rules JVM, which stays in-group, and missed the firestore
|
|
125
|
+
# JVM and every functions worker). Once their node parent dies they re-parent to
|
|
126
|
+
# pid 1; that ancestry is the sweep's orphan test.
|
|
127
|
+
SWEEP_SIGNATURES = ("functionsEmulatorRuntime", "cloud-firestore-emulator",
|
|
128
|
+
"cloud-storage-rules-runtime", "pubsub-emulator",
|
|
129
|
+
"cloud-datastore-emulator", "firebase-database-emulator")
|
|
103
130
|
|
|
104
131
|
|
|
105
132
|
def _now():
|
|
@@ -299,12 +326,91 @@ def _sim_udids():
|
|
|
299
326
|
|
|
300
327
|
|
|
301
328
|
def _sim_teardown(udid):
|
|
302
|
-
"""Shut down + delete a leased simulator
|
|
329
|
+
"""Shut down + delete a leased simulator. Returns True when the device is
|
|
330
|
+
confirmed gone. A failed delete is NOT fatal — the world sweep retries any
|
|
331
|
+
harbormaster-named device that has no lease — but it must be visible."""
|
|
303
332
|
for args in (["shutdown", udid], ["delete", udid]):
|
|
304
333
|
try:
|
|
305
334
|
subprocess.run(["xcrun", "simctl", *args], capture_output=True, text=True, timeout=60)
|
|
306
335
|
except (subprocess.SubprocessError, OSError):
|
|
307
336
|
pass
|
|
337
|
+
udids = _sim_udids()
|
|
338
|
+
return udids is None or udid not in udids
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _sim_inventory():
|
|
342
|
+
"""{udid: name} for every simulator, or None when indeterminate."""
|
|
343
|
+
try:
|
|
344
|
+
r = subprocess.run(["xcrun", "simctl", "list", "devices", "-j"],
|
|
345
|
+
capture_output=True, text=True, timeout=15)
|
|
346
|
+
if r.returncode != 0:
|
|
347
|
+
return None
|
|
348
|
+
devices = json.loads(r.stdout).get("devices", {})
|
|
349
|
+
return {d["udid"]: d.get("name", "")
|
|
350
|
+
for lst in devices.values() for d in lst if d.get("udid")}
|
|
351
|
+
except (subprocess.SubprocessError, OSError, json.JSONDecodeError,
|
|
352
|
+
AttributeError, TypeError):
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _group_alive(pgid):
|
|
357
|
+
"""Any member of the process group left? EPERM counts as alive."""
|
|
358
|
+
try:
|
|
359
|
+
os.killpg(int(pgid), 0)
|
|
360
|
+
return True
|
|
361
|
+
except PermissionError:
|
|
362
|
+
return True
|
|
363
|
+
except (OSError, ProcessLookupError, ValueError, TypeError):
|
|
364
|
+
return False
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _listeners():
|
|
368
|
+
"""{port: {pids}} for this user's TCP LISTEN sockets — ONE lsof call, so
|
|
369
|
+
teardown and the world sweep can check whole port ranges cheaply."""
|
|
370
|
+
out = {}
|
|
371
|
+
try:
|
|
372
|
+
r = subprocess.run(["lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-Fpn"],
|
|
373
|
+
capture_output=True, text=True, timeout=15)
|
|
374
|
+
pid = None
|
|
375
|
+
for line in r.stdout.splitlines():
|
|
376
|
+
if line[:1] == "p":
|
|
377
|
+
try:
|
|
378
|
+
pid = int(line[1:])
|
|
379
|
+
except ValueError:
|
|
380
|
+
pid = None
|
|
381
|
+
elif line[:1] == "n" and pid:
|
|
382
|
+
try:
|
|
383
|
+
port = int(line.rsplit(":", 1)[1])
|
|
384
|
+
except (ValueError, IndexError):
|
|
385
|
+
continue
|
|
386
|
+
out.setdefault(port, set()).add(pid)
|
|
387
|
+
except (subprocess.SubprocessError, OSError):
|
|
388
|
+
pass
|
|
389
|
+
return out
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _kill_wave(pids, wait=5.0):
|
|
393
|
+
"""SIGTERM the pids, wait, SIGKILL survivors. Returns the pids signalled.
|
|
394
|
+
Never signals ourselves or anything system-side (pid <= 1)."""
|
|
395
|
+
victims = sorted({int(p) for p in pids} - {os.getpid()})
|
|
396
|
+
victims = [p for p in victims if p > 1]
|
|
397
|
+
if not victims:
|
|
398
|
+
return []
|
|
399
|
+
for p in victims:
|
|
400
|
+
try:
|
|
401
|
+
os.kill(p, signal.SIGTERM)
|
|
402
|
+
except (OSError, ProcessLookupError):
|
|
403
|
+
pass
|
|
404
|
+
deadline = time.time() + wait
|
|
405
|
+
while time.time() < deadline and any(_pid_alive(p) for p in victims):
|
|
406
|
+
time.sleep(0.1)
|
|
407
|
+
for p in victims:
|
|
408
|
+
if _pid_alive(p):
|
|
409
|
+
try:
|
|
410
|
+
os.kill(p, signal.SIGKILL)
|
|
411
|
+
except (OSError, ProcessLookupError):
|
|
412
|
+
pass
|
|
413
|
+
return victims
|
|
308
414
|
|
|
309
415
|
|
|
310
416
|
# ---- the daemon ----------------------------------------------------------
|
|
@@ -315,17 +421,33 @@ class Broker:
|
|
|
315
421
|
self.leases = {} # leaseId -> caller-owned lease
|
|
316
422
|
self._load()
|
|
317
423
|
self._reconcile()
|
|
424
|
+
# _reconcile just swept; without this the reaper's throttle (epoch 0)
|
|
425
|
+
# would fire a second "periodic" sweep on its first tick, making
|
|
426
|
+
# FBPORTS_SWEEP_INTERVAL meaningless for the first interval.
|
|
427
|
+
self._swept = time.time()
|
|
318
428
|
|
|
319
429
|
def _load(self):
|
|
320
430
|
if LEDGER.exists():
|
|
321
431
|
try:
|
|
322
432
|
self.leases = json.loads(LEDGER.read_text()).get("leases", {})
|
|
323
|
-
except (json.JSONDecodeError, OSError):
|
|
324
|
-
|
|
433
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
434
|
+
# A silently-dropped ledger orphans every live suite/sim/port at
|
|
435
|
+
# once. Quarantine it loudly; the world sweep adopts the fallout.
|
|
436
|
+
quarantine = LEDGER.with_suffix(f".corrupt-{int(time.time())}")
|
|
437
|
+
try:
|
|
438
|
+
LEDGER.rename(quarantine)
|
|
439
|
+
except OSError:
|
|
440
|
+
pass
|
|
441
|
+
print(f"ledger unreadable ({e}); quarantined to {quarantine} — "
|
|
442
|
+
f"world sweep will reap anything it was tracking", flush=True)
|
|
325
443
|
|
|
326
444
|
def _save(self):
|
|
327
445
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
328
|
-
|
|
446
|
+
# Atomic: a crash mid-write must never leave a half-written ledger
|
|
447
|
+
# (see _load — a corrupt ledger orphans everything at once).
|
|
448
|
+
tmp = LEDGER.with_suffix(".tmp")
|
|
449
|
+
tmp.write_text(json.dumps({"leases": self.leases}, indent=2))
|
|
450
|
+
tmp.replace(LEDGER)
|
|
329
451
|
|
|
330
452
|
def _reconcile(self):
|
|
331
453
|
for lid in list(self.leases):
|
|
@@ -334,6 +456,10 @@ class Broker:
|
|
|
334
456
|
print(f"reaping {lid}: {reason}", flush=True)
|
|
335
457
|
self._cleanup(self.leases.pop(lid))
|
|
336
458
|
self._save()
|
|
459
|
+
# Ledger-vs-world is only half the job: anything that escaped the
|
|
460
|
+
# ledger (daemon crash mid-boot, corrupt ledger, detached children of
|
|
461
|
+
# past suites) is invisible to the loop above and immortal without this.
|
|
462
|
+
self._sweep_world(apply=True)
|
|
337
463
|
|
|
338
464
|
def _dead(self, l, now):
|
|
339
465
|
return bool(self._dead_reason(l, now))
|
|
@@ -375,6 +501,15 @@ class Broker:
|
|
|
375
501
|
if l.get("down_since") and now - l["down_since"] >= RECLAIM_GRACE:
|
|
376
502
|
return "reserved block down past grace"
|
|
377
503
|
return None
|
|
504
|
+
if l.get("state") == "booting":
|
|
505
|
+
# Provisional record written at spawn time so a daemon crash can't
|
|
506
|
+
# orphan a mid-boot suite. Ports aren't expected open yet — only a
|
|
507
|
+
# dead process or a blown boot window reaps it.
|
|
508
|
+
if not _child_alive(l.get("pid")):
|
|
509
|
+
return f"suite pid {l.get('pid')} died during boot"
|
|
510
|
+
if now - l.get("booting_since", now) > BOOT_BACKSTOP + 60:
|
|
511
|
+
return "boot window exceeded"
|
|
512
|
+
return None
|
|
378
513
|
# Booted suite: the process is the strong liveness signal — reap a dead
|
|
379
514
|
# process immediately. Ports are a weak signal (a single 0.5s connect can
|
|
380
515
|
# fail on a loaded machine while the suite is fine), so closed ports only
|
|
@@ -398,7 +533,7 @@ class Broker:
|
|
|
398
533
|
return base
|
|
399
534
|
raise RuntimeError("emulator port pool exhausted")
|
|
400
535
|
|
|
401
|
-
def _boot(self, cwd, block, only, project_id, label):
|
|
536
|
+
def _boot(self, cwd, block, only, project_id, label, on_spawn=None, extra_env=None):
|
|
402
537
|
all_ports = _block_ports(block)
|
|
403
538
|
tmp = _write_injected_config(cwd, all_ports)
|
|
404
539
|
# Readiness/liveness track ONLY the data services actually started (respect
|
|
@@ -413,8 +548,20 @@ class Broker:
|
|
|
413
548
|
cmd += ["--only", ",".join(only)]
|
|
414
549
|
if project_id:
|
|
415
550
|
cmd += ["--project", project_id]
|
|
551
|
+
# Caller-supplied env (acquire --fn-env KEY=VAL) rides on top of the
|
|
552
|
+
# daemon's env — the functions emulator runtime inherits it, which is
|
|
553
|
+
# the only way a CALLER can set per-suite env (e.g. LLM_CACHE_DIR)
|
|
554
|
+
# when the daemon, not the caller, spawns the emulator.
|
|
555
|
+
spawn_env = None
|
|
556
|
+
if extra_env:
|
|
557
|
+
spawn_env = {**os.environ, **{str(k): str(v) for k, v in dict(extra_env).items()}}
|
|
416
558
|
with open(log, "w") as lf:
|
|
417
|
-
proc = subprocess.Popen(cmd, cwd=cwd, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True)
|
|
559
|
+
proc = subprocess.Popen(cmd, cwd=cwd, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True, env=spawn_env)
|
|
560
|
+
if on_spawn:
|
|
561
|
+
# Register the suite BEFORE the (up to 600s) readiness wait: a
|
|
562
|
+
# daemon crash inside this window used to leave a fully-booted,
|
|
563
|
+
# session-detached, ledger-less suite that nothing could ever find.
|
|
564
|
+
on_spawn(proc.pid, tmp, str(log))
|
|
418
565
|
# Liveness-driven: keep waiting while the process is alive (a slow JVM is still
|
|
419
566
|
# booting, not stuck); succeed when the ports open; fail if it exits; the
|
|
420
567
|
# backstop only guards a genuinely wedged boot.
|
|
@@ -433,36 +580,121 @@ class Broker:
|
|
|
433
580
|
time.sleep(1)
|
|
434
581
|
|
|
435
582
|
def _cleanup(self, rec):
|
|
436
|
-
"""
|
|
583
|
+
"""Teardown frees the lease's RESOURCES — process group, ports, sim —
|
|
584
|
+
and verifies, loudly. Killing only the process we spawned is not
|
|
585
|
+
enough: firebase-tools detaches its JVMs and functions workers into
|
|
586
|
+
their own groups (killpg can't reach them), and the caller's Metro on
|
|
587
|
+
the leased port was never ours at all. The port block IS the lease, so
|
|
588
|
+
after the group kill anything still LISTENing on the block dies too,
|
|
589
|
+
and the block is re-checked before we forget the lease. Never pkill —
|
|
590
|
+
every kill here is by recorded pid or by a port the lease owns."""
|
|
591
|
+
lid = rec.get("leaseId") or "?"
|
|
592
|
+
actions = []
|
|
437
593
|
pid = rec.get("pid")
|
|
438
|
-
#
|
|
439
|
-
#
|
|
440
|
-
#
|
|
441
|
-
|
|
594
|
+
# PID-reuse guard (same contract as owners): if the pid's start time no
|
|
595
|
+
# longer matches what we recorded at spawn, it's a recycled pid — do
|
|
596
|
+
# not signal a stranger. Old ledger records lack pid_start; for those
|
|
597
|
+
# the group kill proceeds as before.
|
|
598
|
+
recorded_start = rec.get("pid_start")
|
|
599
|
+
if pid and recorded_start:
|
|
600
|
+
observed = _proc_start(pid)
|
|
601
|
+
if observed and observed != recorded_start:
|
|
602
|
+
actions.append(f"pid {pid} recycled — not signalled")
|
|
603
|
+
pid = None
|
|
604
|
+
pgid = rec.get("pgid")
|
|
605
|
+
if pgid is None and pid and _pid_alive(pid):
|
|
606
|
+
try:
|
|
607
|
+
pgid = os.getpgid(int(pid))
|
|
608
|
+
except (OSError, ProcessLookupError):
|
|
609
|
+
pgid = None
|
|
610
|
+
def reap_zombie():
|
|
611
|
+
# Our exited child lingers as a zombie until waited on — and a
|
|
612
|
+
# zombie still counts as a group member, so _group_alive would
|
|
613
|
+
# read "survived" forever without this.
|
|
614
|
+
if pid:
|
|
615
|
+
try:
|
|
616
|
+
os.waitpid(int(pid), os.WNOHANG)
|
|
617
|
+
except (ChildProcessError, OSError, ValueError):
|
|
618
|
+
pass
|
|
619
|
+
|
|
620
|
+
if pgid and int(pgid) > 1:
|
|
621
|
+
# Escalation waits on the GROUP, not the parent: the old loop broke
|
|
622
|
+
# the moment node exited on SIGTERM, so SIGKILL never reached slow
|
|
623
|
+
# or stubborn group members.
|
|
442
624
|
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
625
|
+
reap_zombie()
|
|
626
|
+
if not _group_alive(pgid):
|
|
627
|
+
break
|
|
443
628
|
try:
|
|
444
|
-
os.killpg(
|
|
629
|
+
os.killpg(int(pgid), sig)
|
|
445
630
|
except (OSError, ProcessLookupError):
|
|
446
631
|
break
|
|
447
632
|
for _ in range(50):
|
|
448
|
-
|
|
633
|
+
reap_zombie()
|
|
634
|
+
if not _group_alive(pgid):
|
|
449
635
|
break
|
|
450
636
|
time.sleep(0.1)
|
|
451
|
-
|
|
452
|
-
|
|
637
|
+
reap_zombie()
|
|
638
|
+
actions.append(f"group {pgid} " +
|
|
639
|
+
("SURVIVED kill" if _group_alive(pgid) else "killed"))
|
|
640
|
+
else:
|
|
641
|
+
reap_zombie()
|
|
642
|
+
# Port sweep: every port the lease covers. rec["ports"] catches legacy
|
|
643
|
+
# records; the block range catches everything else (metro, websocket).
|
|
644
|
+
ports = {v for v in (rec.get("ports") or {}).values() if isinstance(v, int)}
|
|
645
|
+
if rec.get("block"):
|
|
646
|
+
ports |= {rec["block"] + off for off in range(BLOCK)}
|
|
647
|
+
if ports:
|
|
648
|
+
listening = _listeners()
|
|
649
|
+
squatters = {p for port in ports for p in listening.get(port, ())}
|
|
650
|
+
killed = _kill_wave(squatters)
|
|
651
|
+
if killed:
|
|
652
|
+
actions.append(f"port sweep killed {killed}")
|
|
653
|
+
still = sorted(port for port, pids in _listeners().items()
|
|
654
|
+
if port in ports and pids)
|
|
655
|
+
if still:
|
|
656
|
+
actions.append(f"ports STILL HELD after sweep: {still}")
|
|
453
657
|
tmp = rec.get("tmp_config")
|
|
454
658
|
if tmp and rec.get("cwd") and (Path(rec["cwd"]) / tmp).exists():
|
|
455
659
|
(Path(rec["cwd"]) / tmp).unlink()
|
|
456
660
|
if rec.get("sim_udid"):
|
|
457
|
-
_sim_teardown(rec["sim_udid"])
|
|
661
|
+
gone = _sim_teardown(rec["sim_udid"])
|
|
662
|
+
actions.append(f"sim {rec['sim_udid'][:8]} " +
|
|
663
|
+
("deleted" if gone else "DELETE FAILED (sweep will retry)"))
|
|
664
|
+
if actions:
|
|
665
|
+
print(f"teardown {lid}: " + "; ".join(actions), flush=True)
|
|
458
666
|
|
|
459
667
|
def acquire(self, cwd, owner_pid, owner_start=None, only=None, ttl=DEFAULT_TTL,
|
|
460
|
-
metro=False, sim=False, sim_device=None, sim_runtime=None):
|
|
668
|
+
metro=False, sim=False, sim_device=None, sim_runtime=None, env=None):
|
|
461
669
|
with self.lock:
|
|
462
670
|
base = self._free_block()
|
|
463
671
|
lid = f"run:{uuid.uuid4().hex[:12]}"
|
|
464
672
|
project_id = _firebaserc_project(cwd)
|
|
465
|
-
|
|
673
|
+
|
|
674
|
+
def on_spawn(spawn_pid, spawn_tmp, spawn_log):
|
|
675
|
+
# Provisional record, saved before the readiness wait: a daemon
|
|
676
|
+
# crash mid-boot must leave a findable lease, not a ghost suite.
|
|
677
|
+
self.leases[lid] = {"leaseId": lid, "kind": "acquired",
|
|
678
|
+
"state": "booting", "booting_since": time.time(),
|
|
679
|
+
"cwd": cwd, "block": base, "ports": {},
|
|
680
|
+
"pid": spawn_pid, "pgid": spawn_pid,
|
|
681
|
+
"pid_start": _proc_start(spawn_pid),
|
|
682
|
+
"owner_pid": owner_pid, "owner_start": owner_start,
|
|
683
|
+
"tmp_config": spawn_tmp, "log": spawn_log,
|
|
684
|
+
"project_id": project_id,
|
|
685
|
+
"started": _now(), "expires": time.time() + ttl}
|
|
686
|
+
self._save()
|
|
687
|
+
|
|
688
|
+
try:
|
|
689
|
+
pid, tmp, log, ports, check = self._boot(
|
|
690
|
+
cwd, base, only, project_id, lid.replace(":", "-"), on_spawn,
|
|
691
|
+
extra_env=env)
|
|
692
|
+
except Exception:
|
|
693
|
+
rec = self.leases.pop(lid, None)
|
|
694
|
+
if rec:
|
|
695
|
+
self._cleanup(rec)
|
|
696
|
+
self._save()
|
|
697
|
+
raise
|
|
466
698
|
# A leased simulator implies a metro port (the e2e dev-env wants both).
|
|
467
699
|
# The metro port lives in the lease's own 20-port block, so it's
|
|
468
700
|
# collision-free by construction and needs no health check.
|
|
@@ -476,10 +708,13 @@ class Broker:
|
|
|
476
708
|
sim_runtime or DEFAULT_SIM_RUNTIME, sim_name)
|
|
477
709
|
except Exception:
|
|
478
710
|
# Don't leak the emulators we just booted if the sim fails.
|
|
479
|
-
self._cleanup(
|
|
711
|
+
self._cleanup(self.leases.pop(lid, None) or
|
|
712
|
+
{"pid": pid, "cwd": cwd, "tmp_config": tmp, "block": base})
|
|
713
|
+
self._save()
|
|
480
714
|
raise
|
|
481
715
|
self.leases[lid] = {"leaseId": lid, "kind": "acquired", "cwd": cwd, "block": base,
|
|
482
716
|
"ports": ports, "check_ports": check, "pid": pid,
|
|
717
|
+
"pgid": pid, "pid_start": _proc_start(pid),
|
|
483
718
|
"owner_pid": owner_pid, "owner_start": owner_start,
|
|
484
719
|
"tmp_config": tmp, "log": log, "project_id": project_id,
|
|
485
720
|
"sim_udid": sim_udid, "sim_name": sim_name,
|
|
@@ -541,6 +776,7 @@ class Broker:
|
|
|
541
776
|
f"{f' spawned pid {pid}' if pid else ''}", flush=True)
|
|
542
777
|
self.leases[lid] = {"leaseId": lid, "kind": "reserved", "cwd": spawn_cwd,
|
|
543
778
|
"block": base, "ports": ports, "pid": pid,
|
|
779
|
+
"pgid": pid, "pid_start": _proc_start(pid) if pid else None,
|
|
544
780
|
"owner_pid": owner_pid,
|
|
545
781
|
"owner_start": owner_start, "tmp_config": None,
|
|
546
782
|
"log": log, "spawn": spawn,
|
|
@@ -571,6 +807,7 @@ class Broker:
|
|
|
571
807
|
for lid in list(self.leases):
|
|
572
808
|
l = self.leases[lid]
|
|
573
809
|
if l.get("owner_pid") == owner_pid and (cwd is None or l.get("cwd") == cwd):
|
|
810
|
+
print(f"released {lid} (owner {owner_pid} asked)", flush=True)
|
|
574
811
|
self._cleanup(self.leases.pop(lid))
|
|
575
812
|
n += 1
|
|
576
813
|
self._save()
|
|
@@ -597,10 +834,17 @@ class Broker:
|
|
|
597
834
|
with self.lock:
|
|
598
835
|
now = time.time()
|
|
599
836
|
for lid in list(self.leases):
|
|
600
|
-
|
|
837
|
+
reason = self._dead_reason(self.leases[lid], now)
|
|
838
|
+
if reason:
|
|
839
|
+
# The routine reap path used to be silent — weeks of leaked
|
|
840
|
+
# JVMs left zero trace. Every reap states its reason.
|
|
841
|
+
print(f"reaping {lid}: {reason}", flush=True)
|
|
601
842
|
self._cleanup(self.leases.pop(lid))
|
|
602
843
|
self._reap_deleted_sims(now)
|
|
603
844
|
self._save()
|
|
845
|
+
if now - getattr(self, "_swept", 0) >= SWEEP_INTERVAL:
|
|
846
|
+
self._swept = now
|
|
847
|
+
self._sweep_world(apply=True)
|
|
604
848
|
|
|
605
849
|
def _reap_deleted_sims(self, now):
|
|
606
850
|
"""Reap leases whose simulator was deleted externally (Xcode cleanup,
|
|
@@ -649,6 +893,80 @@ class Broker:
|
|
|
649
893
|
reasons.append("ports down: " + ",".join(sorted(down)))
|
|
650
894
|
return {"ok": not reasons, "reasons": reasons}
|
|
651
895
|
|
|
896
|
+
def _sweep_world(self, apply):
|
|
897
|
+
"""The other half of reconciliation: find resources that exist in the
|
|
898
|
+
WORLD but not in the ledger, and (apply=True) evict them. Three classes,
|
|
899
|
+
each one an observed leak:
|
|
900
|
+
|
|
901
|
+
- listeners on pool ports no lease covers (caller-run Metros, suites
|
|
902
|
+
that outlived a lost ledger entry);
|
|
903
|
+
- firebase-tools runtimes re-parented to pid 1 (detached JVMs and
|
|
904
|
+
functions workers whose suite died — killpg can never reach these);
|
|
905
|
+
- harbormaster-named simulators (run-*/alloc-*) with no lease.
|
|
906
|
+
|
|
907
|
+
Scoped tightly on purpose: only pool ports, only this user's processes,
|
|
908
|
+
only pid-1 ancestry with firebase-tools signatures, only our own sim
|
|
909
|
+
naming scheme. Returns findings for `doctor`."""
|
|
910
|
+
findings = []
|
|
911
|
+
lease_pids = {int(l["pid"]) for l in self.leases.values() if l.get("pid")}
|
|
912
|
+
leased_ports = set()
|
|
913
|
+
for l in self.leases.values():
|
|
914
|
+
if l.get("block"):
|
|
915
|
+
leased_ports |= {l["block"] + off for off in range(BLOCK)}
|
|
916
|
+
pool_end = POOL_BASE + POOL_BLOCKS * BLOCK
|
|
917
|
+
for port, pids in _listeners().items():
|
|
918
|
+
if POOL_BASE <= port < pool_end and port not in leased_ports:
|
|
919
|
+
for pid in pids:
|
|
920
|
+
if pid not in lease_pids:
|
|
921
|
+
findings.append({"kind": "listener", "pid": pid, "port": port,
|
|
922
|
+
"cmd": _proc_field(pid, "command=")[:200]})
|
|
923
|
+
try:
|
|
924
|
+
r = subprocess.run(["ps", "-xo", "pid=,ppid=,command="],
|
|
925
|
+
capture_output=True, text=True, timeout=10)
|
|
926
|
+
for line in r.stdout.splitlines():
|
|
927
|
+
parts = line.split(None, 2)
|
|
928
|
+
if len(parts) < 3 or parts[1] != "1":
|
|
929
|
+
continue
|
|
930
|
+
pid, cmdline = int(parts[0]), parts[2]
|
|
931
|
+
if pid in lease_pids or pid == os.getpid():
|
|
932
|
+
continue
|
|
933
|
+
if (any(sig in cmdline for sig in SWEEP_SIGNATURES)
|
|
934
|
+
or ".firebase.brokered-" in cmdline):
|
|
935
|
+
findings.append({"kind": "orphan", "pid": pid,
|
|
936
|
+
"cmd": cmdline[:200]})
|
|
937
|
+
except (subprocess.SubprocessError, OSError, ValueError):
|
|
938
|
+
pass
|
|
939
|
+
inv = _sim_inventory()
|
|
940
|
+
if inv is not None:
|
|
941
|
+
leased_udids = {l.get("sim_udid") for l in self.leases.values()}
|
|
942
|
+
for udid, name in inv.items():
|
|
943
|
+
if ((name.startswith("run-") or name.startswith("alloc-"))
|
|
944
|
+
and udid not in leased_udids):
|
|
945
|
+
findings.append({"kind": "sim", "udid": udid, "name": name})
|
|
946
|
+
if apply and findings:
|
|
947
|
+
pids = {f["pid"] for f in findings if f["kind"] in ("listener", "orphan")}
|
|
948
|
+
if pids:
|
|
949
|
+
killed = _kill_wave(pids)
|
|
950
|
+
print(f"world sweep killed {killed}", flush=True)
|
|
951
|
+
for f in findings:
|
|
952
|
+
if f["kind"] == "sim":
|
|
953
|
+
gone = _sim_teardown(f["udid"])
|
|
954
|
+
print(f"world sweep sim {f['name']} ({f['udid'][:8]}): "
|
|
955
|
+
+ ("deleted" if gone else "DELETE FAILED"), flush=True)
|
|
956
|
+
else:
|
|
957
|
+
print(f"world sweep {f['kind']}: pid {f['pid']}"
|
|
958
|
+
+ (f" port {f['port']}" if f.get("port") else "")
|
|
959
|
+
+ f" — {f['cmd']}", flush=True)
|
|
960
|
+
return findings
|
|
961
|
+
|
|
962
|
+
def doctor(self, fix=False):
|
|
963
|
+
"""Report (and with fix=True, evict) everything the sweep can see —
|
|
964
|
+
the command that would have made 51 orphaned JVMs visible on day one."""
|
|
965
|
+
with self.lock:
|
|
966
|
+
findings = self._sweep_world(apply=fix)
|
|
967
|
+
return {"findings": findings, "fixed": bool(fix and findings),
|
|
968
|
+
"leases": len(self.leases)}
|
|
969
|
+
|
|
652
970
|
|
|
653
971
|
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
654
972
|
broker = None
|
|
@@ -685,7 +1003,8 @@ class _Handler(http.server.BaseHTTPRequestHandler):
|
|
|
685
1003
|
return self._send(200, b.acquire(d["cwd"], d.get("owner_pid"), d.get("owner_start"),
|
|
686
1004
|
d.get("only"), d.get("ttl", DEFAULT_TTL),
|
|
687
1005
|
d.get("metro", False), d.get("sim", False),
|
|
688
|
-
d.get("sim_device"), d.get("sim_runtime")
|
|
1006
|
+
d.get("sim_device"), d.get("sim_runtime"),
|
|
1007
|
+
d.get("env")))
|
|
689
1008
|
if self.path == "/allocate":
|
|
690
1009
|
return self._send(200, b.allocate(d.get("owner_pid"), d.get("owner_start"),
|
|
691
1010
|
d.get("ttl", DEFAULT_TTL), d.get("label"),
|
|
@@ -700,6 +1019,8 @@ class _Handler(http.server.BaseHTTPRequestHandler):
|
|
|
700
1019
|
return self._send(200, b.heartbeat(d.get("leaseId"), d.get("ttl", DEFAULT_TTL)))
|
|
701
1020
|
if self.path == "/verify":
|
|
702
1021
|
return self._send(200, b.verify(d.get("leaseId")))
|
|
1022
|
+
if self.path == "/doctor":
|
|
1023
|
+
return self._send(200, b.doctor(d.get("fix", False)))
|
|
703
1024
|
if self.path == "/shutdown":
|
|
704
1025
|
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
705
1026
|
return self._send(200, {"ok": True})
|
|
@@ -844,6 +1165,11 @@ def _print(out, fmt):
|
|
|
844
1165
|
if sim:
|
|
845
1166
|
print(f"export DETOX_UDID={sim['udid']}") # Detox targets this simulator by UDID
|
|
846
1167
|
print(f"export DETOX_SIM_NAME={sim['name']}")
|
|
1168
|
+
# The lease id, so a long-running caller can `harbormaster release` a
|
|
1169
|
+
# suite it has decided is dead and re-acquire, instead of leaking it
|
|
1170
|
+
# until the reaper notices.
|
|
1171
|
+
if out.get("leaseId"):
|
|
1172
|
+
print(f"export FBPORTS_LEASE_ID={out['leaseId']}")
|
|
847
1173
|
else:
|
|
848
1174
|
print(f"{out.get('leaseId', '')} ({pid})".strip())
|
|
849
1175
|
for svc, port in ports.items():
|
|
@@ -860,7 +1186,13 @@ def _parse(args):
|
|
|
860
1186
|
if a.startswith("--"):
|
|
861
1187
|
if "=" in a:
|
|
862
1188
|
k, v = a[2:].split("=", 1)
|
|
863
|
-
|
|
1189
|
+
if k == "fn-env":
|
|
1190
|
+
opts.setdefault("fn-env", []).append(v)
|
|
1191
|
+
else:
|
|
1192
|
+
opts[k] = v
|
|
1193
|
+
elif a[2:] == "fn-env" and i + 1 < len(args):
|
|
1194
|
+
opts.setdefault("fn-env", []).append(args[i + 1])
|
|
1195
|
+
i += 1
|
|
864
1196
|
elif a[2:] in ("cwd", "only", "ttl", "owner", "label", "sim-device", "sim-runtime", "spawn") and i + 1 < len(args):
|
|
865
1197
|
opts[a[2:]] = args[i + 1]
|
|
866
1198
|
i += 1
|
|
@@ -900,7 +1232,8 @@ def main():
|
|
|
900
1232
|
"ttl": int(opts.get("ttl", DEFAULT_TTL)),
|
|
901
1233
|
"metro": "metro" in flags, "sim": "sim" in flags,
|
|
902
1234
|
"sim_device": opts.get("sim-device"),
|
|
903
|
-
"sim_runtime": opts.get("sim-runtime")
|
|
1235
|
+
"sim_runtime": opts.get("sim-runtime"),
|
|
1236
|
+
"env": dict(kv.split("=", 1) for kv in opts.get("fn-env", []))}), "acquire"), fmt)
|
|
904
1237
|
elif cmd == "down":
|
|
905
1238
|
if _up():
|
|
906
1239
|
out = _call("POST", "/release-owned", {"owner_pid": os.getppid(), "cwd": _resolve_cwd(opts)})[1]
|
|
@@ -995,6 +1328,27 @@ def main():
|
|
|
995
1328
|
else:
|
|
996
1329
|
print("; ".join(out.get("reasons") or [out.get("error", "not ok")]))
|
|
997
1330
|
sys.exit(1)
|
|
1331
|
+
elif cmd == "doctor":
|
|
1332
|
+
# doctor [--fix] — everything alive in the WORLD that the ledger does
|
|
1333
|
+
# not account for: pool-port squatters, orphaned firebase runtimes,
|
|
1334
|
+
# stray harbormaster sims. --fix evicts them. Exit 1 when findings
|
|
1335
|
+
# remain unfixed, so health checks can gate on it.
|
|
1336
|
+
_ensure()
|
|
1337
|
+
out = ok(_call("POST", "/doctor", {"fix": "fix" in flags}), "doctor")
|
|
1338
|
+
findings = out.get("findings", [])
|
|
1339
|
+
if not findings:
|
|
1340
|
+
print(f"clean — {out.get('leases', 0)} lease(s), no strays.")
|
|
1341
|
+
return
|
|
1342
|
+
for f in findings:
|
|
1343
|
+
if f["kind"] == "sim":
|
|
1344
|
+
print(f" stray sim {f['name']} {f['udid']}")
|
|
1345
|
+
else:
|
|
1346
|
+
where = f" port {f['port']}" if f.get("port") else ""
|
|
1347
|
+
print(f" {f['kind']:<12} pid {f['pid']}{where} {f['cmd']}")
|
|
1348
|
+
print(f"{len(findings)} finding(s) " +
|
|
1349
|
+
("evicted." if out.get("fixed") else "— run `harbormaster doctor --fix` to evict."))
|
|
1350
|
+
if not out.get("fixed"):
|
|
1351
|
+
sys.exit(1)
|
|
998
1352
|
elif cmd in ("status", "leases"):
|
|
999
1353
|
if not _up():
|
|
1000
1354
|
print("daemon not running.")
|
|
@@ -1016,7 +1370,7 @@ def main():
|
|
|
1016
1370
|
f"{l.get('label') or ''} {l.get('cwd') or ''}".rstrip())
|
|
1017
1371
|
else:
|
|
1018
1372
|
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
1019
|
-
print("Commands: acquire, up, down, release, allocate, verify, status, leases, serve", file=sys.stderr)
|
|
1373
|
+
print("Commands: acquire, up, down, release, allocate, verify, doctor, status, leases, serve", file=sys.stderr)
|
|
1020
1374
|
sys.exit(1)
|
|
1021
1375
|
|
|
1022
1376
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/harbormaster",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|