@simon-says-build/harbormaster 0.8.0 → 0.9.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.
Files changed (3) hide show
  1. package/DESIGN.md +26 -10
  2. package/harbormaster +187 -14
  3. package/package.json +1 -1
package/DESIGN.md CHANGED
@@ -109,22 +109,38 @@ before it was caught (2026-07-29). Teardown in v0.7 is therefore layered:
109
109
 
110
110
  1. `killpg` the recorded group (TERM → wait on **group** emptiness, not parent
111
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
112
+ 2. **Tree sweep** (v0.9): while a lease's root lives, its detached children
113
+ still report it as `ppid` — setsid changes the group, not the parent — so
114
+ the reaper tick records the observed descendant tree as `(pid, start-time)`
115
+ pairs in the ledger. Teardown kills the recorded tree, start re-checked at
116
+ kill time. This is what catches the non-listening functions workers that
117
+ the group kill structurally misses and the port sweep can't see.
118
+ 3. **Port sweep**: anything still LISTENing on any port of the lease's block is
113
119
  killed (TERM → KILL). The block *is* the lease — this catches detached JVMs,
114
120
  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 —
121
+ 4. Verify the block's ports are actually free, and log every action taken —
116
122
  a teardown that leaves a listener behind says so instead of pretending.
117
123
 
118
124
  Beyond per-lease teardown, the daemon reconciles **both directions**: the ledger
119
125
  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.
126
+ every `FBPORTS_SWEEP_INTERVAL` (300s) it looks for listeners on pool ports no
127
+ lease covers, firebase-tools runtimes re-parented to pid 1 (the
128
+ detached-children signature: `functionsEmulatorRuntime`,
129
+ `cloud-firestore-emulator*.jar`, …), and `run-*`/`alloc-*` simulators with no
130
+ lease. `harbormaster doctor [--fix]` runs the same sweep on demand.
131
+
132
+ Finding and killing are separate verdicts (v0.9). Everything above is worth
133
+ *reporting*, but the sweep evicts only what it can positively attribute to
134
+ itself: a pid in a recorded lease tree (start-time re-checked), a cmdline
135
+ naming the `.firebase.brokered-*` config only harbormaster writes, or a sim
136
+ whose UDID is in the daemon's own `authored-sims.json` (written at create,
137
+ kept separate from the ledger so a lost ledger orphans leases, not
138
+ attribution). "Looks like it might have come from us" is not attribution —
139
+ v0.8's lease-shaped heuristic killed a resident ollama whose default port sat
140
+ inside the pool, and `run-*` name-matching had two daemons on one machine
141
+ deleting each other's simulators. Unattributed findings are reported by
142
+ `doctor` and never signalled; the daemon owns lifecycle end-to-end, callers
143
+ use `acquire`/`release`, never `pkill`.
128
144
 
129
145
  The ledger itself is written atomically (temp + rename) and a corrupt ledger is
130
146
  quarantined loudly instead of silently zeroed — with the world sweep adopting
package/harbormaster CHANGED
@@ -80,7 +80,7 @@ import uuid
80
80
  from datetime import datetime, timezone
81
81
  from pathlib import Path
82
82
 
83
- VERSION = "0.8.0"
83
+ VERSION = "0.9.0"
84
84
 
85
85
  # Overridable for tests / parallel installs; default is the shared user daemon.
86
86
  # The dir keeps the old "firebase-ports" name: pre-rename binaries still installed
@@ -127,6 +127,48 @@ SWEEP_INTERVAL = int(os.environ.get("FBPORTS_SWEEP_INTERVAL", "300"))
127
127
  SWEEP_SIGNATURES = ("functionsEmulatorRuntime", "cloud-firestore-emulator",
128
128
  "cloud-storage-rules-runtime", "pubsub-emulator",
129
129
  "cloud-datastore-emulator", "firebase-database-emulator")
130
+ # Authorship, not resemblance. v0.8.1 killed pool-port listeners that "looked
131
+ # lease-shaped" (node/java/python) — a profile, and profiles convict bystanders
132
+ # (0.8.0's broader rule killed ollama, whose 11434 sits inside the default
133
+ # pool). The only honest kill test is "harbormaster started this", so that is
134
+ # what gets recorded: while a lease's root process is alive, its detached
135
+ # children still report it as ppid (setsid changes the group, not the parent),
136
+ # and the reaper tick snapshots that descendant tree — (pid, start-time), start
137
+ # checked again before any signal so a recycled pid is never a stranger's
138
+ # death warrant. Teardown kills the recorded tree; the world sweep evicts only
139
+ # what it can positively attribute: a recorded pid, or a cmdline naming the
140
+ # injected config file harbormaster itself wrote (.firebase.brokered-*).
141
+ # Everything else — signature-matched orphans included — is reported by
142
+ # doctor and never signalled. The env-marker alternative is off the table on
143
+ # macOS: KERN_PROCARGS2 hides other processes' environments even same-user.
144
+ def _ps_table():
145
+ """{pid: (ppid, lstart)} for every visible process, one ps call."""
146
+ table = {}
147
+ try:
148
+ r = subprocess.run(["ps", "-axo", "pid=,ppid=,lstart="],
149
+ capture_output=True, text=True, timeout=10)
150
+ for line in r.stdout.splitlines():
151
+ parts = line.split(None, 2)
152
+ if len(parts) == 3:
153
+ table[int(parts[0])] = (int(parts[1]), parts[2].strip())
154
+ except (subprocess.SubprocessError, OSError, ValueError):
155
+ pass
156
+ return table
157
+
158
+
159
+ def _descendants(root, table):
160
+ """{pid: lstart} of every process reachable from root via ppid edges."""
161
+ kids = {}
162
+ for pid, (ppid, _) in table.items():
163
+ kids.setdefault(ppid, []).append(pid)
164
+ out, frontier = {}, [int(root)]
165
+ while frontier:
166
+ p = frontier.pop()
167
+ for c in kids.get(p, ()):
168
+ if c not in out:
169
+ out[c] = table[c][1]
170
+ frontier.append(c)
171
+ return out
130
172
 
131
173
 
132
174
  def _now():
@@ -294,6 +336,30 @@ def _write_injected_config(cwd, ports):
294
336
  return tmp
295
337
 
296
338
 
339
+ # Simulator authorship. run-*/alloc-* NAMING is not attribution: two daemons
340
+ # on one machine (a test suite next to the production broker) each read the
341
+ # other's sims as their own strays and delete them — observed 2026-08-12, the
342
+ # production broker eating the e2e's fixtures within seconds. So every UDID
343
+ # this daemon creates is recorded in its own file, SEPARATE from the ledger:
344
+ # ledger loss must orphan leases, not attribution. Eviction requires the UDID
345
+ # to be in this registry; a matching name alone is reported, never deleted.
346
+ SIMS_FILE = STATE_DIR / "authored-sims.json"
347
+
348
+
349
+ def _authored_sims():
350
+ try:
351
+ return set(json.loads(SIMS_FILE.read_text()))
352
+ except (OSError, json.JSONDecodeError):
353
+ return set()
354
+
355
+
356
+ def _authored_sims_write(udids):
357
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
358
+ tmp = SIMS_FILE.with_suffix(".tmp")
359
+ tmp.write_text(json.dumps(sorted(udids)))
360
+ tmp.replace(SIMS_FILE)
361
+
362
+
297
363
  def _sim_create_boot(device, runtime, name):
298
364
  """Create a fresh iOS simulator, boot it, and return its UDID. The device is
299
365
  per-lease (unique name) so concurrent e2e runs never share one; reaped on
@@ -303,6 +369,9 @@ def _sim_create_boot(device, runtime, name):
303
369
  if r.returncode != 0:
304
370
  raise RuntimeError(f"simctl create failed ({device} / {runtime}): {r.stderr.strip()}")
305
371
  udid = r.stdout.strip()
372
+ # Recorded before the boot: a crash mid-boot must still leave the device
373
+ # attributable, or nothing may ever delete it.
374
+ _authored_sims_write(_authored_sims() | {udid})
306
375
  # boot is idempotent-ish (errors if already booted); bootstatus waits until ready.
307
376
  subprocess.run(["xcrun", "simctl", "boot", udid], capture_output=True, text=True, timeout=120)
308
377
  subprocess.run(["xcrun", "simctl", "bootstatus", udid, "-b"], capture_output=True, text=True, timeout=300)
@@ -335,7 +404,10 @@ def _sim_teardown(udid):
335
404
  except (subprocess.SubprocessError, OSError):
336
405
  pass
337
406
  udids = _sim_udids()
338
- return udids is None or udid not in udids
407
+ gone = udids is None or udid not in udids
408
+ if gone and udid in _authored_sims():
409
+ _authored_sims_write(_authored_sims() - {udid})
410
+ return gone
339
411
 
340
412
 
341
413
  def _sim_inventory():
@@ -455,7 +527,31 @@ class Broker:
455
527
  if reason:
456
528
  print(f"reaping {lid}: {reason}", flush=True)
457
529
  self._cleanup(self.leases.pop(lid))
530
+ self._record_trees()
458
531
  self._save()
532
+
533
+ def _record_trees(self):
534
+ """Every tick, note which processes each live lease's root has fathered.
535
+ This is the authorship ledger the teardown and sweep kill from: a
536
+ detached child keeps its ppid while the parent lives, so polling while
537
+ the suite is up sees the workers that killpg will later miss. Entries
538
+ accumulate across polls (a worker seen once is remembered after it
539
+ detaches); a recycled pid — same number, different start — is dropped,
540
+ never inherited."""
541
+ table = _ps_table()
542
+ if not table:
543
+ return
544
+ for rec in self.leases.values():
545
+ root = rec.get("pid")
546
+ if not root or root not in table:
547
+ continue
548
+ tree = rec.setdefault("tree", {})
549
+ for pid, start in _descendants(root, table).items():
550
+ tree[str(pid)] = start
551
+ for pid in list(tree):
552
+ live = table.get(int(pid))
553
+ if live and live[1] != tree[pid]:
554
+ del tree[pid] # recycled pid — a stranger now
459
555
  # Ledger-vs-world is only half the job: anything that escaped the
460
556
  # ledger (daemon crash mid-boot, corrupt ledger, detached children of
461
557
  # past suites) is invisible to the loop above and immortal without this.
@@ -639,6 +735,23 @@ class Broker:
639
735
  ("SURVIVED kill" if _group_alive(pgid) else "killed"))
640
736
  else:
641
737
  reap_zombie()
738
+ # The recorded tree: children observed under the root while it lived,
739
+ # including the setsid'd workers the group kill above can never reach
740
+ # and non-listening processes the port sweep below can never see —
741
+ # functionsEmulatorRuntime leaked two per run through exactly that gap.
742
+ # Start-time is re-checked at kill time: a recycled pid is a stranger.
743
+ tree = rec.get("tree") or {}
744
+ strays = set()
745
+ for pid, start in tree.items():
746
+ pid = int(pid)
747
+ observed = _proc_start(pid)
748
+ if observed and observed == start:
749
+ strays.add(pid)
750
+ strays.discard(rec.get("pid"))
751
+ if strays:
752
+ killed = _kill_wave(strays)
753
+ if killed:
754
+ actions.append(f"tree sweep killed {sorted(killed)}")
642
755
  # Port sweep: every port the lease covers. rec["ports"] catches legacy
643
756
  # records; the block range catches everything else (metro, websocket).
644
757
  ports = {v for v in (rec.get("ports") or {}).values() if isinstance(v, int)}
@@ -840,6 +953,10 @@ class Broker:
840
953
  # JVMs left zero trace. Every reap states its reason.
841
954
  print(f"reaping {lid}: {reason}", flush=True)
842
955
  self._cleanup(self.leases.pop(lid))
956
+ # Runs on the tick, not just at startup: the tree must be observed
957
+ # WHILE the parents live — after a root dies its detached children
958
+ # read ppid 1 and can never be attributed again.
959
+ self._record_trees()
843
960
  self._reap_deleted_sims(now)
844
961
  self._save()
845
962
  if now - getattr(self, "_swept", 0) >= SWEEP_INTERVAL:
@@ -904,11 +1021,34 @@ class Broker:
904
1021
  functions workers whose suite died — killpg can never reach these);
905
1022
  - harbormaster-named simulators (run-*/alloc-*) with no lease.
906
1023
 
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`."""
1024
+ Finding and killing are separate verdicts. Everything above is worth
1025
+ REPORTING (a squatted block can't be leased; an orphaned JVM is a leak
1026
+ whoever fathered it), but eviction requires positive attribution: the
1027
+ pid appears in a live lease's recorded tree (start-time re-checked),
1028
+ the cmdline names the .firebase.brokered-* config only harbormaster
1029
+ writes, or the sim's UDID is in this daemon's authored-sims registry.
1030
+ "Looks like it might have come from us" is not attribution — the
1031
+ lease-shape rule killed ollama, and name-matching run-* sims had two
1032
+ daemons on one box deleting each other's devices.
1033
+ Returns findings for `doctor`."""
910
1034
  findings = []
911
1035
  lease_pids = {int(l["pid"]) for l in self.leases.values() if l.get("pid")}
1036
+ # The authorship ledger: every (pid, start) any live lease's tree has
1037
+ # recorded. Eviction requires membership here — start re-checked so a
1038
+ # recycled pid never matches — or a cmdline that names the injected
1039
+ # config file only harbormaster writes. Anything else is somebody
1040
+ # else's process wearing a familiar name: reported, never signalled.
1041
+ authored = {}
1042
+ for l in self.leases.values():
1043
+ for pid, start in (l.get("tree") or {}).items():
1044
+ authored[int(pid)] = start
1045
+
1046
+ def _attributed(pid, cmdline=""):
1047
+ if ".firebase.brokered-" in cmdline:
1048
+ return True
1049
+ start = authored.get(pid)
1050
+ return bool(start) and _proc_start(pid) == start
1051
+
912
1052
  leased_ports = set()
913
1053
  for l in self.leases.values():
914
1054
  if l.get("block"):
@@ -918,8 +1058,9 @@ class Broker:
918
1058
  if POOL_BASE <= port < pool_end and port not in leased_ports:
919
1059
  for pid in pids:
920
1060
  if pid not in lease_pids:
1061
+ cmd = _proc_field(pid, "command=")[:200]
921
1062
  findings.append({"kind": "listener", "pid": pid, "port": port,
922
- "cmd": _proc_field(pid, "command=")[:200]})
1063
+ "cmd": cmd, "evict": _attributed(pid, cmd)})
923
1064
  try:
924
1065
  r = subprocess.run(["ps", "-xo", "pid=,ppid=,command="],
925
1066
  capture_output=True, text=True, timeout=10)
@@ -933,26 +1074,48 @@ class Broker:
933
1074
  if (any(sig in cmdline for sig in SWEEP_SIGNATURES)
934
1075
  or ".firebase.brokered-" in cmdline):
935
1076
  findings.append({"kind": "orphan", "pid": pid,
936
- "cmd": cmdline[:200]})
1077
+ "cmd": cmdline[:200],
1078
+ "evict": _attributed(pid, cmdline)})
937
1079
  except (subprocess.SubprocessError, OSError, ValueError):
938
1080
  pass
939
1081
  inv = _sim_inventory()
940
1082
  if inv is not None:
941
1083
  leased_udids = {l.get("sim_udid") for l in self.leases.values()}
1084
+ mine = _authored_sims()
942
1085
  for udid, name in inv.items():
943
1086
  if ((name.startswith("run-") or name.startswith("alloc-"))
944
1087
  and udid not in leased_udids):
945
- findings.append({"kind": "sim", "udid": udid, "name": name})
1088
+ findings.append({"kind": "sim", "udid": udid, "name": name,
1089
+ "evict": udid in mine})
946
1090
  if apply and findings:
947
- pids = {f["pid"] for f in findings if f["kind"] in ("listener", "orphan")}
1091
+ pids = {f["pid"] for f in findings
1092
+ if f["kind"] in ("listener", "orphan") and f.get("evict")}
948
1093
  if pids:
949
1094
  killed = _kill_wave(pids)
950
1095
  print(f"world sweep killed {killed}", flush=True)
951
1096
  for f in findings:
952
- if f["kind"] == "sim":
1097
+ if f["kind"] == "sim" and f.get("evict"):
953
1098
  gone = _sim_teardown(f["udid"])
954
1099
  print(f"world sweep sim {f['name']} ({f['udid'][:8]}): "
955
1100
  + ("deleted" if gone else "DELETE FAILED"), flush=True)
1101
+ elif f["kind"] == "sim":
1102
+ seen = self.__dict__.setdefault("_bystanders_logged", set())
1103
+ if f["udid"] not in seen:
1104
+ seen.add(f["udid"])
1105
+ print(f"world sweep sim {f['name']} ({f['udid'][:8]}) left "
1106
+ f"alone (not provably ours — another daemon's?)",
1107
+ flush=True)
1108
+ elif not f.get("evict"):
1109
+ # Logged once per pid — an unattributed finding survives
1110
+ # every sweep by design, and 288 identical lines a day is
1111
+ # how logs go unread.
1112
+ seen = self.__dict__.setdefault("_bystanders_logged", set())
1113
+ if f["pid"] not in seen:
1114
+ seen.add(f["pid"])
1115
+ where = f" on port {f['port']}" if f.get("port") else ""
1116
+ print(f"world sweep {f['kind']}{where} left alone "
1117
+ f"(not provably ours): pid {f['pid']} — {f['cmd']}",
1118
+ flush=True)
956
1119
  else:
957
1120
  print(f"world sweep {f['kind']}: pid {f['pid']}"
958
1121
  + (f" port {f['port']}" if f.get("port") else "")
@@ -1339,14 +1502,24 @@ def main():
1339
1502
  if not findings:
1340
1503
  print(f"clean — {out.get('leases', 0)} lease(s), no strays.")
1341
1504
  return
1505
+ spared = [f for f in findings if not f.get("evict", True)]
1342
1506
  for f in findings:
1507
+ tag = " (not provably ours — never evicted)" if f in spared else ""
1343
1508
  if f["kind"] == "sim":
1344
- print(f" stray sim {f['name']} {f['udid']}")
1509
+ print(f" stray sim {f['name']} {f['udid']}{tag}")
1345
1510
  else:
1346
1511
  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."))
1512
+ print(f" {f['kind']:<12} pid {f['pid']}{where} {f['cmd']}{tag}")
1513
+ evictable = len(findings) - len(spared)
1514
+ if out.get("fixed"):
1515
+ print(f"{evictable} finding(s) evicted."
1516
+ + (f" {len(spared)} left alone — harbormaster only kills what "
1517
+ f"it can prove it authored; evict those by hand if they're "
1518
+ f"yours." if spared else ""))
1519
+ else:
1520
+ print(f"{len(findings)} finding(s) — run `harbormaster doctor --fix` to evict"
1521
+ + (f" ({len(spared)} not provably ours, will be left alone)."
1522
+ if spared else "."))
1350
1523
  if not out.get("fixed"):
1351
1524
  sys.exit(1)
1352
1525
  elif cmd in ("status", "leases"):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/harbormaster",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",