@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.
- package/DESIGN.md +26 -10
- package/harbormaster +199 -41
- 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. **
|
|
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
|
-
|
|
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
|
|
121
|
-
covers, firebase-tools runtimes re-parented to pid 1 (the
|
|
122
|
-
signature: `functionsEmulatorRuntime`,
|
|
123
|
-
`run-*`/`alloc-*` simulators with no
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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.
|
|
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
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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 =
|
|
667
|
+
spawn_env = {**os.environ, "TMPDIR": str(suite_tmp)}
|
|
572
668
|
if extra_env:
|
|
573
|
-
spawn_env
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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":
|
|
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"]
|
|
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"] == "
|
|
976
|
-
|
|
977
|
-
|
|
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
|
-
|
|
982
|
-
|
|
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
|
|
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)}
|
|
1381
|
-
f"
|
|
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)}
|
|
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.
|
|
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",
|