@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 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/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. (Formerly firebase-ports.)
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] [--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.1"
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
- 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,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 (best effort) so devices never leak."""
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
- pass
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
- LEDGER.write_text(json.dumps({"leases": self.leases}, indent=2))
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
- """Coordinated teardown — killpg the whole group so JVM children die too. Never pkill."""
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
- # _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):
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(os.getpgid(int(pid)), sig)
629
+ os.killpg(int(pgid), sig)
445
630
  except (OSError, ProcessLookupError):
446
631
  break
447
632
  for _ in range(50):
448
- if not _child_alive(pid):
633
+ reap_zombie()
634
+ if not _group_alive(pgid):
449
635
  break
450
636
  time.sleep(0.1)
451
- if not _child_alive(pid):
452
- break
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
- pid, tmp, log, ports, check = self._boot(cwd, base, only, project_id, lid.replace(":", "-"))
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({"pid": pid, "cwd": cwd, "tmp_config": tmp})
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
- if self._dead(self.leases[lid], now):
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
- opts[k] = v
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")}), "acquire"), fmt)
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.6.1",
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",