@simon-says-build/harbormaster 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +146 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/harbormaster +1024 -0
- package/harbormaster.example.json +27 -0
- package/package.json +40 -0
package/harbormaster
ADDED
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""harbormaster — caller-owned port leases: Firebase emulator sandboxes, Metro
|
|
3
|
+
ports, private simulators. (Formerly firebase-ports.)
|
|
4
|
+
|
|
5
|
+
A localhost daemon hands each CALLER its own emulator sandbox (a pool port block +
|
|
6
|
+
fresh data) and reclaims it when the caller's process exits. No registry, no shared
|
|
7
|
+
per-project instances — two callers that both want "an impulse emulator" get two
|
|
8
|
+
independent suites, so they never collide on ports or data.
|
|
9
|
+
|
|
10
|
+
acquire [--cwd DIR] [--owner PID] [--only a,b] [--ttl N] [--metro] [--sim] [--json|--env]
|
|
11
|
+
Boot a private sandbox for the project in DIR (default: nearest firebase.json
|
|
12
|
+
above $PWD). Owned by --owner (default: the calling process's parent), reaped
|
|
13
|
+
when that process exits. Prints the ports (use --env to eval into your shell).
|
|
14
|
+
--metro also reserves a Metro/Expo port (METRO_PORT). --sim also creates+boots
|
|
15
|
+
a private iOS simulator (DETOX_UDID/DETOX_SIM_NAME) and implies --metro; the
|
|
16
|
+
sim is deleted when the lease is reaped. Tune with --sim-device / --sim-runtime
|
|
17
|
+
(or FBPORTS_SIM_DEVICE / FBPORTS_SIM_RUNTIME). This is the e2e dev-env: one
|
|
18
|
+
lease = emulators + metro port + simulator, all torn down when the owner exits.
|
|
19
|
+
up ... Alias for acquire (interactive dev; owner = your shell).
|
|
20
|
+
down [--cwd DIR] Release this shell's sandbox for DIR (else it's reaped on exit).
|
|
21
|
+
release <leaseId> Release a specific lease.
|
|
22
|
+
status Everything the daemon has out.
|
|
23
|
+
allocate [--owner PID|auto] [--ttl N] [--metro] [--sim] [--spawn CMD] [--cwd DIR]
|
|
24
|
+
[--detach] [--json|--env]
|
|
25
|
+
Reserve a block WITHOUT booting — for a caller that boots its own suite (the
|
|
26
|
+
agent-qa run driver). Also owner-reaped. --metro/--sim work as on acquire
|
|
27
|
+
(metro port in the block; private simulator, deleted on reap) for e2e Metro
|
|
28
|
+
sessions whose emulators are per-RUN acquires. NOTE: reserved leases also
|
|
29
|
+
reap on TTL even with a live owner — pass a long --ttl for long-lived
|
|
30
|
+
Metro sessions (owner death still reaps promptly).
|
|
31
|
+
--spawn CMD makes the DAEMON run CMD (via sh -c, in --cwd if given) as the
|
|
32
|
+
lease's process: daemon-owned, so it survives the calling shell/agent —
|
|
33
|
+
liveness is the spawned pid (+ TTL backstop), teardown kills its process
|
|
34
|
+
group, stdout goes to the lease log. CMD sees FBPORTS_BLOCK_BASE,
|
|
35
|
+
FBPORTS_LEASE_ID, and METRO_PORT / DETOX_UDID / DETOX_SIM_NAME when leased.
|
|
36
|
+
With --spawn (or --detach) the lease defaults to NO owner pid, since the
|
|
37
|
+
calling shell exits immediately and would reap it. Pass --owner auto to
|
|
38
|
+
record the nearest ancestor that DOES outlive the call (under an agent,
|
|
39
|
+
the agent process) so the lease dies with that session; or --owner PID
|
|
40
|
+
to name one yourself. Otherwise release it with
|
|
41
|
+
`harbormaster release <leaseId>` or let the TTL reap it.
|
|
42
|
+
Any lease is also reaped once its --cwd stops existing — a deleted
|
|
43
|
+
worktree can never need its Metro again.
|
|
44
|
+
exec [--cwd DIR] [--only a,b] '<script>'
|
|
45
|
+
Run `firebase emulators:exec` on a daemon-allocated block: boots the suite on
|
|
46
|
+
free ports, runs the script (which gets FIRESTORE_EMULATOR_HOST etc.), tears
|
|
47
|
+
down. Use this instead of a bare `firebase emulators:exec` so concurrent runs
|
|
48
|
+
don't collide.
|
|
49
|
+
verify <leaseId> Exit 0 iff the lease is FULLY alive: known to the daemon,
|
|
50
|
+
its process running, its simulator still present, and (for booted suites)
|
|
51
|
+
its ports open. The reuse gate for long-lived sessions:
|
|
52
|
+
harbormaster verify "$FBPORTS_LEASE_ID" || <re-lease>
|
|
53
|
+
serve Run the daemon (auto-starts on first command).
|
|
54
|
+
|
|
55
|
+
NOTE: firebase.json ports are optional — the daemon injects (and overrides) a
|
|
56
|
+
per-lease pool block, so concurrent suites never collide. Ledger:
|
|
57
|
+
~/.local/state/firebase-ports/ledger.json. See DESIGN.md.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
import http.client
|
|
61
|
+
import http.server
|
|
62
|
+
import json
|
|
63
|
+
import os
|
|
64
|
+
import signal
|
|
65
|
+
import socket
|
|
66
|
+
import subprocess
|
|
67
|
+
import sys
|
|
68
|
+
import threading
|
|
69
|
+
import time
|
|
70
|
+
import uuid
|
|
71
|
+
from datetime import datetime, timezone
|
|
72
|
+
from pathlib import Path
|
|
73
|
+
|
|
74
|
+
VERSION = "0.6.0"
|
|
75
|
+
|
|
76
|
+
# Overridable for tests / parallel installs; default is the shared user daemon.
|
|
77
|
+
# The dir keeps the old "firebase-ports" name: pre-rename binaries still installed
|
|
78
|
+
# in other projects must find the same ledger and daemon.
|
|
79
|
+
STATE_DIR = Path(os.environ.get("FBPORTS_STATE_DIR",
|
|
80
|
+
str(Path.home() / ".local" / "state" / "firebase-ports")))
|
|
81
|
+
LEDGER = STATE_DIR / "ledger.json"
|
|
82
|
+
BROKER_HOST, BROKER_PORT = "127.0.0.1", int(os.environ.get("FBPORTS_PORT", "4999"))
|
|
83
|
+
POOL_BASE, BLOCK, POOL_BLOCKS = 11000, 20, 200 # blocks of 20 ports, 11000-14999
|
|
84
|
+
BLOCK_OFFSETS = {"auth": 0, "firestore": 1, "functions": 2, "storage": 3, "ui": 4,
|
|
85
|
+
"hub": 5, "logging": 6, "pubsub": 7, "eventarc": 8, "database": 9,
|
|
86
|
+
# Firestore's UI websocket is a SECOND port it needs (CLI default 9150).
|
|
87
|
+
# It must be pool-allocated too — otherwise every concurrent firestore
|
|
88
|
+
# emulator defaults to 9150 and they fight over it, even though their
|
|
89
|
+
# data ports are isolated. (Block is 20 wide, so +10 is in range.)
|
|
90
|
+
"firestore_websocket": 10}
|
|
91
|
+
METRO_OFFSET = 11 # Metro/Expo dev-server port in the block (offsets 0-10 are emulators)
|
|
92
|
+
DEFAULT_SIM_DEVICE = os.environ.get("FBPORTS_SIM_DEVICE", "iPhone 16")
|
|
93
|
+
DEFAULT_SIM_RUNTIME = os.environ.get("FBPORTS_SIM_RUNTIME",
|
|
94
|
+
"com.apple.CoreSimulator.SimRuntime.iOS-18-2")
|
|
95
|
+
DEFAULT_TTL = 3600
|
|
96
|
+
RECLAIM_GRACE = int(os.environ.get("FBPORTS_RECLAIM_GRACE", "45"))
|
|
97
|
+
REAP_INTERVAL = int(os.environ.get("FBPORTS_REAP_INTERVAL", "10"))
|
|
98
|
+
# How often the reaper cross-checks leased simulators against simctl. A lease
|
|
99
|
+
# whose sim was deleted externally is broken for its caller even if its process
|
|
100
|
+
# is healthy — sweep it whole rather than leave a half-alive session.
|
|
101
|
+
SIM_CHECK_INTERVAL = int(os.environ.get("FBPORTS_SIM_CHECK_INTERVAL", "60"))
|
|
102
|
+
BOOT_BACKSTOP = 600 # only for a genuinely wedged boot; readiness is liveness-driven
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _now():
|
|
106
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _port_open(port, host="127.0.0.1"):
|
|
110
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
111
|
+
s.settimeout(0.5)
|
|
112
|
+
return s.connect_ex((host, int(port))) == 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _all_open(ports):
|
|
116
|
+
return bool(ports) and all(_port_open(p) for p in ports.values())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _pid_alive(pid):
|
|
120
|
+
try:
|
|
121
|
+
os.kill(int(pid), 0)
|
|
122
|
+
return True
|
|
123
|
+
except PermissionError:
|
|
124
|
+
return True # EPERM: exists, just not ours to signal
|
|
125
|
+
except (OSError, ProcessLookupError, ValueError, TypeError):
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _child_alive(pid):
|
|
130
|
+
"""Liveness for a process the DAEMON spawned. A zombie child passes kill-0
|
|
131
|
+
until someone waits on it, so reap it first (WNOHANG); if it exited, it's
|
|
132
|
+
dead. After a daemon restart the process is no longer our child
|
|
133
|
+
(ECHILD) — launchd reaps it instead, so plain kill-0 is then correct."""
|
|
134
|
+
try:
|
|
135
|
+
done, _ = os.waitpid(int(pid), os.WNOHANG)
|
|
136
|
+
if done == int(pid):
|
|
137
|
+
return False
|
|
138
|
+
except (ChildProcessError, OSError, ValueError, TypeError):
|
|
139
|
+
pass
|
|
140
|
+
return _pid_alive(pid)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _proc_start(pid):
|
|
144
|
+
"""A process's start time — pairs with the PID to survive PID reuse. '' if gone."""
|
|
145
|
+
try:
|
|
146
|
+
r = subprocess.run(["ps", "-o", "lstart=", "-p", str(int(pid))],
|
|
147
|
+
capture_output=True, text=True, timeout=2)
|
|
148
|
+
return r.stdout.strip()
|
|
149
|
+
except (subprocess.SubprocessError, ValueError, TypeError):
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Ancestors that exit with (or just before) the script asking for a lease, so
|
|
154
|
+
# owning a lease by one of them would reap it seconds later. `--owner auto`
|
|
155
|
+
# walks past these looking for something that outlives the call.
|
|
156
|
+
_TRANSIENT_COMMS = {"sh", "bash", "zsh", "dash", "fish", "ksh", "login", "env",
|
|
157
|
+
"node", "npm", "npx", "yarn", "pnpm", "bun", "make", "xargs"}
|
|
158
|
+
# Walking past *these* would hand ownership to something that outlives the work
|
|
159
|
+
# by days (your editor, your login session), which reaps nothing in practice.
|
|
160
|
+
# Better to record no owner and fall back to TTL + working-dir checks.
|
|
161
|
+
_BOUNDARY_COMMS = {"launchd", "Code", "Code Helper", "Code Helper (Plugin)",
|
|
162
|
+
"Code Helper (Renderer)", "Electron", "Terminal", "iTerm2",
|
|
163
|
+
"WindowServer", "sshd", "init", "systemd"}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _proc_field(pid, fmt):
|
|
167
|
+
try:
|
|
168
|
+
r = subprocess.run(["ps", "-o", fmt, "-p", str(int(pid))],
|
|
169
|
+
capture_output=True, text=True, timeout=2)
|
|
170
|
+
return r.stdout.strip()
|
|
171
|
+
except (subprocess.SubprocessError, ValueError, TypeError):
|
|
172
|
+
return ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _durable_ancestor(pid, _max_hops=16):
|
|
176
|
+
"""The nearest ancestor of `pid` that will outlive the calling script.
|
|
177
|
+
|
|
178
|
+
A daemon-spawned lease (`--spawn`) deliberately survives the shell that
|
|
179
|
+
asked for it, so that shell's pid is useless as an owner — it exits within
|
|
180
|
+
seconds and would take the lease with it. But "daemonised" only decides who
|
|
181
|
+
*reaps the child*; we can still record who is *interested*. Walking up past
|
|
182
|
+
the transient shell/node wrappers finds that process: under an agent it is
|
|
183
|
+
the agent itself, so its leases die with the session.
|
|
184
|
+
|
|
185
|
+
Returns None when the walk reaches a boundary (an editor, a login session)
|
|
186
|
+
rather than pretending a days-long process is a meaningful owner.
|
|
187
|
+
"""
|
|
188
|
+
seen = pid
|
|
189
|
+
for _ in range(_max_hops):
|
|
190
|
+
ppid = _proc_field(seen, "ppid=")
|
|
191
|
+
if not ppid or not ppid.isdigit() or int(ppid) <= 1:
|
|
192
|
+
return None
|
|
193
|
+
seen = int(ppid)
|
|
194
|
+
comm = os.path.basename(_proc_field(seen, "comm="))
|
|
195
|
+
if not comm or comm in _BOUNDARY_COMMS:
|
|
196
|
+
return None
|
|
197
|
+
if comm not in _TRANSIENT_COMMS:
|
|
198
|
+
return seen
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _owner_alive(pid, start):
|
|
203
|
+
"""A lease's owner is alive iff the same PID exists AND was started at the same time."""
|
|
204
|
+
if pid is None:
|
|
205
|
+
return True # no owner tracking → rely on TTL / liveness
|
|
206
|
+
if not _pid_alive(pid):
|
|
207
|
+
return False
|
|
208
|
+
if not start:
|
|
209
|
+
return True
|
|
210
|
+
observed = _proc_start(pid)
|
|
211
|
+
# `ps` can time out on a loaded machine (exactly when a lease is booting).
|
|
212
|
+
# An unreadable start time is NOT evidence of PID reuse — the pid exists
|
|
213
|
+
# (os.kill(0) just succeeded), so treat indeterminate as alive rather than
|
|
214
|
+
# reaping a healthy lease.
|
|
215
|
+
return not observed or observed == start
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _tail(path, n=25):
|
|
219
|
+
try:
|
|
220
|
+
return "\n".join(Path(path).read_text().splitlines()[-n:])
|
|
221
|
+
except OSError:
|
|
222
|
+
return ""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _firebaserc_project(cwd):
|
|
226
|
+
try:
|
|
227
|
+
return json.loads((Path(cwd) / ".firebaserc").read_text()).get("projects", {}).get("default")
|
|
228
|
+
except (OSError, json.JSONDecodeError, AttributeError):
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _find_firebase_dir(start):
|
|
233
|
+
p = Path(start).resolve()
|
|
234
|
+
for d in [p, *p.parents]:
|
|
235
|
+
if (d / "firebase.json").exists():
|
|
236
|
+
return str(d)
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _block_ports(base):
|
|
241
|
+
return {svc: base + off for svc, off in BLOCK_OFFSETS.items()}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _service_ports(ports):
|
|
245
|
+
return {s: p for s, p in ports.items() if s not in ("ui", "hub", "logging")}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _write_injected_config(cwd, ports):
|
|
249
|
+
"""Set ports on the emulators the portless firebase.json DECLARES (+ hub/logging).
|
|
250
|
+
Never adds emulators the project doesn't use. Returns the temp config basename."""
|
|
251
|
+
cfg = json.loads((Path(cwd) / "firebase.json").read_text())
|
|
252
|
+
emu = cfg.setdefault("emulators", {})
|
|
253
|
+
for svc in [k for k in emu if k != "singleProjectMode"]:
|
|
254
|
+
if svc in ports and isinstance(emu[svc], dict):
|
|
255
|
+
emu[svc]["port"] = ports[svc]
|
|
256
|
+
if svc == "ui":
|
|
257
|
+
emu[svc]["enabled"] = True
|
|
258
|
+
if svc == "firestore":
|
|
259
|
+
# Give firestore's UI websocket a private port too, else concurrent
|
|
260
|
+
# suites all default to 9150 and collide (isolated data port isn't enough).
|
|
261
|
+
emu[svc]["websocketPort"] = ports["firestore_websocket"]
|
|
262
|
+
for svc in ("hub", "logging"):
|
|
263
|
+
emu.setdefault(svc, {})["port"] = ports[svc]
|
|
264
|
+
emu["singleProjectMode"] = True
|
|
265
|
+
tmp = f".firebase.brokered-{ports['firestore']}.json"
|
|
266
|
+
(Path(cwd) / tmp).write_text(json.dumps(cfg, indent=2))
|
|
267
|
+
return tmp
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _sim_create_boot(device, runtime, name):
|
|
271
|
+
"""Create a fresh iOS simulator, boot it, and return its UDID. The device is
|
|
272
|
+
per-lease (unique name) so concurrent e2e runs never share one; reaped on
|
|
273
|
+
lease teardown. Boots on a PINNED runtime so we don't pay a second OS image."""
|
|
274
|
+
r = subprocess.run(["xcrun", "simctl", "create", name, device, runtime],
|
|
275
|
+
capture_output=True, text=True, timeout=120)
|
|
276
|
+
if r.returncode != 0:
|
|
277
|
+
raise RuntimeError(f"simctl create failed ({device} / {runtime}): {r.stderr.strip()}")
|
|
278
|
+
udid = r.stdout.strip()
|
|
279
|
+
# boot is idempotent-ish (errors if already booted); bootstatus waits until ready.
|
|
280
|
+
subprocess.run(["xcrun", "simctl", "boot", udid], capture_output=True, text=True, timeout=120)
|
|
281
|
+
subprocess.run(["xcrun", "simctl", "bootstatus", udid, "-b"], capture_output=True, text=True, timeout=300)
|
|
282
|
+
return udid
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _sim_udids():
|
|
286
|
+
"""The set of simulator UDIDs simctl knows, or None when indeterminate.
|
|
287
|
+
simctl erroring/missing must never read as 'every sim is gone' — callers
|
|
288
|
+
treat None as 'no evidence', mirroring _owner_alive's philosophy."""
|
|
289
|
+
try:
|
|
290
|
+
r = subprocess.run(["xcrun", "simctl", "list", "devices", "-j"],
|
|
291
|
+
capture_output=True, text=True, timeout=15)
|
|
292
|
+
if r.returncode != 0:
|
|
293
|
+
return None
|
|
294
|
+
devices = json.loads(r.stdout).get("devices", {})
|
|
295
|
+
return {d["udid"] for lst in devices.values() for d in lst if d.get("udid")}
|
|
296
|
+
except (subprocess.SubprocessError, OSError, json.JSONDecodeError,
|
|
297
|
+
AttributeError, TypeError):
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _sim_teardown(udid):
|
|
302
|
+
"""Shut down + delete a leased simulator (best effort) so devices never leak."""
|
|
303
|
+
for args in (["shutdown", udid], ["delete", udid]):
|
|
304
|
+
try:
|
|
305
|
+
subprocess.run(["xcrun", "simctl", *args], capture_output=True, text=True, timeout=60)
|
|
306
|
+
except (subprocess.SubprocessError, OSError):
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---- the daemon ----------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
class Broker:
|
|
313
|
+
def __init__(self):
|
|
314
|
+
self.lock = threading.RLock()
|
|
315
|
+
self.leases = {} # leaseId -> caller-owned lease
|
|
316
|
+
self._load()
|
|
317
|
+
self._reconcile()
|
|
318
|
+
|
|
319
|
+
def _load(self):
|
|
320
|
+
if LEDGER.exists():
|
|
321
|
+
try:
|
|
322
|
+
self.leases = json.loads(LEDGER.read_text()).get("leases", {})
|
|
323
|
+
except (json.JSONDecodeError, OSError):
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
def _save(self):
|
|
327
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
LEDGER.write_text(json.dumps({"leases": self.leases}, indent=2))
|
|
329
|
+
|
|
330
|
+
def _reconcile(self):
|
|
331
|
+
for lid in list(self.leases):
|
|
332
|
+
reason = self._dead_reason(self.leases[lid], time.time())
|
|
333
|
+
if reason:
|
|
334
|
+
print(f"reaping {lid}: {reason}", flush=True)
|
|
335
|
+
self._cleanup(self.leases.pop(lid))
|
|
336
|
+
self._save()
|
|
337
|
+
|
|
338
|
+
def _dead(self, l, now):
|
|
339
|
+
return bool(self._dead_reason(l, now))
|
|
340
|
+
|
|
341
|
+
def _dead_reason(self, l, now):
|
|
342
|
+
if l.get("owner_pid") is not None and not _owner_alive(l["owner_pid"], l.get("owner_start")):
|
|
343
|
+
return f"owner pid {l['owner_pid']} gone"
|
|
344
|
+
# A lease for a directory that no longer exists can never be useful
|
|
345
|
+
# again: deleted worktrees were the main source of immortal Metro
|
|
346
|
+
# leases, since a daemon-spawned Metro has no owner to outlive and the
|
|
347
|
+
# TTL is measured in days. Only a *confirmed* absence counts — a parent
|
|
348
|
+
# that is itself missing means an unmounted volume or similar, which is
|
|
349
|
+
# not evidence the project is gone.
|
|
350
|
+
cwd = l.get("cwd")
|
|
351
|
+
if cwd and not os.path.isdir(cwd) and os.path.isdir(os.path.dirname(cwd)):
|
|
352
|
+
return f"working dir {cwd} no longer exists"
|
|
353
|
+
exp = l.get("expires")
|
|
354
|
+
if exp is not None and exp < now:
|
|
355
|
+
# TTL is a backstop for callers the daemon can't track (no owner)
|
|
356
|
+
# and for reserved blocks (documented: reserved leases reap on TTL
|
|
357
|
+
# even with a live owner). A BOOTED lease whose tracked owner is
|
|
358
|
+
# verifiably alive is not orphaned — let owner-death/liveness
|
|
359
|
+
# govern it instead of killing a healthy hour-old dev-env.
|
|
360
|
+
if l["kind"] == "reserved" or l.get("owner_pid") is None:
|
|
361
|
+
return "ttl expired"
|
|
362
|
+
if l["kind"] == "reserved":
|
|
363
|
+
# Daemon-spawned process (--spawn): its pid IS the lease's liveness —
|
|
364
|
+
# ports are irrelevant (Metro restarting/compiling must not reap it).
|
|
365
|
+
if l.get("pid"):
|
|
366
|
+
if not _child_alive(l["pid"]):
|
|
367
|
+
return f"spawned pid {l['pid']} died"
|
|
368
|
+
return None
|
|
369
|
+
# Caller boots its own — liveness on the block.
|
|
370
|
+
base = l["block"]
|
|
371
|
+
if _port_open(base) or _port_open(base + 1):
|
|
372
|
+
l["seen_active"], l["down_since"] = True, None
|
|
373
|
+
elif l.get("seen_active"):
|
|
374
|
+
l["down_since"] = l.get("down_since") or now
|
|
375
|
+
if l.get("down_since") and now - l["down_since"] >= RECLAIM_GRACE:
|
|
376
|
+
return "reserved block down past grace"
|
|
377
|
+
return None
|
|
378
|
+
# Booted suite: the process is the strong liveness signal — reap a dead
|
|
379
|
+
# process immediately. Ports are a weak signal (a single 0.5s connect can
|
|
380
|
+
# fail on a loaded machine while the suite is fine), so closed ports only
|
|
381
|
+
# reap after RECLAIM_GRACE of CONTINUOUS downtime, mirroring the
|
|
382
|
+
# reserved-lease path above.
|
|
383
|
+
if not _child_alive(l.get("pid")):
|
|
384
|
+
return f"suite pid {l.get('pid')} died"
|
|
385
|
+
if _all_open(l.get("check_ports") or _service_ports(l["ports"])):
|
|
386
|
+
l["down_since"] = None
|
|
387
|
+
return None
|
|
388
|
+
l["down_since"] = l.get("down_since") or now
|
|
389
|
+
if now - l["down_since"] >= RECLAIM_GRACE:
|
|
390
|
+
return "ports down past grace"
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
def _free_block(self):
|
|
394
|
+
used = {l["block"] for l in self.leases.values() if l.get("block")}
|
|
395
|
+
for i in range(POOL_BLOCKS):
|
|
396
|
+
base = POOL_BASE + i * BLOCK
|
|
397
|
+
if base not in used and all(not _port_open(base + off) for off in range(BLOCK)):
|
|
398
|
+
return base
|
|
399
|
+
raise RuntimeError("emulator port pool exhausted")
|
|
400
|
+
|
|
401
|
+
def _boot(self, cwd, block, only, project_id, label):
|
|
402
|
+
all_ports = _block_ports(block)
|
|
403
|
+
tmp = _write_injected_config(cwd, all_ports)
|
|
404
|
+
# Readiness/liveness track ONLY the data services actually started (respect
|
|
405
|
+
# --only, else the project's declared emulators; skip ui/hub/logging) — else
|
|
406
|
+
# we'd wait forever for ports (functions, pubsub, …) that never open.
|
|
407
|
+
declared = [k for k in json.loads((Path(cwd) / "firebase.json").read_text()).get("emulators", {}) if k != "singleProjectMode"]
|
|
408
|
+
run_ports = {s: all_ports[s] for s in (only or declared) if s in all_ports}
|
|
409
|
+
check = {s: p for s, p in run_ports.items() if s not in ("ui", "hub", "logging")}
|
|
410
|
+
log = STATE_DIR / f"{label}.log"
|
|
411
|
+
cmd = ["firebase", "emulators:start", "--config", tmp]
|
|
412
|
+
if only:
|
|
413
|
+
cmd += ["--only", ",".join(only)]
|
|
414
|
+
if project_id:
|
|
415
|
+
cmd += ["--project", project_id]
|
|
416
|
+
with open(log, "w") as lf:
|
|
417
|
+
proc = subprocess.Popen(cmd, cwd=cwd, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True)
|
|
418
|
+
# Liveness-driven: keep waiting while the process is alive (a slow JVM is still
|
|
419
|
+
# booting, not stuck); succeed when the ports open; fail if it exits; the
|
|
420
|
+
# backstop only guards a genuinely wedged boot.
|
|
421
|
+
backstop = time.time() + BOOT_BACKSTOP
|
|
422
|
+
while True:
|
|
423
|
+
if _all_open(check):
|
|
424
|
+
return proc.pid, tmp, str(log), run_ports, check
|
|
425
|
+
if proc.poll() is not None:
|
|
426
|
+
raise RuntimeError(f"emulators exited early; see {log}\n{_tail(log)}")
|
|
427
|
+
if time.time() > backstop:
|
|
428
|
+
try:
|
|
429
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL) # killpg → reap JVM children
|
|
430
|
+
except (OSError, ProcessLookupError):
|
|
431
|
+
pass
|
|
432
|
+
raise RuntimeError(f"boot backstop ({BOOT_BACKSTOP}s) hit, process still alive; see {log}")
|
|
433
|
+
time.sleep(1)
|
|
434
|
+
|
|
435
|
+
def _cleanup(self, rec):
|
|
436
|
+
"""Coordinated teardown — killpg the whole group so JVM children die too. Never pkill."""
|
|
437
|
+
pid = rec.get("pid")
|
|
438
|
+
# _child_alive, not _pid_alive: a daemon-spawned process that already
|
|
439
|
+
# exited lingers as our zombie child, which passes kill-0 — treating it
|
|
440
|
+
# as alive would stall this loop through a futile TERM+KILL cycle.
|
|
441
|
+
if _child_alive(pid):
|
|
442
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
443
|
+
try:
|
|
444
|
+
os.killpg(os.getpgid(int(pid)), sig)
|
|
445
|
+
except (OSError, ProcessLookupError):
|
|
446
|
+
break
|
|
447
|
+
for _ in range(50):
|
|
448
|
+
if not _child_alive(pid):
|
|
449
|
+
break
|
|
450
|
+
time.sleep(0.1)
|
|
451
|
+
if not _child_alive(pid):
|
|
452
|
+
break
|
|
453
|
+
tmp = rec.get("tmp_config")
|
|
454
|
+
if tmp and rec.get("cwd") and (Path(rec["cwd"]) / tmp).exists():
|
|
455
|
+
(Path(rec["cwd"]) / tmp).unlink()
|
|
456
|
+
if rec.get("sim_udid"):
|
|
457
|
+
_sim_teardown(rec["sim_udid"])
|
|
458
|
+
|
|
459
|
+
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):
|
|
461
|
+
with self.lock:
|
|
462
|
+
base = self._free_block()
|
|
463
|
+
lid = f"run:{uuid.uuid4().hex[:12]}"
|
|
464
|
+
project_id = _firebaserc_project(cwd)
|
|
465
|
+
pid, tmp, log, ports, check = self._boot(cwd, base, only, project_id, lid.replace(":", "-"))
|
|
466
|
+
# A leased simulator implies a metro port (the e2e dev-env wants both).
|
|
467
|
+
# The metro port lives in the lease's own 20-port block, so it's
|
|
468
|
+
# collision-free by construction and needs no health check.
|
|
469
|
+
if metro or sim:
|
|
470
|
+
ports["metro"] = base + METRO_OFFSET
|
|
471
|
+
sim_udid = sim_name = None
|
|
472
|
+
if sim:
|
|
473
|
+
sim_name = lid.replace(":", "-")
|
|
474
|
+
try:
|
|
475
|
+
sim_udid = _sim_create_boot(sim_device or DEFAULT_SIM_DEVICE,
|
|
476
|
+
sim_runtime or DEFAULT_SIM_RUNTIME, sim_name)
|
|
477
|
+
except Exception:
|
|
478
|
+
# Don't leak the emulators we just booted if the sim fails.
|
|
479
|
+
self._cleanup({"pid": pid, "cwd": cwd, "tmp_config": tmp})
|
|
480
|
+
raise
|
|
481
|
+
self.leases[lid] = {"leaseId": lid, "kind": "acquired", "cwd": cwd, "block": base,
|
|
482
|
+
"ports": ports, "check_ports": check, "pid": pid,
|
|
483
|
+
"owner_pid": owner_pid, "owner_start": owner_start,
|
|
484
|
+
"tmp_config": tmp, "log": log, "project_id": project_id,
|
|
485
|
+
"sim_udid": sim_udid, "sim_name": sim_name,
|
|
486
|
+
"started": _now(), "expires": time.time() + ttl}
|
|
487
|
+
self._save()
|
|
488
|
+
resp = {"leaseId": lid, "ports": ports, "projectId": project_id}
|
|
489
|
+
if sim_udid:
|
|
490
|
+
resp["sim"] = {"udid": sim_udid, "name": sim_name}
|
|
491
|
+
return resp
|
|
492
|
+
|
|
493
|
+
def allocate(self, owner_pid, owner_start=None, ttl=DEFAULT_TTL, label=None,
|
|
494
|
+
metro=False, sim=False, sim_device=None, sim_runtime=None,
|
|
495
|
+
spawn=None, spawn_cwd=None):
|
|
496
|
+
with self.lock:
|
|
497
|
+
base = self._free_block()
|
|
498
|
+
lid = f"alloc:{uuid.uuid4().hex[:12]}"
|
|
499
|
+
ports = {"base": base}
|
|
500
|
+
# A reserved block can also carry the dev-env extras for callers that
|
|
501
|
+
# boot their own emulators (or none at all): a Metro port and/or a
|
|
502
|
+
# private simulator. Used by e2e Metro sessions whose emulator suites
|
|
503
|
+
# are per-RUN acquires (walk2gether) — the long-lived Metro process
|
|
504
|
+
# owns just the port + sim.
|
|
505
|
+
if metro or sim:
|
|
506
|
+
ports["metro"] = base + METRO_OFFSET
|
|
507
|
+
sim_udid = sim_name = None
|
|
508
|
+
if sim:
|
|
509
|
+
sim_name = lid.replace(":", "-")
|
|
510
|
+
sim_udid = _sim_create_boot(sim_device or DEFAULT_SIM_DEVICE,
|
|
511
|
+
sim_runtime or DEFAULT_SIM_RUNTIME, sim_name)
|
|
512
|
+
# --spawn: the DAEMON runs the lease's process (Metro, a dev server…),
|
|
513
|
+
# so it survives the calling shell/agent/session. Its pid is the
|
|
514
|
+
# lease's liveness; teardown killpgs it like a booted suite.
|
|
515
|
+
pid = None
|
|
516
|
+
log = None
|
|
517
|
+
if spawn:
|
|
518
|
+
log = str(STATE_DIR / f"{lid.replace(':', '-')}.log")
|
|
519
|
+
env = {**os.environ,
|
|
520
|
+
"FBPORTS_BLOCK_BASE": str(base),
|
|
521
|
+
"FBPORTS_LEASE_ID": lid}
|
|
522
|
+
if ports.get("metro"):
|
|
523
|
+
env["METRO_PORT"] = str(ports["metro"])
|
|
524
|
+
if sim_udid:
|
|
525
|
+
env["DETOX_UDID"] = sim_udid
|
|
526
|
+
env["DETOX_SIM_NAME"] = sim_name
|
|
527
|
+
try:
|
|
528
|
+
with open(log, "w") as lf:
|
|
529
|
+
proc = subprocess.Popen(["/bin/sh", "-c", spawn],
|
|
530
|
+
cwd=spawn_cwd or None, env=env,
|
|
531
|
+
stdout=lf, stderr=subprocess.STDOUT,
|
|
532
|
+
start_new_session=True)
|
|
533
|
+
pid = proc.pid
|
|
534
|
+
except OSError:
|
|
535
|
+
# Don't leak the simulator we just created if the spawn fails.
|
|
536
|
+
if sim_udid:
|
|
537
|
+
_sim_teardown(sim_udid)
|
|
538
|
+
raise
|
|
539
|
+
print(f"leased {lid} ({label or 'no label'}): base {base}"
|
|
540
|
+
f"{f' sim {sim_name}' if sim_udid else ''}"
|
|
541
|
+
f"{f' spawned pid {pid}' if pid else ''}", flush=True)
|
|
542
|
+
self.leases[lid] = {"leaseId": lid, "kind": "reserved", "cwd": spawn_cwd,
|
|
543
|
+
"block": base, "ports": ports, "pid": pid,
|
|
544
|
+
"owner_pid": owner_pid,
|
|
545
|
+
"owner_start": owner_start, "tmp_config": None,
|
|
546
|
+
"log": log, "spawn": spawn,
|
|
547
|
+
"label": label, "sim_udid": sim_udid, "sim_name": sim_name,
|
|
548
|
+
"started": _now(), "expires": time.time() + ttl}
|
|
549
|
+
self._save()
|
|
550
|
+
resp = {"leaseId": lid, "base": base, "ports": ports}
|
|
551
|
+
if sim_udid:
|
|
552
|
+
resp["sim"] = {"udid": sim_udid, "name": sim_name}
|
|
553
|
+
if pid:
|
|
554
|
+
resp["spawnedPid"] = pid
|
|
555
|
+
resp["log"] = log
|
|
556
|
+
return resp
|
|
557
|
+
|
|
558
|
+
def release(self, lid):
|
|
559
|
+
with self.lock:
|
|
560
|
+
rec = self.leases.pop(lid, None)
|
|
561
|
+
if not rec:
|
|
562
|
+
return {"released": False, "reason": "unknown lease"}
|
|
563
|
+
print(f"released {lid} ({rec.get('label') or rec.get('cwd') or 'no label'})", flush=True)
|
|
564
|
+
self._cleanup(rec)
|
|
565
|
+
self._save()
|
|
566
|
+
return {"released": True}
|
|
567
|
+
|
|
568
|
+
def release_owned(self, owner_pid, cwd=None):
|
|
569
|
+
with self.lock:
|
|
570
|
+
n = 0
|
|
571
|
+
for lid in list(self.leases):
|
|
572
|
+
l = self.leases[lid]
|
|
573
|
+
if l.get("owner_pid") == owner_pid and (cwd is None or l.get("cwd") == cwd):
|
|
574
|
+
self._cleanup(self.leases.pop(lid))
|
|
575
|
+
n += 1
|
|
576
|
+
self._save()
|
|
577
|
+
return {"released": n}
|
|
578
|
+
|
|
579
|
+
def heartbeat(self, lid, ttl=DEFAULT_TTL):
|
|
580
|
+
with self.lock:
|
|
581
|
+
l = self.leases.get(lid)
|
|
582
|
+
if l and l.get("expires") is not None:
|
|
583
|
+
l["expires"] = time.time() + ttl
|
|
584
|
+
self._save()
|
|
585
|
+
return {"ok": bool(l)}
|
|
586
|
+
|
|
587
|
+
def status(self):
|
|
588
|
+
with self.lock:
|
|
589
|
+
return {"leases": [{"leaseId": l["leaseId"], "kind": l["kind"], "cwd": l.get("cwd"),
|
|
590
|
+
"ownerPid": l.get("owner_pid"), "up": _pid_alive(l.get("pid")),
|
|
591
|
+
"ports": l["ports"], "projectId": l.get("project_id"),
|
|
592
|
+
"sim": l.get("sim_udid"), "expires": l.get("expires"),
|
|
593
|
+
"label": l.get("label"), "log": l.get("log")}
|
|
594
|
+
for l in self.leases.values()]}
|
|
595
|
+
|
|
596
|
+
def reap(self):
|
|
597
|
+
with self.lock:
|
|
598
|
+
now = time.time()
|
|
599
|
+
for lid in list(self.leases):
|
|
600
|
+
if self._dead(self.leases[lid], now):
|
|
601
|
+
self._cleanup(self.leases.pop(lid))
|
|
602
|
+
self._reap_deleted_sims(now)
|
|
603
|
+
self._save()
|
|
604
|
+
|
|
605
|
+
def _reap_deleted_sims(self, now):
|
|
606
|
+
"""Reap leases whose simulator was deleted externally (Xcode cleanup,
|
|
607
|
+
`simctl delete/erase`, a stray reset). The lease is broken for its
|
|
608
|
+
caller even though its process is healthy; reaping it WHOLE (killing
|
|
609
|
+
the spawn too) prevents the half-alive ghost: sim gone, Metro still
|
|
610
|
+
squatting its port, callers 'reusing' a session Detox can't drive.
|
|
611
|
+
Throttled — one simctl listing per SIM_CHECK_INTERVAL, not per lease."""
|
|
612
|
+
if now - getattr(self, "_sims_checked", 0) < SIM_CHECK_INTERVAL:
|
|
613
|
+
return
|
|
614
|
+
sim_leases = [lid for lid, l in self.leases.items() if l.get("sim_udid")]
|
|
615
|
+
if not sim_leases:
|
|
616
|
+
self._sims_checked = now
|
|
617
|
+
return
|
|
618
|
+
udids = _sim_udids()
|
|
619
|
+
if udids is None:
|
|
620
|
+
return # indeterminate — no evidence, retry next pass
|
|
621
|
+
self._sims_checked = now
|
|
622
|
+
for lid in sim_leases:
|
|
623
|
+
l = self.leases.get(lid)
|
|
624
|
+
if l and l["sim_udid"] not in udids:
|
|
625
|
+
print(f"reaping {lid}: sim {l['sim_udid']} deleted externally", flush=True)
|
|
626
|
+
self._cleanup(self.leases.pop(lid))
|
|
627
|
+
|
|
628
|
+
def verify(self, lid):
|
|
629
|
+
"""Full-liveness check for one lease, for callers deciding whether to
|
|
630
|
+
REUSE a session rather than re-lease: known to the daemon + process
|
|
631
|
+
alive + simulator present + (booted suites) ports open. Read-only —
|
|
632
|
+
never tears anything down."""
|
|
633
|
+
with self.lock:
|
|
634
|
+
l = self.leases.get(lid)
|
|
635
|
+
if not l:
|
|
636
|
+
return {"ok": False, "reasons": ["unknown lease"]}
|
|
637
|
+
reasons = []
|
|
638
|
+
pid = l.get("pid")
|
|
639
|
+
if pid and not _child_alive(pid):
|
|
640
|
+
reasons.append(f"process {pid} dead")
|
|
641
|
+
if l.get("sim_udid"):
|
|
642
|
+
udids = _sim_udids()
|
|
643
|
+
if udids is not None and l["sim_udid"] not in udids:
|
|
644
|
+
reasons.append(f"sim {l['sim_udid']} gone")
|
|
645
|
+
if l["kind"] == "acquired":
|
|
646
|
+
down = [s for s, p in (l.get("check_ports") or _service_ports(l["ports"])).items()
|
|
647
|
+
if not _port_open(p)]
|
|
648
|
+
if down:
|
|
649
|
+
reasons.append("ports down: " + ",".join(sorted(down)))
|
|
650
|
+
return {"ok": not reasons, "reasons": reasons}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
654
|
+
broker = None
|
|
655
|
+
|
|
656
|
+
def log_message(self, *_):
|
|
657
|
+
pass
|
|
658
|
+
|
|
659
|
+
def _send(self, code, obj):
|
|
660
|
+
body = json.dumps(obj).encode()
|
|
661
|
+
self.send_response(code)
|
|
662
|
+
self.send_header("Content-Type", "application/json")
|
|
663
|
+
self.send_header("Content-Length", str(len(body)))
|
|
664
|
+
self.end_headers()
|
|
665
|
+
self.wfile.write(body)
|
|
666
|
+
|
|
667
|
+
def _body(self):
|
|
668
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
669
|
+
try:
|
|
670
|
+
return json.loads(self.rfile.read(n) or b"{}") if n else {}
|
|
671
|
+
except json.JSONDecodeError:
|
|
672
|
+
return {}
|
|
673
|
+
|
|
674
|
+
def do_GET(self):
|
|
675
|
+
if self.path == "/health":
|
|
676
|
+
return self._send(200, {"ok": True, "version": VERSION})
|
|
677
|
+
if self.path == "/status":
|
|
678
|
+
return self._send(200, self.broker.status())
|
|
679
|
+
self._send(404, {"error": "not found"})
|
|
680
|
+
|
|
681
|
+
def do_POST(self):
|
|
682
|
+
b, d = self.broker, self._body()
|
|
683
|
+
try:
|
|
684
|
+
if self.path == "/acquire":
|
|
685
|
+
return self._send(200, b.acquire(d["cwd"], d.get("owner_pid"), d.get("owner_start"),
|
|
686
|
+
d.get("only"), d.get("ttl", DEFAULT_TTL),
|
|
687
|
+
d.get("metro", False), d.get("sim", False),
|
|
688
|
+
d.get("sim_device"), d.get("sim_runtime")))
|
|
689
|
+
if self.path == "/allocate":
|
|
690
|
+
return self._send(200, b.allocate(d.get("owner_pid"), d.get("owner_start"),
|
|
691
|
+
d.get("ttl", DEFAULT_TTL), d.get("label"),
|
|
692
|
+
d.get("metro", False), d.get("sim", False),
|
|
693
|
+
d.get("sim_device"), d.get("sim_runtime"),
|
|
694
|
+
d.get("spawn"), d.get("spawn_cwd")))
|
|
695
|
+
if self.path == "/release":
|
|
696
|
+
return self._send(200, b.release(d.get("leaseId")))
|
|
697
|
+
if self.path == "/release-owned":
|
|
698
|
+
return self._send(200, b.release_owned(d.get("owner_pid"), d.get("cwd")))
|
|
699
|
+
if self.path == "/heartbeat":
|
|
700
|
+
return self._send(200, b.heartbeat(d.get("leaseId"), d.get("ttl", DEFAULT_TTL)))
|
|
701
|
+
if self.path == "/verify":
|
|
702
|
+
return self._send(200, b.verify(d.get("leaseId")))
|
|
703
|
+
if self.path == "/shutdown":
|
|
704
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
705
|
+
return self._send(200, {"ok": True})
|
|
706
|
+
self._send(404, {"error": "not found"})
|
|
707
|
+
except Exception as e:
|
|
708
|
+
self._send(500, {"error": str(e)})
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def cmd_serve():
|
|
712
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
713
|
+
_Handler.broker = Broker()
|
|
714
|
+
srv = http.server.ThreadingHTTPServer((BROKER_HOST, BROKER_PORT), _Handler)
|
|
715
|
+
|
|
716
|
+
def reaper():
|
|
717
|
+
while True:
|
|
718
|
+
time.sleep(REAP_INTERVAL)
|
|
719
|
+
try:
|
|
720
|
+
_Handler.broker.reap()
|
|
721
|
+
except Exception:
|
|
722
|
+
pass
|
|
723
|
+
|
|
724
|
+
threading.Thread(target=reaper, daemon=True).start()
|
|
725
|
+
print(f"harbormaster {VERSION} daemon on http://{BROKER_HOST}:{BROKER_PORT} "
|
|
726
|
+
f"(pool {POOL_BASE}-{POOL_BASE + POOL_BLOCKS * BLOCK - 1})", flush=True)
|
|
727
|
+
srv.serve_forever()
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# ---- client --------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
def _call(method, path, body=None, timeout=BOOT_BACKSTOP + 60): # client must outlive server's boot
|
|
733
|
+
conn = http.client.HTTPConnection(BROKER_HOST, BROKER_PORT, timeout=timeout)
|
|
734
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
735
|
+
conn.request(method, path, body=data, headers={"Content-Type": "application/json"} if data else {})
|
|
736
|
+
resp = conn.getresponse()
|
|
737
|
+
out = json.loads(resp.read() or b"{}")
|
|
738
|
+
conn.close()
|
|
739
|
+
return resp.status, out
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _up():
|
|
743
|
+
try:
|
|
744
|
+
return _call("GET", "/health", timeout=1)[0] == 200
|
|
745
|
+
except OSError:
|
|
746
|
+
return False
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _daemon_version():
|
|
750
|
+
"""The running daemon's version, '0' for pre-0.3 daemons, None if not running."""
|
|
751
|
+
try:
|
|
752
|
+
st, out = _call("GET", "/health", timeout=1)
|
|
753
|
+
return (out.get("version") or "0") if st == 200 else None
|
|
754
|
+
except OSError:
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _ver_tuple(v):
|
|
759
|
+
try:
|
|
760
|
+
return tuple(int(x) for x in str(v).split("."))
|
|
761
|
+
except ValueError:
|
|
762
|
+
return (0,)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _spawn_daemon():
|
|
766
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
767
|
+
log = open(STATE_DIR / "daemon.log", "a")
|
|
768
|
+
subprocess.Popen([sys.argv[0], "serve"], stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _ensure():
|
|
772
|
+
v = _daemon_version()
|
|
773
|
+
if v is not None:
|
|
774
|
+
if _ver_tuple(v) >= _ver_tuple(VERSION):
|
|
775
|
+
return
|
|
776
|
+
# A newer client found an older daemon (e.g. a stale node_modules copy
|
|
777
|
+
# started it). Replace it: leases persist in the ledger and every leased
|
|
778
|
+
# process (suites, spawns) runs in its own session, so the successor
|
|
779
|
+
# daemon reloads them and carries on — nothing is torn down.
|
|
780
|
+
try:
|
|
781
|
+
_call("POST", "/shutdown", timeout=5)
|
|
782
|
+
except OSError:
|
|
783
|
+
pass
|
|
784
|
+
for _ in range(50):
|
|
785
|
+
if not _up():
|
|
786
|
+
break
|
|
787
|
+
time.sleep(0.1)
|
|
788
|
+
if _up():
|
|
789
|
+
raise RuntimeError(
|
|
790
|
+
f"daemon {v} is older than this client ({VERSION}) and did not "
|
|
791
|
+
f"shut down — kill the 'harbormaster serve' process and retry")
|
|
792
|
+
_spawn_daemon()
|
|
793
|
+
for _ in range(50):
|
|
794
|
+
if _up():
|
|
795
|
+
return
|
|
796
|
+
time.sleep(0.1)
|
|
797
|
+
raise RuntimeError("daemon failed to start (see ~/.local/state/firebase-ports/daemon.log)")
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _owner(opts):
|
|
801
|
+
"""The owning process: --owner, else the CLI's parent (the calling shell/runner).
|
|
802
|
+
|
|
803
|
+
`--owner auto` resolves the nearest ancestor that outlives the calling
|
|
804
|
+
script — the useful answer for daemon-spawned leases, where the immediate
|
|
805
|
+
parent exits seconds later. Resolves to no owner when nothing suitable is
|
|
806
|
+
found, leaving TTL and working-dir checks to govern the lease.
|
|
807
|
+
"""
|
|
808
|
+
if str(opts.get("owner")).lower() == "auto":
|
|
809
|
+
pid = _durable_ancestor(os.getpid())
|
|
810
|
+
return (pid, _proc_start(pid)) if pid else (None, None)
|
|
811
|
+
pid = int(opts["owner"]) if opts.get("owner") else os.getppid()
|
|
812
|
+
return pid, _proc_start(pid)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _resolve_cwd(opts):
|
|
816
|
+
cwd = opts.get("cwd") or _find_firebase_dir(os.getcwd())
|
|
817
|
+
if not cwd or not (Path(cwd) / "firebase.json").exists():
|
|
818
|
+
print("no firebase.json here — run inside a project or pass --cwd DIR", file=sys.stderr)
|
|
819
|
+
sys.exit(1)
|
|
820
|
+
return str(Path(cwd).resolve())
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _print(out, fmt):
|
|
824
|
+
ports, pid = out.get("ports", {}), out.get("projectId")
|
|
825
|
+
sim = out.get("sim")
|
|
826
|
+
if fmt == "json":
|
|
827
|
+
print(json.dumps(out))
|
|
828
|
+
elif fmt == "env":
|
|
829
|
+
# Standard Firebase host vars (admin SDK + emulators:exec auto-read these) AND
|
|
830
|
+
# EMULATOR_<SVC>_PORT (what app.config / config.sh / scrub scripts read).
|
|
831
|
+
std = {"firestore": "FIRESTORE_EMULATOR_HOST", "auth": "FIREBASE_AUTH_EMULATOR_HOST",
|
|
832
|
+
"storage": "FIREBASE_STORAGE_EMULATOR_HOST", "database": "FIREBASE_DATABASE_EMULATOR_HOST",
|
|
833
|
+
"pubsub": "PUBSUB_EMULATOR_HOST"}
|
|
834
|
+
if pid:
|
|
835
|
+
print(f"export GCLOUD_PROJECT={pid}")
|
|
836
|
+
print(f"export GOOGLE_CLOUD_PROJECT={pid}")
|
|
837
|
+
for svc, port in ports.items():
|
|
838
|
+
if svc == "metro":
|
|
839
|
+
print(f"export METRO_PORT={port}") # Expo/Metro dev server + Detox bundle URL
|
|
840
|
+
continue
|
|
841
|
+
if svc in std:
|
|
842
|
+
print(f"export {std[svc]}=127.0.0.1:{port}")
|
|
843
|
+
print(f"export EMULATOR_{svc.upper()}_PORT={port}")
|
|
844
|
+
if sim:
|
|
845
|
+
print(f"export DETOX_UDID={sim['udid']}") # Detox targets this simulator by UDID
|
|
846
|
+
print(f"export DETOX_SIM_NAME={sim['name']}")
|
|
847
|
+
else:
|
|
848
|
+
print(f"{out.get('leaseId', '')} ({pid})".strip())
|
|
849
|
+
for svc, port in ports.items():
|
|
850
|
+
print(f" {svc:<10} 127.0.0.1:{port}")
|
|
851
|
+
if sim:
|
|
852
|
+
print(f" {'sim':<10} {sim['name']} {sim['udid']}")
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _parse(args):
|
|
856
|
+
pos, flags, opts = [], set(), {}
|
|
857
|
+
i = 0
|
|
858
|
+
while i < len(args):
|
|
859
|
+
a = args[i]
|
|
860
|
+
if a.startswith("--"):
|
|
861
|
+
if "=" in a:
|
|
862
|
+
k, v = a[2:].split("=", 1)
|
|
863
|
+
opts[k] = v
|
|
864
|
+
elif a[2:] in ("cwd", "only", "ttl", "owner", "label", "sim-device", "sim-runtime", "spawn") and i + 1 < len(args):
|
|
865
|
+
opts[a[2:]] = args[i + 1]
|
|
866
|
+
i += 1
|
|
867
|
+
else:
|
|
868
|
+
flags.add(a[2:])
|
|
869
|
+
else:
|
|
870
|
+
pos.append(a)
|
|
871
|
+
i += 1
|
|
872
|
+
return pos, flags, opts
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def main():
|
|
876
|
+
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
|
|
877
|
+
print(__doc__.strip())
|
|
878
|
+
sys.exit(0)
|
|
879
|
+
cmd = sys.argv[1]
|
|
880
|
+
pos, flags, opts = _parse(sys.argv[2:])
|
|
881
|
+
fmt = "json" if "json" in flags else "env" if "env" in flags else "human"
|
|
882
|
+
only = opts["only"].split(",") if opts.get("only") else None
|
|
883
|
+
|
|
884
|
+
def ok(res, verb):
|
|
885
|
+
st, out = res
|
|
886
|
+
if st != 200:
|
|
887
|
+
print(f"{verb} failed: {out.get('error', out)}", file=sys.stderr)
|
|
888
|
+
sys.exit(1)
|
|
889
|
+
return out
|
|
890
|
+
|
|
891
|
+
if cmd == "version":
|
|
892
|
+
print(VERSION)
|
|
893
|
+
elif cmd == "serve":
|
|
894
|
+
cmd_serve()
|
|
895
|
+
elif cmd in ("acquire", "up"):
|
|
896
|
+
_ensure()
|
|
897
|
+
pid, start = _owner(opts)
|
|
898
|
+
_print(ok(_call("POST", "/acquire", {"cwd": _resolve_cwd(opts), "owner_pid": pid,
|
|
899
|
+
"owner_start": start, "only": only,
|
|
900
|
+
"ttl": int(opts.get("ttl", DEFAULT_TTL)),
|
|
901
|
+
"metro": "metro" in flags, "sim": "sim" in flags,
|
|
902
|
+
"sim_device": opts.get("sim-device"),
|
|
903
|
+
"sim_runtime": opts.get("sim-runtime")}), "acquire"), fmt)
|
|
904
|
+
elif cmd == "down":
|
|
905
|
+
if _up():
|
|
906
|
+
out = _call("POST", "/release-owned", {"owner_pid": os.getppid(), "cwd": _resolve_cwd(opts)})[1]
|
|
907
|
+
print(f"released {out.get('released', 0)} sandbox(es)")
|
|
908
|
+
else:
|
|
909
|
+
print("daemon not running")
|
|
910
|
+
elif cmd == "release":
|
|
911
|
+
if _up() and pos:
|
|
912
|
+
out = _call("POST", "/release", {"leaseId": pos[0]})[1]
|
|
913
|
+
print("released" if out.get("released") else f"not released: {out.get('reason')}")
|
|
914
|
+
else:
|
|
915
|
+
print("nothing to release")
|
|
916
|
+
elif cmd == "allocate":
|
|
917
|
+
_ensure()
|
|
918
|
+
spawn = opts.get("spawn")
|
|
919
|
+
# A daemon-spawned or explicitly-detached lease has no owner: it outlives
|
|
920
|
+
# the calling shell/agent by design and is governed by pid liveness + TTL.
|
|
921
|
+
if (spawn or "detach" in flags) and not opts.get("owner"):
|
|
922
|
+
pid, start = None, None
|
|
923
|
+
else:
|
|
924
|
+
pid, start = _owner(opts)
|
|
925
|
+
out = ok(_call("POST", "/allocate", {"owner_pid": pid, "owner_start": start,
|
|
926
|
+
"ttl": int(opts.get("ttl", DEFAULT_TTL)),
|
|
927
|
+
"label": opts.get("label"),
|
|
928
|
+
"metro": "metro" in flags, "sim": "sim" in flags,
|
|
929
|
+
"sim_device": opts.get("sim-device"),
|
|
930
|
+
"sim_runtime": opts.get("sim-runtime"),
|
|
931
|
+
"spawn": spawn,
|
|
932
|
+
"spawn_cwd": str(Path(opts["cwd"]).resolve()) if opts.get("cwd") else None}),
|
|
933
|
+
"allocate")
|
|
934
|
+
if fmt == "env":
|
|
935
|
+
# Reserved block, nothing booted — emit only the dev-env extras (plus
|
|
936
|
+
# the base for callers that derive their own ports from it).
|
|
937
|
+
print(f"export FBPORTS_BLOCK_BASE={out['base']}")
|
|
938
|
+
print(f"export FBPORTS_LEASE_ID={out['leaseId']}")
|
|
939
|
+
if out.get("ports", {}).get("metro"):
|
|
940
|
+
print(f"export METRO_PORT={out['ports']['metro']}")
|
|
941
|
+
if out.get("sim"):
|
|
942
|
+
print(f"export DETOX_UDID={out['sim']['udid']}")
|
|
943
|
+
print(f"export DETOX_SIM_NAME={out['sim']['name']}")
|
|
944
|
+
if out.get("log"):
|
|
945
|
+
print(f"export FBPORTS_SPAWN_LOG={out['log']}")
|
|
946
|
+
elif fmt == "json":
|
|
947
|
+
print(json.dumps(out))
|
|
948
|
+
else:
|
|
949
|
+
extra = f" metro {out['ports']['metro']}" if out.get("ports", {}).get("metro") else ""
|
|
950
|
+
extra += f" sim {out['sim']['udid']}" if out.get("sim") else ""
|
|
951
|
+
extra += f" spawned pid {out['spawnedPid']}" if out.get("spawnedPid") else ""
|
|
952
|
+
print(f"{out['leaseId']} base {out['base']}{extra}")
|
|
953
|
+
elif cmd == "exec":
|
|
954
|
+
# harbormaster exec [--cwd DIR] [--only a,b] '<script>'
|
|
955
|
+
# Reserve a free block from the daemon, then run `firebase emulators:exec`
|
|
956
|
+
# on it (which boots the suite, sets FIRESTORE_EMULATOR_HOST etc. for the
|
|
957
|
+
# script, and tears down). This is how projects should invoke emulators:exec
|
|
958
|
+
# so concurrent runs never collide on ports.
|
|
959
|
+
_ensure()
|
|
960
|
+
cwd = _resolve_cwd(opts)
|
|
961
|
+
script = " ".join(pos)
|
|
962
|
+
if not script:
|
|
963
|
+
print("usage: harbormaster exec [--cwd DIR] [--only a,b] '<script>'", file=sys.stderr)
|
|
964
|
+
sys.exit(1)
|
|
965
|
+
me = os.getpid() # this process owns the reservation for the run
|
|
966
|
+
res = ok(_call("POST", "/allocate", {"owner_pid": me, "owner_start": _proc_start(me), "label": "exec"}), "exec")
|
|
967
|
+
base, lid = res["base"], res["leaseId"]
|
|
968
|
+
tmp = _write_injected_config(cwd, _block_ports(base))
|
|
969
|
+
try:
|
|
970
|
+
run = ["firebase", "emulators:exec", "--config", tmp]
|
|
971
|
+
if only:
|
|
972
|
+
run += ["--only", ",".join(only)]
|
|
973
|
+
run.append(script)
|
|
974
|
+
rc = subprocess.call(run, cwd=cwd)
|
|
975
|
+
finally:
|
|
976
|
+
try:
|
|
977
|
+
_call("POST", "/release", {"leaseId": lid})
|
|
978
|
+
except OSError:
|
|
979
|
+
pass
|
|
980
|
+
p = Path(cwd) / tmp
|
|
981
|
+
if p.exists():
|
|
982
|
+
p.unlink()
|
|
983
|
+
sys.exit(rc)
|
|
984
|
+
elif cmd == "verify":
|
|
985
|
+
# verify <leaseId> — exit 0 iff the lease is FULLY alive. The reuse
|
|
986
|
+
# gate for long-lived sessions (e2e Metro + sim): anything less than
|
|
987
|
+
# fully alive means tear down what's left and re-lease.
|
|
988
|
+
if not pos:
|
|
989
|
+
print("usage: harbormaster verify <leaseId>", file=sys.stderr)
|
|
990
|
+
sys.exit(2)
|
|
991
|
+
_ensure() # also upgrades an older daemon first
|
|
992
|
+
out = _call("POST", "/verify", {"leaseId": pos[0]})[1]
|
|
993
|
+
if out.get("ok"):
|
|
994
|
+
print("ok")
|
|
995
|
+
else:
|
|
996
|
+
print("; ".join(out.get("reasons") or [out.get("error", "not ok")]))
|
|
997
|
+
sys.exit(1)
|
|
998
|
+
elif cmd in ("status", "leases"):
|
|
999
|
+
if not _up():
|
|
1000
|
+
print("daemon not running.")
|
|
1001
|
+
return
|
|
1002
|
+
leases = _call("GET", "/status")[1].get("leases", [])
|
|
1003
|
+
if not leases:
|
|
1004
|
+
print("nothing out.")
|
|
1005
|
+
return
|
|
1006
|
+
for l in leases:
|
|
1007
|
+
exp = l.get("expires")
|
|
1008
|
+
ttl = "" if exp is None else f" ttl~{max(0, int(exp - time.time()))}s"
|
|
1009
|
+
where = f"base:{l['ports']['base']}" if l["kind"] == "reserved" else f"firestore:{l['ports'].get('firestore')}"
|
|
1010
|
+
if l["kind"] != "reserved" and l["ports"].get("metro"):
|
|
1011
|
+
where += f" metro:{l['ports']['metro']}"
|
|
1012
|
+
if l.get("sim"):
|
|
1013
|
+
where += f" sim:{l['sim'][:8]}"
|
|
1014
|
+
print(f"{l['leaseId']:<22} {l['kind']:<9} owner:{str(l.get('ownerPid') or '-'):<7} "
|
|
1015
|
+
f"{'up' if l['up'] else '-':<3} {where}{ttl} "
|
|
1016
|
+
f"{l.get('label') or ''} {l.get('cwd') or ''}".rstrip())
|
|
1017
|
+
else:
|
|
1018
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
1019
|
+
print("Commands: acquire, up, down, release, allocate, verify, status, leases, serve", file=sys.stderr)
|
|
1020
|
+
sys.exit(1)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
if __name__ == "__main__":
|
|
1024
|
+
main()
|