@simon-says-build/harbormaster 0.8.1 → 0.9.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 +26 -10
  2. package/harbormaster +199 -41
  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
@@ -72,6 +72,7 @@ import json
72
72
  import os
73
73
  import signal
74
74
  import socket
75
+ import shutil
75
76
  import subprocess
76
77
  import sys
77
78
  import threading
@@ -80,7 +81,7 @@ import uuid
80
81
  from datetime import datetime, timezone
81
82
  from pathlib import Path
82
83
 
83
- VERSION = "0.8.1"
84
+ VERSION = "0.9.1"
84
85
 
85
86
  # Overridable for tests / parallel installs; default is the shared user daemon.
86
87
  # The dir keeps the old "firebase-ports" name: pre-rename binaries still installed
@@ -88,6 +89,9 @@ VERSION = "0.8.1"
88
89
  STATE_DIR = Path(os.environ.get("FBPORTS_STATE_DIR",
89
90
  str(Path.home() / ".local" / "state" / "firebase-ports")))
90
91
  LEDGER = STATE_DIR / "ledger.json"
92
+ # Per-suite TMPDIR for the emulator process (see EmulatorPool._boot).
93
+ def _suite_tmpdir(label):
94
+ return STATE_DIR / "tmp" / str(label)
91
95
  BROKER_HOST, BROKER_PORT = "127.0.0.1", int(os.environ.get("FBPORTS_PORT", "4999"))
92
96
  # Blocks of 20 ports, 11000-14999 by default. Overridable so an isolated test
93
97
  # daemon (FBPORTS_PORT + FBPORTS_STATE_DIR) can also use a DISJOINT pool — its
@@ -127,22 +131,48 @@ SWEEP_INTERVAL = int(os.environ.get("FBPORTS_SWEEP_INTERVAL", "300"))
127
131
  SWEEP_SIGNATURES = ("functionsEmulatorRuntime", "cloud-firestore-emulator",
128
132
  "cloud-storage-rules-runtime", "pubsub-emulator",
129
133
  "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)
134
+ # Authorship, not resemblance. v0.8.1 killed pool-port listeners that "looked
135
+ # lease-shaped" (node/java/python) — a profile, and profiles convict bystanders
136
+ # (0.8.0's broader rule killed ollama, whose 11434 sits inside the default
137
+ # pool). The only honest kill test is "harbormaster started this", so that is
138
+ # what gets recorded: while a lease's root process is alive, its detached
139
+ # children still report it as ppid (setsid changes the group, not the parent),
140
+ # and the reaper tick snapshots that descendant tree — (pid, start-time), start
141
+ # checked again before any signal so a recycled pid is never a stranger's
142
+ # death warrant. Teardown kills the recorded tree; the world sweep evicts only
143
+ # what it can positively attribute: a recorded pid, or a cmdline naming the
144
+ # injected config file harbormaster itself wrote (.firebase.brokered-*).
145
+ # Everything else — signature-matched orphans included — is reported by
146
+ # doctor and never signalled. The env-marker alternative is off the table on
147
+ # macOS: KERN_PROCARGS2 hides other processes' environments even same-user.
148
+ def _ps_table():
149
+ """{pid: (ppid, lstart)} for every visible process, one ps call."""
150
+ table = {}
151
+ try:
152
+ r = subprocess.run(["ps", "-axo", "pid=,ppid=,lstart="],
153
+ capture_output=True, text=True, timeout=10)
154
+ for line in r.stdout.splitlines():
155
+ parts = line.split(None, 2)
156
+ if len(parts) == 3:
157
+ table[int(parts[0])] = (int(parts[1]), parts[2].strip())
158
+ except (subprocess.SubprocessError, OSError, ValueError):
159
+ pass
160
+ return table
161
+
162
+
163
+ def _descendants(root, table):
164
+ """{pid: lstart} of every process reachable from root via ppid edges."""
165
+ kids = {}
166
+ for pid, (ppid, _) in table.items():
167
+ kids.setdefault(ppid, []).append(pid)
168
+ out, frontier = {}, [int(root)]
169
+ while frontier:
170
+ p = frontier.pop()
171
+ for c in kids.get(p, ()):
172
+ if c not in out:
173
+ out[c] = table[c][1]
174
+ frontier.append(c)
175
+ return out
146
176
 
147
177
 
148
178
  def _now():
@@ -310,6 +340,30 @@ def _write_injected_config(cwd, ports):
310
340
  return tmp
311
341
 
312
342
 
343
+ # Simulator authorship. run-*/alloc-* NAMING is not attribution: two daemons
344
+ # on one machine (a test suite next to the production broker) each read the
345
+ # other's sims as their own strays and delete them — observed 2026-08-12, the
346
+ # production broker eating the e2e's fixtures within seconds. So every UDID
347
+ # this daemon creates is recorded in its own file, SEPARATE from the ledger:
348
+ # ledger loss must orphan leases, not attribution. Eviction requires the UDID
349
+ # to be in this registry; a matching name alone is reported, never deleted.
350
+ SIMS_FILE = STATE_DIR / "authored-sims.json"
351
+
352
+
353
+ def _authored_sims():
354
+ try:
355
+ return set(json.loads(SIMS_FILE.read_text()))
356
+ except (OSError, json.JSONDecodeError):
357
+ return set()
358
+
359
+
360
+ def _authored_sims_write(udids):
361
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
362
+ tmp = SIMS_FILE.with_suffix(".tmp")
363
+ tmp.write_text(json.dumps(sorted(udids)))
364
+ tmp.replace(SIMS_FILE)
365
+
366
+
313
367
  def _sim_create_boot(device, runtime, name):
314
368
  """Create a fresh iOS simulator, boot it, and return its UDID. The device is
315
369
  per-lease (unique name) so concurrent e2e runs never share one; reaped on
@@ -319,6 +373,9 @@ def _sim_create_boot(device, runtime, name):
319
373
  if r.returncode != 0:
320
374
  raise RuntimeError(f"simctl create failed ({device} / {runtime}): {r.stderr.strip()}")
321
375
  udid = r.stdout.strip()
376
+ # Recorded before the boot: a crash mid-boot must still leave the device
377
+ # attributable, or nothing may ever delete it.
378
+ _authored_sims_write(_authored_sims() | {udid})
322
379
  # boot is idempotent-ish (errors if already booted); bootstatus waits until ready.
323
380
  subprocess.run(["xcrun", "simctl", "boot", udid], capture_output=True, text=True, timeout=120)
324
381
  subprocess.run(["xcrun", "simctl", "bootstatus", udid, "-b"], capture_output=True, text=True, timeout=300)
@@ -351,7 +408,10 @@ def _sim_teardown(udid):
351
408
  except (subprocess.SubprocessError, OSError):
352
409
  pass
353
410
  udids = _sim_udids()
354
- return udids is None or udid not in udids
411
+ gone = udids is None or udid not in udids
412
+ if gone and udid in _authored_sims():
413
+ _authored_sims_write(_authored_sims() - {udid})
414
+ return gone
355
415
 
356
416
 
357
417
  def _sim_inventory():
@@ -471,7 +531,31 @@ class Broker:
471
531
  if reason:
472
532
  print(f"reaping {lid}: {reason}", flush=True)
473
533
  self._cleanup(self.leases.pop(lid))
534
+ self._record_trees()
474
535
  self._save()
536
+
537
+ def _record_trees(self):
538
+ """Every tick, note which processes each live lease's root has fathered.
539
+ This is the authorship ledger the teardown and sweep kill from: a
540
+ detached child keeps its ppid while the parent lives, so polling while
541
+ the suite is up sees the workers that killpg will later miss. Entries
542
+ accumulate across polls (a worker seen once is remembered after it
543
+ detaches); a recycled pid — same number, different start — is dropped,
544
+ never inherited."""
545
+ table = _ps_table()
546
+ if not table:
547
+ return
548
+ for rec in self.leases.values():
549
+ root = rec.get("pid")
550
+ if not root or root not in table:
551
+ continue
552
+ tree = rec.setdefault("tree", {})
553
+ for pid, start in _descendants(root, table).items():
554
+ tree[str(pid)] = start
555
+ for pid in list(tree):
556
+ live = table.get(int(pid))
557
+ if live and live[1] != tree[pid]:
558
+ del tree[pid] # recycled pid — a stranger now
475
559
  # Ledger-vs-world is only half the job: anything that escaped the
476
560
  # ledger (daemon crash mid-boot, corrupt ledger, detached children of
477
561
  # past suites) is invisible to the loop above and immortal without this.
@@ -559,6 +643,18 @@ class Broker:
559
643
  run_ports = {s: all_ports[s] for s in (only or declared) if s in all_ports}
560
644
  check = {s: p for s, p in run_ports.items() if s not in ("ui", "hub", "logging")}
561
645
  log = STATE_DIR / f"{label}.log"
646
+ # Each suite gets its OWN temp dir. firebase-tools keeps the Storage
647
+ # emulator's blobs at `${os.tmpdir()}/firebase/storage/blobs` — one
648
+ # fixed path for every instance on the box — and `stop()` rm -rf's it.
649
+ # So any suite shutting down wiped the blobs of every other suite still
650
+ # running, and their next upload died with ENOENT inside firebase-tools
651
+ # ("An unexpected error has occurred.", exit 2, all emulators gone).
652
+ # On a box that runs several suites at once that is not a corner case:
653
+ # 2026-08-28, three walk2gether e2e runs in a row on the studio, each
654
+ # killed by a daemon verify drive finishing. os.tmpdir() honours
655
+ # TMPDIR, so a per-suite one makes the path private.
656
+ suite_tmp = _suite_tmpdir(label)
657
+ suite_tmp.mkdir(parents=True, exist_ok=True)
562
658
  cmd = ["firebase", "emulators:start", "--config", tmp]
563
659
  if only:
564
660
  cmd += ["--only", ",".join(only)]
@@ -568,9 +664,9 @@ class Broker:
568
664
  # daemon's env — the functions emulator runtime inherits it, which is
569
665
  # the only way a CALLER can set per-suite env (e.g. LLM_CACHE_DIR)
570
666
  # when the daemon, not the caller, spawns the emulator.
571
- spawn_env = None
667
+ spawn_env = {**os.environ, "TMPDIR": str(suite_tmp)}
572
668
  if extra_env:
573
- spawn_env = {**os.environ, **{str(k): str(v) for k, v in dict(extra_env).items()}}
669
+ spawn_env.update({str(k): str(v) for k, v in dict(extra_env).items()})
574
670
  with open(log, "w") as lf:
575
671
  proc = subprocess.Popen(cmd, cwd=cwd, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True, env=spawn_env)
576
672
  if on_spawn:
@@ -655,6 +751,23 @@ class Broker:
655
751
  ("SURVIVED kill" if _group_alive(pgid) else "killed"))
656
752
  else:
657
753
  reap_zombie()
754
+ # The recorded tree: children observed under the root while it lived,
755
+ # including the setsid'd workers the group kill above can never reach
756
+ # and non-listening processes the port sweep below can never see —
757
+ # functionsEmulatorRuntime leaked two per run through exactly that gap.
758
+ # Start-time is re-checked at kill time: a recycled pid is a stranger.
759
+ tree = rec.get("tree") or {}
760
+ strays = set()
761
+ for pid, start in tree.items():
762
+ pid = int(pid)
763
+ observed = _proc_start(pid)
764
+ if observed and observed == start:
765
+ strays.add(pid)
766
+ strays.discard(rec.get("pid"))
767
+ if strays:
768
+ killed = _kill_wave(strays)
769
+ if killed:
770
+ actions.append(f"tree sweep killed {sorted(killed)}")
658
771
  # Port sweep: every port the lease covers. rec["ports"] catches legacy
659
772
  # records; the block range catches everything else (metro, websocket).
660
773
  ports = {v for v in (rec.get("ports") or {}).values() if isinstance(v, int)}
@@ -673,6 +786,11 @@ class Broker:
673
786
  tmp = rec.get("tmp_config")
674
787
  if tmp and rec.get("cwd") and (Path(rec["cwd"]) / tmp).exists():
675
788
  (Path(rec["cwd"]) / tmp).unlink()
789
+ # The suite's private TMPDIR (see _boot). Derived from the label the
790
+ # same way _boot derived it, so old ledger records need no new field.
791
+ label = rec.get("label") or (Path(rec["log"]).stem if rec.get("log") else None)
792
+ if label:
793
+ shutil.rmtree(_suite_tmpdir(label), ignore_errors=True)
676
794
  if rec.get("sim_udid"):
677
795
  gone = _sim_teardown(rec["sim_udid"])
678
796
  actions.append(f"sim {rec['sim_udid'][:8]} " +
@@ -856,6 +974,10 @@ class Broker:
856
974
  # JVMs left zero trace. Every reap states its reason.
857
975
  print(f"reaping {lid}: {reason}", flush=True)
858
976
  self._cleanup(self.leases.pop(lid))
977
+ # Runs on the tick, not just at startup: the tree must be observed
978
+ # WHILE the parents live — after a root dies its detached children
979
+ # read ppid 1 and can never be attributed again.
980
+ self._record_trees()
859
981
  self._reap_deleted_sims(now)
860
982
  self._save()
861
983
  if now - getattr(self, "_swept", 0) >= SWEEP_INTERVAL:
@@ -920,11 +1042,34 @@ class Broker:
920
1042
  functions workers whose suite died — killpg can never reach these);
921
1043
  - harbormaster-named simulators (run-*/alloc-*) with no lease.
922
1044
 
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`."""
1045
+ Finding and killing are separate verdicts. Everything above is worth
1046
+ REPORTING (a squatted block can't be leased; an orphaned JVM is a leak
1047
+ whoever fathered it), but eviction requires positive attribution: the
1048
+ pid appears in a live lease's recorded tree (start-time re-checked),
1049
+ the cmdline names the .firebase.brokered-* config only harbormaster
1050
+ writes, or the sim's UDID is in this daemon's authored-sims registry.
1051
+ "Looks like it might have come from us" is not attribution — the
1052
+ lease-shape rule killed ollama, and name-matching run-* sims had two
1053
+ daemons on one box deleting each other's devices.
1054
+ Returns findings for `doctor`."""
926
1055
  findings = []
927
1056
  lease_pids = {int(l["pid"]) for l in self.leases.values() if l.get("pid")}
1057
+ # The authorship ledger: every (pid, start) any live lease's tree has
1058
+ # recorded. Eviction requires membership here — start re-checked so a
1059
+ # recycled pid never matches — or a cmdline that names the injected
1060
+ # config file only harbormaster writes. Anything else is somebody
1061
+ # else's process wearing a familiar name: reported, never signalled.
1062
+ authored = {}
1063
+ for l in self.leases.values():
1064
+ for pid, start in (l.get("tree") or {}).items():
1065
+ authored[int(pid)] = start
1066
+
1067
+ def _attributed(pid, cmdline=""):
1068
+ if ".firebase.brokered-" in cmdline:
1069
+ return True
1070
+ start = authored.get(pid)
1071
+ return bool(start) and _proc_start(pid) == start
1072
+
928
1073
  leased_ports = set()
929
1074
  for l in self.leases.values():
930
1075
  if l.get("block"):
@@ -936,7 +1081,7 @@ class Broker:
936
1081
  if pid not in lease_pids:
937
1082
  cmd = _proc_field(pid, "command=")[:200]
938
1083
  findings.append({"kind": "listener", "pid": pid, "port": port,
939
- "cmd": cmd, "evict": _lease_shaped(cmd)})
1084
+ "cmd": cmd, "evict": _attributed(pid, cmd)})
940
1085
  try:
941
1086
  r = subprocess.run(["ps", "-xo", "pid=,ppid=,command="],
942
1087
  capture_output=True, text=True, timeout=10)
@@ -950,36 +1095,48 @@ class Broker:
950
1095
  if (any(sig in cmdline for sig in SWEEP_SIGNATURES)
951
1096
  or ".firebase.brokered-" in cmdline):
952
1097
  findings.append({"kind": "orphan", "pid": pid,
953
- "cmd": cmdline[:200]})
1098
+ "cmd": cmdline[:200],
1099
+ "evict": _attributed(pid, cmdline)})
954
1100
  except (subprocess.SubprocessError, OSError, ValueError):
955
1101
  pass
956
1102
  inv = _sim_inventory()
957
1103
  if inv is not None:
958
1104
  leased_udids = {l.get("sim_udid") for l in self.leases.values()}
1105
+ mine = _authored_sims()
959
1106
  for udid, name in inv.items():
960
1107
  if ((name.startswith("run-") or name.startswith("alloc-"))
961
1108
  and udid not in leased_udids):
962
- findings.append({"kind": "sim", "udid": udid, "name": name})
1109
+ findings.append({"kind": "sim", "udid": udid, "name": name,
1110
+ "evict": udid in mine})
963
1111
  if apply and findings:
964
1112
  pids = {f["pid"] for f in findings
965
- if f["kind"] == "orphan"
966
- or (f["kind"] == "listener" and f.get("evict"))}
1113
+ if f["kind"] in ("listener", "orphan") and f.get("evict")}
967
1114
  if pids:
968
1115
  killed = _kill_wave(pids)
969
1116
  print(f"world sweep killed {killed}", flush=True)
970
1117
  for f in findings:
971
- if f["kind"] == "sim":
1118
+ if f["kind"] == "sim" and f.get("evict"):
972
1119
  gone = _sim_teardown(f["udid"])
973
1120
  print(f"world sweep sim {f['name']} ({f['udid'][:8]}): "
974
1121
  + ("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.
1122
+ elif f["kind"] == "sim":
1123
+ seen = self.__dict__.setdefault("_bystanders_logged", set())
1124
+ if f["udid"] not in seen:
1125
+ seen.add(f["udid"])
1126
+ print(f"world sweep sim {f['name']} ({f['udid'][:8]}) left "
1127
+ f"alone (not provably ours — another daemon's?)",
1128
+ flush=True)
1129
+ elif not f.get("evict"):
1130
+ # Logged once per pid — an unattributed finding survives
1131
+ # every sweep by design, and 288 identical lines a day is
1132
+ # how logs go unread.
978
1133
  seen = self.__dict__.setdefault("_bystanders_logged", set())
979
1134
  if f["pid"] not in seen:
980
1135
  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)
1136
+ where = f" on port {f['port']}" if f.get("port") else ""
1137
+ print(f"world sweep {f['kind']}{where} left alone "
1138
+ f"(not provably ours): pid {f['pid']} — {f['cmd']}",
1139
+ flush=True)
983
1140
  else:
984
1141
  print(f"world sweep {f['kind']}: pid {f['pid']}"
985
1142
  + (f" port {f['port']}" if f.get("port") else "")
@@ -1366,23 +1523,24 @@ def main():
1366
1523
  if not findings:
1367
1524
  print(f"clean — {out.get('leases', 0)} lease(s), no strays.")
1368
1525
  return
1369
- spared = [f for f in findings if f["kind"] == "listener" and not f.get("evict", True)]
1526
+ spared = [f for f in findings if not f.get("evict", True)]
1370
1527
  for f in findings:
1528
+ tag = " (not provably ours — never evicted)" if f in spared else ""
1371
1529
  if f["kind"] == "sim":
1372
- print(f" stray sim {f['name']} {f['udid']}")
1530
+ print(f" stray sim {f['name']} {f['udid']}{tag}")
1373
1531
  else:
1374
1532
  where = f" port {f['port']}" if f.get("port") else ""
1375
- tag = " (bystander — never evicted)" if f in spared else ""
1376
1533
  print(f" {f['kind']:<12} pid {f['pid']}{where} {f['cmd']}{tag}")
1377
1534
  evictable = len(findings) - len(spared)
1378
1535
  if out.get("fixed"):
1379
1536
  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 ""))
1537
+ + (f" {len(spared)} left alone — harbormaster only kills what "
1538
+ f"it can prove it authored; evict those by hand if they're "
1539
+ f"yours." if spared else ""))
1383
1540
  else:
1384
1541
  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 "."))
1542
+ + (f" ({len(spared)} not provably ours, will be left alone)."
1543
+ if spared else "."))
1386
1544
  if not out.get("fixed"):
1387
1545
  sys.exit(1)
1388
1546
  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.1",
3
+ "version": "0.9.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",