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