@simon-says-build/harbormaster 0.6.2 → 0.8.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.
Files changed (3) hide show
  1. package/DESIGN.md +37 -5
  2. package/harbormaster +418 -28
  3. 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 always `killpg`s the whole process group so the emulator's **JVM children are
103
- reaped** — a v1 bug leaked Firestore/Storage JVMs because only the node parent was
104
- killed (verify this in v2). The daemon owns lifecycle end-to-end; callers use
105
- `acquire`/`release`, never `pkill`.
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/harbormaster CHANGED
@@ -7,13 +7,17 @@ fresh data) and reclaims it when the caller's process exits. No registry, no sha
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] [--json|--env]
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. Tune with --sim-device / --sim-runtime
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.6.2"
83
+ VERSION = "0.8.1"
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
- POOL_BASE, BLOCK, POOL_BLOCKS = 11000, 20, 200 # blocks of 20 ports, 11000-14999
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,34 @@ 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")
130
+ # What the listener sweep may KILL. Everything a lease has ever put on a pool
131
+ # port — firebase hubs and their JVMs, Metro/vite/dev servers, test drivers —
132
+ # runs under node, java, or python. A listener that is none of those is a
133
+ # bystander that happened to bind in-range (ollama's default 11434 sits inside
134
+ # the default pool, and the sweep killed it on 2026-08-11), and killing
135
+ # strangers is the pkill blast radius this tool exists to end. Bystanders are
136
+ # still reported — a squatted block can't be leased, so doctor must show them —
137
+ # but they are never signalled. The trade: an escapee from a LOST lease whose
138
+ # --spawn was some other binary now outlives the sweep; report-only is the
139
+ # right side of that trade.
140
+ LEASE_SHAPES = ("node", "java", "python")
141
+
142
+
143
+ def _lease_shaped(cmdline):
144
+ exe = os.path.basename((cmdline.split() or [""])[0]).lower()
145
+ return exe.startswith(LEASE_SHAPES)
103
146
 
104
147
 
105
148
  def _now():
@@ -299,12 +342,91 @@ def _sim_udids():
299
342
 
300
343
 
301
344
  def _sim_teardown(udid):
302
- """Shut down + delete a leased simulator (best effort) so devices never leak."""
345
+ """Shut down + delete a leased simulator. Returns True when the device is
346
+ confirmed gone. A failed delete is NOT fatal — the world sweep retries any
347
+ harbormaster-named device that has no lease — but it must be visible."""
303
348
  for args in (["shutdown", udid], ["delete", udid]):
304
349
  try:
305
350
  subprocess.run(["xcrun", "simctl", *args], capture_output=True, text=True, timeout=60)
306
351
  except (subprocess.SubprocessError, OSError):
307
352
  pass
353
+ udids = _sim_udids()
354
+ return udids is None or udid not in udids
355
+
356
+
357
+ def _sim_inventory():
358
+ """{udid: name} for every simulator, or None when indeterminate."""
359
+ try:
360
+ r = subprocess.run(["xcrun", "simctl", "list", "devices", "-j"],
361
+ capture_output=True, text=True, timeout=15)
362
+ if r.returncode != 0:
363
+ return None
364
+ devices = json.loads(r.stdout).get("devices", {})
365
+ return {d["udid"]: d.get("name", "")
366
+ for lst in devices.values() for d in lst if d.get("udid")}
367
+ except (subprocess.SubprocessError, OSError, json.JSONDecodeError,
368
+ AttributeError, TypeError):
369
+ return None
370
+
371
+
372
+ def _group_alive(pgid):
373
+ """Any member of the process group left? EPERM counts as alive."""
374
+ try:
375
+ os.killpg(int(pgid), 0)
376
+ return True
377
+ except PermissionError:
378
+ return True
379
+ except (OSError, ProcessLookupError, ValueError, TypeError):
380
+ return False
381
+
382
+
383
+ def _listeners():
384
+ """{port: {pids}} for this user's TCP LISTEN sockets — ONE lsof call, so
385
+ teardown and the world sweep can check whole port ranges cheaply."""
386
+ out = {}
387
+ try:
388
+ r = subprocess.run(["lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-Fpn"],
389
+ capture_output=True, text=True, timeout=15)
390
+ pid = None
391
+ for line in r.stdout.splitlines():
392
+ if line[:1] == "p":
393
+ try:
394
+ pid = int(line[1:])
395
+ except ValueError:
396
+ pid = None
397
+ elif line[:1] == "n" and pid:
398
+ try:
399
+ port = int(line.rsplit(":", 1)[1])
400
+ except (ValueError, IndexError):
401
+ continue
402
+ out.setdefault(port, set()).add(pid)
403
+ except (subprocess.SubprocessError, OSError):
404
+ pass
405
+ return out
406
+
407
+
408
+ def _kill_wave(pids, wait=5.0):
409
+ """SIGTERM the pids, wait, SIGKILL survivors. Returns the pids signalled.
410
+ Never signals ourselves or anything system-side (pid <= 1)."""
411
+ victims = sorted({int(p) for p in pids} - {os.getpid()})
412
+ victims = [p for p in victims if p > 1]
413
+ if not victims:
414
+ return []
415
+ for p in victims:
416
+ try:
417
+ os.kill(p, signal.SIGTERM)
418
+ except (OSError, ProcessLookupError):
419
+ pass
420
+ deadline = time.time() + wait
421
+ while time.time() < deadline and any(_pid_alive(p) for p in victims):
422
+ time.sleep(0.1)
423
+ for p in victims:
424
+ if _pid_alive(p):
425
+ try:
426
+ os.kill(p, signal.SIGKILL)
427
+ except (OSError, ProcessLookupError):
428
+ pass
429
+ return victims
308
430
 
309
431
 
310
432
  # ---- the daemon ----------------------------------------------------------
@@ -315,17 +437,33 @@ class Broker:
315
437
  self.leases = {} # leaseId -> caller-owned lease
316
438
  self._load()
317
439
  self._reconcile()
440
+ # _reconcile just swept; without this the reaper's throttle (epoch 0)
441
+ # would fire a second "periodic" sweep on its first tick, making
442
+ # FBPORTS_SWEEP_INTERVAL meaningless for the first interval.
443
+ self._swept = time.time()
318
444
 
319
445
  def _load(self):
320
446
  if LEDGER.exists():
321
447
  try:
322
448
  self.leases = json.loads(LEDGER.read_text()).get("leases", {})
323
- except (json.JSONDecodeError, OSError):
324
- pass
449
+ except (json.JSONDecodeError, OSError) as e:
450
+ # A silently-dropped ledger orphans every live suite/sim/port at
451
+ # once. Quarantine it loudly; the world sweep adopts the fallout.
452
+ quarantine = LEDGER.with_suffix(f".corrupt-{int(time.time())}")
453
+ try:
454
+ LEDGER.rename(quarantine)
455
+ except OSError:
456
+ pass
457
+ print(f"ledger unreadable ({e}); quarantined to {quarantine} — "
458
+ f"world sweep will reap anything it was tracking", flush=True)
325
459
 
326
460
  def _save(self):
327
461
  STATE_DIR.mkdir(parents=True, exist_ok=True)
328
- LEDGER.write_text(json.dumps({"leases": self.leases}, indent=2))
462
+ # Atomic: a crash mid-write must never leave a half-written ledger
463
+ # (see _load — a corrupt ledger orphans everything at once).
464
+ tmp = LEDGER.with_suffix(".tmp")
465
+ tmp.write_text(json.dumps({"leases": self.leases}, indent=2))
466
+ tmp.replace(LEDGER)
329
467
 
330
468
  def _reconcile(self):
331
469
  for lid in list(self.leases):
@@ -334,6 +472,10 @@ class Broker:
334
472
  print(f"reaping {lid}: {reason}", flush=True)
335
473
  self._cleanup(self.leases.pop(lid))
336
474
  self._save()
475
+ # Ledger-vs-world is only half the job: anything that escaped the
476
+ # ledger (daemon crash mid-boot, corrupt ledger, detached children of
477
+ # past suites) is invisible to the loop above and immortal without this.
478
+ self._sweep_world(apply=True)
337
479
 
338
480
  def _dead(self, l, now):
339
481
  return bool(self._dead_reason(l, now))
@@ -375,6 +517,15 @@ class Broker:
375
517
  if l.get("down_since") and now - l["down_since"] >= RECLAIM_GRACE:
376
518
  return "reserved block down past grace"
377
519
  return None
520
+ if l.get("state") == "booting":
521
+ # Provisional record written at spawn time so a daemon crash can't
522
+ # orphan a mid-boot suite. Ports aren't expected open yet — only a
523
+ # dead process or a blown boot window reaps it.
524
+ if not _child_alive(l.get("pid")):
525
+ return f"suite pid {l.get('pid')} died during boot"
526
+ if now - l.get("booting_since", now) > BOOT_BACKSTOP + 60:
527
+ return "boot window exceeded"
528
+ return None
378
529
  # Booted suite: the process is the strong liveness signal — reap a dead
379
530
  # process immediately. Ports are a weak signal (a single 0.5s connect can
380
531
  # fail on a loaded machine while the suite is fine), so closed ports only
@@ -398,7 +549,7 @@ class Broker:
398
549
  return base
399
550
  raise RuntimeError("emulator port pool exhausted")
400
551
 
401
- def _boot(self, cwd, block, only, project_id, label):
552
+ def _boot(self, cwd, block, only, project_id, label, on_spawn=None, extra_env=None):
402
553
  all_ports = _block_ports(block)
403
554
  tmp = _write_injected_config(cwd, all_ports)
404
555
  # Readiness/liveness track ONLY the data services actually started (respect
@@ -413,8 +564,20 @@ class Broker:
413
564
  cmd += ["--only", ",".join(only)]
414
565
  if project_id:
415
566
  cmd += ["--project", project_id]
567
+ # Caller-supplied env (acquire --fn-env KEY=VAL) rides on top of the
568
+ # daemon's env — the functions emulator runtime inherits it, which is
569
+ # the only way a CALLER can set per-suite env (e.g. LLM_CACHE_DIR)
570
+ # when the daemon, not the caller, spawns the emulator.
571
+ spawn_env = None
572
+ if extra_env:
573
+ spawn_env = {**os.environ, **{str(k): str(v) for k, v in dict(extra_env).items()}}
416
574
  with open(log, "w") as lf:
417
- proc = subprocess.Popen(cmd, cwd=cwd, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True)
575
+ proc = subprocess.Popen(cmd, cwd=cwd, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True, env=spawn_env)
576
+ if on_spawn:
577
+ # Register the suite BEFORE the (up to 600s) readiness wait: a
578
+ # daemon crash inside this window used to leave a fully-booted,
579
+ # session-detached, ledger-less suite that nothing could ever find.
580
+ on_spawn(proc.pid, tmp, str(log))
418
581
  # Liveness-driven: keep waiting while the process is alive (a slow JVM is still
419
582
  # booting, not stuck); succeed when the ports open; fail if it exits; the
420
583
  # backstop only guards a genuinely wedged boot.
@@ -433,36 +596,121 @@ class Broker:
433
596
  time.sleep(1)
434
597
 
435
598
  def _cleanup(self, rec):
436
- """Coordinated teardown — killpg the whole group so JVM children die too. Never pkill."""
599
+ """Teardown frees the lease's RESOURCES — process group, ports, sim —
600
+ and verifies, loudly. Killing only the process we spawned is not
601
+ enough: firebase-tools detaches its JVMs and functions workers into
602
+ their own groups (killpg can't reach them), and the caller's Metro on
603
+ the leased port was never ours at all. The port block IS the lease, so
604
+ after the group kill anything still LISTENing on the block dies too,
605
+ and the block is re-checked before we forget the lease. Never pkill —
606
+ every kill here is by recorded pid or by a port the lease owns."""
607
+ lid = rec.get("leaseId") or "?"
608
+ actions = []
437
609
  pid = rec.get("pid")
438
- # _child_alive, not _pid_alive: a daemon-spawned process that already
439
- # exited lingers as our zombie child, which passes kill-0 — treating it
440
- # as alive would stall this loop through a futile TERM+KILL cycle.
441
- if _child_alive(pid):
610
+ # PID-reuse guard (same contract as owners): if the pid's start time no
611
+ # longer matches what we recorded at spawn, it's a recycled pid — do
612
+ # not signal a stranger. Old ledger records lack pid_start; for those
613
+ # the group kill proceeds as before.
614
+ recorded_start = rec.get("pid_start")
615
+ if pid and recorded_start:
616
+ observed = _proc_start(pid)
617
+ if observed and observed != recorded_start:
618
+ actions.append(f"pid {pid} recycled — not signalled")
619
+ pid = None
620
+ pgid = rec.get("pgid")
621
+ if pgid is None and pid and _pid_alive(pid):
622
+ try:
623
+ pgid = os.getpgid(int(pid))
624
+ except (OSError, ProcessLookupError):
625
+ pgid = None
626
+ def reap_zombie():
627
+ # Our exited child lingers as a zombie until waited on — and a
628
+ # zombie still counts as a group member, so _group_alive would
629
+ # read "survived" forever without this.
630
+ if pid:
631
+ try:
632
+ os.waitpid(int(pid), os.WNOHANG)
633
+ except (ChildProcessError, OSError, ValueError):
634
+ pass
635
+
636
+ if pgid and int(pgid) > 1:
637
+ # Escalation waits on the GROUP, not the parent: the old loop broke
638
+ # the moment node exited on SIGTERM, so SIGKILL never reached slow
639
+ # or stubborn group members.
442
640
  for sig in (signal.SIGTERM, signal.SIGKILL):
641
+ reap_zombie()
642
+ if not _group_alive(pgid):
643
+ break
443
644
  try:
444
- os.killpg(os.getpgid(int(pid)), sig)
645
+ os.killpg(int(pgid), sig)
445
646
  except (OSError, ProcessLookupError):
446
647
  break
447
648
  for _ in range(50):
448
- if not _child_alive(pid):
649
+ reap_zombie()
650
+ if not _group_alive(pgid):
449
651
  break
450
652
  time.sleep(0.1)
451
- if not _child_alive(pid):
452
- break
653
+ reap_zombie()
654
+ actions.append(f"group {pgid} " +
655
+ ("SURVIVED kill" if _group_alive(pgid) else "killed"))
656
+ else:
657
+ reap_zombie()
658
+ # Port sweep: every port the lease covers. rec["ports"] catches legacy
659
+ # records; the block range catches everything else (metro, websocket).
660
+ ports = {v for v in (rec.get("ports") or {}).values() if isinstance(v, int)}
661
+ if rec.get("block"):
662
+ ports |= {rec["block"] + off for off in range(BLOCK)}
663
+ if ports:
664
+ listening = _listeners()
665
+ squatters = {p for port in ports for p in listening.get(port, ())}
666
+ killed = _kill_wave(squatters)
667
+ if killed:
668
+ actions.append(f"port sweep killed {killed}")
669
+ still = sorted(port for port, pids in _listeners().items()
670
+ if port in ports and pids)
671
+ if still:
672
+ actions.append(f"ports STILL HELD after sweep: {still}")
453
673
  tmp = rec.get("tmp_config")
454
674
  if tmp and rec.get("cwd") and (Path(rec["cwd"]) / tmp).exists():
455
675
  (Path(rec["cwd"]) / tmp).unlink()
456
676
  if rec.get("sim_udid"):
457
- _sim_teardown(rec["sim_udid"])
677
+ gone = _sim_teardown(rec["sim_udid"])
678
+ actions.append(f"sim {rec['sim_udid'][:8]} " +
679
+ ("deleted" if gone else "DELETE FAILED (sweep will retry)"))
680
+ if actions:
681
+ print(f"teardown {lid}: " + "; ".join(actions), flush=True)
458
682
 
459
683
  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):
684
+ metro=False, sim=False, sim_device=None, sim_runtime=None, env=None):
461
685
  with self.lock:
462
686
  base = self._free_block()
463
687
  lid = f"run:{uuid.uuid4().hex[:12]}"
464
688
  project_id = _firebaserc_project(cwd)
465
- pid, tmp, log, ports, check = self._boot(cwd, base, only, project_id, lid.replace(":", "-"))
689
+
690
+ def on_spawn(spawn_pid, spawn_tmp, spawn_log):
691
+ # Provisional record, saved before the readiness wait: a daemon
692
+ # crash mid-boot must leave a findable lease, not a ghost suite.
693
+ self.leases[lid] = {"leaseId": lid, "kind": "acquired",
694
+ "state": "booting", "booting_since": time.time(),
695
+ "cwd": cwd, "block": base, "ports": {},
696
+ "pid": spawn_pid, "pgid": spawn_pid,
697
+ "pid_start": _proc_start(spawn_pid),
698
+ "owner_pid": owner_pid, "owner_start": owner_start,
699
+ "tmp_config": spawn_tmp, "log": spawn_log,
700
+ "project_id": project_id,
701
+ "started": _now(), "expires": time.time() + ttl}
702
+ self._save()
703
+
704
+ try:
705
+ pid, tmp, log, ports, check = self._boot(
706
+ cwd, base, only, project_id, lid.replace(":", "-"), on_spawn,
707
+ extra_env=env)
708
+ except Exception:
709
+ rec = self.leases.pop(lid, None)
710
+ if rec:
711
+ self._cleanup(rec)
712
+ self._save()
713
+ raise
466
714
  # A leased simulator implies a metro port (the e2e dev-env wants both).
467
715
  # The metro port lives in the lease's own 20-port block, so it's
468
716
  # collision-free by construction and needs no health check.
@@ -476,10 +724,13 @@ class Broker:
476
724
  sim_runtime or DEFAULT_SIM_RUNTIME, sim_name)
477
725
  except Exception:
478
726
  # Don't leak the emulators we just booted if the sim fails.
479
- self._cleanup({"pid": pid, "cwd": cwd, "tmp_config": tmp})
727
+ self._cleanup(self.leases.pop(lid, None) or
728
+ {"pid": pid, "cwd": cwd, "tmp_config": tmp, "block": base})
729
+ self._save()
480
730
  raise
481
731
  self.leases[lid] = {"leaseId": lid, "kind": "acquired", "cwd": cwd, "block": base,
482
732
  "ports": ports, "check_ports": check, "pid": pid,
733
+ "pgid": pid, "pid_start": _proc_start(pid),
483
734
  "owner_pid": owner_pid, "owner_start": owner_start,
484
735
  "tmp_config": tmp, "log": log, "project_id": project_id,
485
736
  "sim_udid": sim_udid, "sim_name": sim_name,
@@ -541,6 +792,7 @@ class Broker:
541
792
  f"{f' spawned pid {pid}' if pid else ''}", flush=True)
542
793
  self.leases[lid] = {"leaseId": lid, "kind": "reserved", "cwd": spawn_cwd,
543
794
  "block": base, "ports": ports, "pid": pid,
795
+ "pgid": pid, "pid_start": _proc_start(pid) if pid else None,
544
796
  "owner_pid": owner_pid,
545
797
  "owner_start": owner_start, "tmp_config": None,
546
798
  "log": log, "spawn": spawn,
@@ -571,6 +823,7 @@ class Broker:
571
823
  for lid in list(self.leases):
572
824
  l = self.leases[lid]
573
825
  if l.get("owner_pid") == owner_pid and (cwd is None or l.get("cwd") == cwd):
826
+ print(f"released {lid} (owner {owner_pid} asked)", flush=True)
574
827
  self._cleanup(self.leases.pop(lid))
575
828
  n += 1
576
829
  self._save()
@@ -597,10 +850,17 @@ class Broker:
597
850
  with self.lock:
598
851
  now = time.time()
599
852
  for lid in list(self.leases):
600
- if self._dead(self.leases[lid], now):
853
+ reason = self._dead_reason(self.leases[lid], now)
854
+ if reason:
855
+ # The routine reap path used to be silent — weeks of leaked
856
+ # JVMs left zero trace. Every reap states its reason.
857
+ print(f"reaping {lid}: {reason}", flush=True)
601
858
  self._cleanup(self.leases.pop(lid))
602
859
  self._reap_deleted_sims(now)
603
860
  self._save()
861
+ if now - getattr(self, "_swept", 0) >= SWEEP_INTERVAL:
862
+ self._swept = now
863
+ self._sweep_world(apply=True)
604
864
 
605
865
  def _reap_deleted_sims(self, now):
606
866
  """Reap leases whose simulator was deleted externally (Xcode cleanup,
@@ -649,6 +909,91 @@ class Broker:
649
909
  reasons.append("ports down: " + ",".join(sorted(down)))
650
910
  return {"ok": not reasons, "reasons": reasons}
651
911
 
912
+ def _sweep_world(self, apply):
913
+ """The other half of reconciliation: find resources that exist in the
914
+ WORLD but not in the ledger, and (apply=True) evict them. Three classes,
915
+ each one an observed leak:
916
+
917
+ - listeners on pool ports no lease covers (caller-run Metros, suites
918
+ that outlived a lost ledger entry);
919
+ - firebase-tools runtimes re-parented to pid 1 (detached JVMs and
920
+ functions workers whose suite died — killpg can never reach these);
921
+ - harbormaster-named simulators (run-*/alloc-*) with no lease.
922
+
923
+ Scoped tightly on purpose: only pool ports, only this user's processes,
924
+ only pid-1 ancestry with firebase-tools signatures, only our own sim
925
+ naming scheme. Returns findings for `doctor`."""
926
+ findings = []
927
+ lease_pids = {int(l["pid"]) for l in self.leases.values() if l.get("pid")}
928
+ leased_ports = set()
929
+ for l in self.leases.values():
930
+ if l.get("block"):
931
+ leased_ports |= {l["block"] + off for off in range(BLOCK)}
932
+ pool_end = POOL_BASE + POOL_BLOCKS * BLOCK
933
+ for port, pids in _listeners().items():
934
+ if POOL_BASE <= port < pool_end and port not in leased_ports:
935
+ for pid in pids:
936
+ if pid not in lease_pids:
937
+ cmd = _proc_field(pid, "command=")[:200]
938
+ findings.append({"kind": "listener", "pid": pid, "port": port,
939
+ "cmd": cmd, "evict": _lease_shaped(cmd)})
940
+ try:
941
+ r = subprocess.run(["ps", "-xo", "pid=,ppid=,command="],
942
+ capture_output=True, text=True, timeout=10)
943
+ for line in r.stdout.splitlines():
944
+ parts = line.split(None, 2)
945
+ if len(parts) < 3 or parts[1] != "1":
946
+ continue
947
+ pid, cmdline = int(parts[0]), parts[2]
948
+ if pid in lease_pids or pid == os.getpid():
949
+ continue
950
+ if (any(sig in cmdline for sig in SWEEP_SIGNATURES)
951
+ or ".firebase.brokered-" in cmdline):
952
+ findings.append({"kind": "orphan", "pid": pid,
953
+ "cmd": cmdline[:200]})
954
+ except (subprocess.SubprocessError, OSError, ValueError):
955
+ pass
956
+ inv = _sim_inventory()
957
+ if inv is not None:
958
+ leased_udids = {l.get("sim_udid") for l in self.leases.values()}
959
+ for udid, name in inv.items():
960
+ if ((name.startswith("run-") or name.startswith("alloc-"))
961
+ and udid not in leased_udids):
962
+ findings.append({"kind": "sim", "udid": udid, "name": name})
963
+ if apply and findings:
964
+ pids = {f["pid"] for f in findings
965
+ if f["kind"] == "orphan"
966
+ or (f["kind"] == "listener" and f.get("evict"))}
967
+ if pids:
968
+ killed = _kill_wave(pids)
969
+ print(f"world sweep killed {killed}", flush=True)
970
+ for f in findings:
971
+ if f["kind"] == "sim":
972
+ gone = _sim_teardown(f["udid"])
973
+ print(f"world sweep sim {f['name']} ({f['udid'][:8]}): "
974
+ + ("deleted" if gone else "DELETE FAILED"), flush=True)
975
+ elif f["kind"] == "listener" and not f.get("evict"):
976
+ # Logged once per pid — a bystander survives every sweep by
977
+ # design, and 288 identical lines a day is how logs go unread.
978
+ seen = self.__dict__.setdefault("_bystanders_logged", set())
979
+ if f["pid"] not in seen:
980
+ seen.add(f["pid"])
981
+ print(f"world sweep bystander on port {f['port']} left alone "
982
+ f"(not lease-shaped): pid {f['pid']} — {f['cmd']}", flush=True)
983
+ else:
984
+ print(f"world sweep {f['kind']}: pid {f['pid']}"
985
+ + (f" port {f['port']}" if f.get("port") else "")
986
+ + f" — {f['cmd']}", flush=True)
987
+ return findings
988
+
989
+ def doctor(self, fix=False):
990
+ """Report (and with fix=True, evict) everything the sweep can see —
991
+ the command that would have made 51 orphaned JVMs visible on day one."""
992
+ with self.lock:
993
+ findings = self._sweep_world(apply=fix)
994
+ return {"findings": findings, "fixed": bool(fix and findings),
995
+ "leases": len(self.leases)}
996
+
652
997
 
653
998
  class _Handler(http.server.BaseHTTPRequestHandler):
654
999
  broker = None
@@ -685,7 +1030,8 @@ class _Handler(http.server.BaseHTTPRequestHandler):
685
1030
  return self._send(200, b.acquire(d["cwd"], d.get("owner_pid"), d.get("owner_start"),
686
1031
  d.get("only"), d.get("ttl", DEFAULT_TTL),
687
1032
  d.get("metro", False), d.get("sim", False),
688
- d.get("sim_device"), d.get("sim_runtime")))
1033
+ d.get("sim_device"), d.get("sim_runtime"),
1034
+ d.get("env")))
689
1035
  if self.path == "/allocate":
690
1036
  return self._send(200, b.allocate(d.get("owner_pid"), d.get("owner_start"),
691
1037
  d.get("ttl", DEFAULT_TTL), d.get("label"),
@@ -700,6 +1046,8 @@ class _Handler(http.server.BaseHTTPRequestHandler):
700
1046
  return self._send(200, b.heartbeat(d.get("leaseId"), d.get("ttl", DEFAULT_TTL)))
701
1047
  if self.path == "/verify":
702
1048
  return self._send(200, b.verify(d.get("leaseId")))
1049
+ if self.path == "/doctor":
1050
+ return self._send(200, b.doctor(d.get("fix", False)))
703
1051
  if self.path == "/shutdown":
704
1052
  threading.Thread(target=self.server.shutdown, daemon=True).start()
705
1053
  return self._send(200, {"ok": True})
@@ -844,6 +1192,11 @@ def _print(out, fmt):
844
1192
  if sim:
845
1193
  print(f"export DETOX_UDID={sim['udid']}") # Detox targets this simulator by UDID
846
1194
  print(f"export DETOX_SIM_NAME={sim['name']}")
1195
+ # The lease id, so a long-running caller can `harbormaster release` a
1196
+ # suite it has decided is dead and re-acquire, instead of leaking it
1197
+ # until the reaper notices.
1198
+ if out.get("leaseId"):
1199
+ print(f"export FBPORTS_LEASE_ID={out['leaseId']}")
847
1200
  else:
848
1201
  print(f"{out.get('leaseId', '')} ({pid})".strip())
849
1202
  for svc, port in ports.items():
@@ -860,7 +1213,13 @@ def _parse(args):
860
1213
  if a.startswith("--"):
861
1214
  if "=" in a:
862
1215
  k, v = a[2:].split("=", 1)
863
- opts[k] = v
1216
+ if k == "fn-env":
1217
+ opts.setdefault("fn-env", []).append(v)
1218
+ else:
1219
+ opts[k] = v
1220
+ elif a[2:] == "fn-env" and i + 1 < len(args):
1221
+ opts.setdefault("fn-env", []).append(args[i + 1])
1222
+ i += 1
864
1223
  elif a[2:] in ("cwd", "only", "ttl", "owner", "label", "sim-device", "sim-runtime", "spawn") and i + 1 < len(args):
865
1224
  opts[a[2:]] = args[i + 1]
866
1225
  i += 1
@@ -900,7 +1259,8 @@ def main():
900
1259
  "ttl": int(opts.get("ttl", DEFAULT_TTL)),
901
1260
  "metro": "metro" in flags, "sim": "sim" in flags,
902
1261
  "sim_device": opts.get("sim-device"),
903
- "sim_runtime": opts.get("sim-runtime")}), "acquire"), fmt)
1262
+ "sim_runtime": opts.get("sim-runtime"),
1263
+ "env": dict(kv.split("=", 1) for kv in opts.get("fn-env", []))}), "acquire"), fmt)
904
1264
  elif cmd == "down":
905
1265
  if _up():
906
1266
  out = _call("POST", "/release-owned", {"owner_pid": os.getppid(), "cwd": _resolve_cwd(opts)})[1]
@@ -995,6 +1355,36 @@ def main():
995
1355
  else:
996
1356
  print("; ".join(out.get("reasons") or [out.get("error", "not ok")]))
997
1357
  sys.exit(1)
1358
+ elif cmd == "doctor":
1359
+ # doctor [--fix] — everything alive in the WORLD that the ledger does
1360
+ # not account for: pool-port squatters, orphaned firebase runtimes,
1361
+ # stray harbormaster sims. --fix evicts them. Exit 1 when findings
1362
+ # remain unfixed, so health checks can gate on it.
1363
+ _ensure()
1364
+ out = ok(_call("POST", "/doctor", {"fix": "fix" in flags}), "doctor")
1365
+ findings = out.get("findings", [])
1366
+ if not findings:
1367
+ print(f"clean — {out.get('leases', 0)} lease(s), no strays.")
1368
+ return
1369
+ spared = [f for f in findings if f["kind"] == "listener" and not f.get("evict", True)]
1370
+ for f in findings:
1371
+ if f["kind"] == "sim":
1372
+ print(f" stray sim {f['name']} {f['udid']}")
1373
+ else:
1374
+ where = f" port {f['port']}" if f.get("port") else ""
1375
+ tag = " (bystander — never evicted)" if f in spared else ""
1376
+ print(f" {f['kind']:<12} pid {f['pid']}{where} {f['cmd']}{tag}")
1377
+ evictable = len(findings) - len(spared)
1378
+ if out.get("fixed"):
1379
+ print(f"{evictable} finding(s) evicted."
1380
+ + (f" {len(spared)} bystander(s) left alone — they squat pool "
1381
+ f"ports harbormaster can't lease; move them or the pool."
1382
+ if spared else ""))
1383
+ else:
1384
+ print(f"{len(findings)} finding(s) — run `harbormaster doctor --fix` to evict"
1385
+ + (f" ({len(spared)} bystander(s) will be left alone)." if spared else "."))
1386
+ if not out.get("fixed"):
1387
+ sys.exit(1)
998
1388
  elif cmd in ("status", "leases"):
999
1389
  if not _up():
1000
1390
  print("daemon not running.")
@@ -1016,7 +1406,7 @@ def main():
1016
1406
  f"{l.get('label') or ''} {l.get('cwd') or ''}".rstrip())
1017
1407
  else:
1018
1408
  print(f"Unknown command: {cmd}", file=sys.stderr)
1019
- print("Commands: acquire, up, down, release, allocate, verify, status, leases, serve", file=sys.stderr)
1409
+ print("Commands: acquire, up, down, release, allocate, verify, doctor, status, leases, serve", file=sys.stderr)
1020
1410
  sys.exit(1)
1021
1411
 
1022
1412
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/harbormaster",
3
- "version": "0.6.2",
3
+ "version": "0.8.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",