@simon-says-build/harbormaster 0.14.2 → 0.15.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 +14 -3
- package/README.md +47 -2
- package/harbormaster +193 -33
- package/package.json +1 -1
package/DESIGN.md
CHANGED
|
@@ -169,9 +169,20 @@ POST /doctor {fix?} -> {findings, fixed,
|
|
|
169
169
|
```
|
|
170
170
|
|
|
171
171
|
CLI: `acquire` / `up` (alias) / `down` (release your lease) / `release <id>` / `status`
|
|
172
|
-
/ `leases` / `allocate` / `serve`. Pool: blocks of 20 ports from 11000–14999
|
|
173
|
-
`
|
|
174
|
-
|
|
172
|
+
/ `leases` / `allocate` / `serve`. Pool: blocks of 20 ports from 11000–14999. An
|
|
173
|
+
`acquire`/`exec` suite's block (0.15.0: every port the CLI may bind, declared or not):
|
|
174
|
+
|
|
175
|
+
| +0 auth | +5 hub | +10 firestore websocket | +15 hosting |
|
|
176
|
+
|---|---|---|---|
|
|
177
|
+
| +1 firestore | +6 logging | +11 Metro | +16 apphosting |
|
|
178
|
+
| +2 functions | +7 pubsub | +12 caller | +17 dataconnect |
|
|
179
|
+
| +3 storage | +8 eventarc | +13 caller | +18 dataconnect postgres |
|
|
180
|
+
| +4 ui | +9 database | +14 caller | +19 tasks |
|
|
181
|
+
|
|
182
|
+
+12..+14 are never bound by a suite: they are the caller's (SimonSays' verify dev
|
|
183
|
+
server, CDP, api, vite). A reserved (`allocate`) block is the caller's whole, bar
|
|
184
|
+
Metro at +11. Android emulator console/adb pairs (`--android`) come from a second
|
|
185
|
+
range, even consoles 5554–5584 (adb = console + 1), leased the same way.
|
|
175
186
|
|
|
176
187
|
## What changes from v1
|
|
177
188
|
|
package/README.md
CHANGED
|
@@ -92,8 +92,9 @@ its port) can never linger for callers to "reuse".
|
|
|
92
92
|
children die too) when `owner_pid` dies. The caller's death *is* the release —
|
|
93
93
|
nothing to remember, and never a reason to `pkill`. A TTL and emulator-liveness are
|
|
94
94
|
backstops.
|
|
95
|
-
- **Pool ports.** Blocks of 20 from 11000–14999
|
|
96
|
-
|
|
95
|
+
- **Pool ports.** Blocks of 20 from 11000–14999. Every port a suite binds is on its
|
|
96
|
+
block, including the ones firebase-tools starts unasked (see "Every port a suite
|
|
97
|
+
binds is on its block"); offsets 12–14 are left for the caller.
|
|
97
98
|
- **No registry, no shared instances.** A "project" is just the `firebase.json` in
|
|
98
99
|
`cwd`; the project id comes from `.firebaserc`.
|
|
99
100
|
|
|
@@ -164,6 +165,50 @@ their own variable for the emulator (Tapuat's `EMULATOR_PORT_FIRESTORE ||
|
|
|
164
165
|
"8080"`) otherwise talks to whatever holds the default. An unknown service is
|
|
165
166
|
left as written. `python3 test/port_env.py`.
|
|
166
167
|
|
|
168
|
+
## Every port a suite binds is on its block (0.15.0)
|
|
169
|
+
|
|
170
|
+
firebase-tools starts more than `firebase.json` declares: the hub always; the
|
|
171
|
+
UI whenever a UI-supported emulator runs (declared or not) and logging with
|
|
172
|
+
it; eventarc and tasks whenever functions has a backend (an extension counts);
|
|
173
|
+
firestore's websocket (9150) and dataconnect's postgres (5432). Any port it is
|
|
174
|
+
not given it takes from the default and, if that is held, probes upward —
|
|
175
|
+
check-then-bind, so two suites booting in the same seconds pick the same port
|
|
176
|
+
and the second dies with "An unexpected error has occurred." (SimonSays' Mac
|
|
177
|
+
Studio, 2026-10-21: logging 4501 and 4502 taken twice; a walk2gether-expo CI
|
|
178
|
+
suite holding tasks 9501.) The injected config now sets all of them, plus
|
|
179
|
+
hosting, apphosting and dataconnect wherever the project uses them:
|
|
180
|
+
|
|
181
|
+
| +0 auth | +5 hub | +10 firestore websocket | +15 hosting |
|
|
182
|
+
|---|---|---|---|
|
|
183
|
+
| +1 firestore | +6 logging | +11 Metro | +16 apphosting |
|
|
184
|
+
| +2 functions | +7 pubsub | +12 caller | +17 dataconnect |
|
|
185
|
+
| +3 storage | +8 eventarc | +13 caller | +18 dataconnect postgres |
|
|
186
|
+
| +4 ui | +9 database | +14 caller | +19 tasks |
|
|
187
|
+
|
|
188
|
+
+12..+14 are never bound by a suite. A declared emulator with no offset is
|
|
189
|
+
refused by name instead of left to its default. Teardown and the world sweep
|
|
190
|
+
already cover the whole block. `python3 test/injected_config.py`; `test/e2e.sh`
|
|
191
|
+
checks a live suite's listeners.
|
|
192
|
+
|
|
193
|
+
## An Android emulator's ports are a lease (0.15.0)
|
|
194
|
+
|
|
195
|
+
`acquire --android` / `allocate --android` also lease an emulator console/adb
|
|
196
|
+
pair: an even console port in 5554..5584 (the range adb discovers; adb =
|
|
197
|
+
console + 1; `FBPORTS_ANDROID_CONSOLE_FIRST`/`_LAST` move it), unique among
|
|
198
|
+
live leases and free when handed out. The caller (and a `--spawn` process)
|
|
199
|
+
gets `ANDROID_CONSOLE_PORT`, `ANDROID_ADB_PORT`, `ANDROID_SERIAL=emulator-<console>`:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
harbormaster allocate --android --owner auto \
|
|
203
|
+
--spawn 'exec emulator -avd Pixel -port "$ANDROID_CONSOLE_PORT" -read-only -no-window' --env
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The pair is outside the pool, where a hand-booted emulator lands by default, so
|
|
207
|
+
teardown kills only what the lease spawned (its group and recorded tree) and
|
|
208
|
+
reports anything else still on the pair; the world sweep reports unleased
|
|
209
|
+
listeners on the range and evicts only what it can attribute.
|
|
210
|
+
`python3 test/android_ports.py`.
|
|
211
|
+
|
|
167
212
|
## A lease never reads the hosting config it doesn't start (0.14.1)
|
|
168
213
|
|
|
169
214
|
A lease whose `--only` leaves hosting out boots from a config with no
|
package/harbormaster
CHANGED
|
@@ -8,7 +8,7 @@ per-project instances — two callers that both want "an impulse emulator" get t
|
|
|
8
8
|
independent suites, so they never collide on ports or data.
|
|
9
9
|
|
|
10
10
|
acquire [--cwd DIR] [--owner PID] [--only a,b] [--ttl N] [--metro] [--sim]
|
|
11
|
-
[--fn-env KEY=VAL ...] [--json|--env]
|
|
11
|
+
[--android] [--fn-env KEY=VAL ...] [--json|--env]
|
|
12
12
|
Boot a private sandbox for the project in DIR (default: nearest firebase.json
|
|
13
13
|
above $PWD). Owned by --owner (default: the calling process's parent), reaped
|
|
14
14
|
when that process exits. Prints the ports (use --env to eval into your shell).
|
|
@@ -20,15 +20,23 @@ independent suites, so they never collide on ports or data.
|
|
|
20
20
|
Tune with --sim-device / --sim-runtime
|
|
21
21
|
(or FBPORTS_SIM_DEVICE / FBPORTS_SIM_RUNTIME). This is the e2e dev-env: one
|
|
22
22
|
lease = emulators + metro port + simulator, all torn down when the owner exits.
|
|
23
|
+
--android also leases an Android emulator console/adb port pair (an even
|
|
24
|
+
console port in 5554..5584, adb = console + 1; FBPORTS_ANDROID_CONSOLE_FIRST
|
|
25
|
+
/ _LAST move the range): ANDROID_CONSOLE_PORT, ANDROID_ADB_PORT,
|
|
26
|
+
ANDROID_SERIAL=emulator-<console>. Boot with `emulator -port "$ANDROID_CONSOLE_PORT"`.
|
|
27
|
+
Every port the suite binds is on the block, including the ones firebase-tools
|
|
28
|
+
starts unasked (ui, logging, eventarc, tasks, firestore's websocket,
|
|
29
|
+
dataconnect's postgres); block offsets 12-14 are never bound — they're yours.
|
|
23
30
|
up ... Alias for acquire (interactive dev; owner = your shell).
|
|
24
31
|
down [--cwd DIR] Release this shell's sandbox for DIR (else it's reaped on exit).
|
|
25
32
|
release <leaseId> Release a specific lease.
|
|
26
33
|
status Everything the daemon has out.
|
|
27
|
-
allocate [--owner PID|auto] [--ttl N] [--metro] [--sim] [--
|
|
28
|
-
[--detach] [--json|--env]
|
|
34
|
+
allocate [--owner PID|auto] [--ttl N] [--metro] [--sim] [--android] [--spawn CMD]
|
|
35
|
+
[--cwd DIR] [--detach] [--json|--env]
|
|
29
36
|
Reserve a block WITHOUT booting — for a caller that boots its own suite (the
|
|
30
37
|
agent-qa run driver). Also owner-reaped. --metro/--sim work as on acquire
|
|
31
|
-
(metro port in the block; private simulator, deleted on reap)
|
|
38
|
+
(metro port in the block; private simulator, deleted on reap), and so does
|
|
39
|
+
--android (the pair; CMD sees ANDROID_CONSOLE_PORT etc.), for e2e Metro
|
|
32
40
|
sessions whose emulators are per-RUN acquires. NOTE: reserved leases also
|
|
33
41
|
reap on TTL even with a live owner — pass a long --ttl for long-lived
|
|
34
42
|
Metro sessions (owner death still reaps promptly).
|
|
@@ -36,7 +44,8 @@ independent suites, so they never collide on ports or data.
|
|
|
36
44
|
lease's process: daemon-owned, so it survives the calling shell/agent —
|
|
37
45
|
liveness is the spawned pid (+ TTL backstop), teardown kills its process
|
|
38
46
|
group, stdout goes to the lease log. CMD sees FBPORTS_BLOCK_BASE,
|
|
39
|
-
FBPORTS_LEASE_ID, and METRO_PORT / DETOX_UDID / DETOX_SIM_NAME
|
|
47
|
+
FBPORTS_LEASE_ID, and METRO_PORT / DETOX_UDID / DETOX_SIM_NAME /
|
|
48
|
+
ANDROID_CONSOLE_PORT / ANDROID_ADB_PORT / ANDROID_SERIAL when leased.
|
|
40
49
|
With --spawn (or --detach) the lease defaults to NO owner pid, since the
|
|
41
50
|
calling shell exits immediately and would reap it. Pass --owner auto to
|
|
42
51
|
record the nearest ancestor that DOES outlive the call (under an agent,
|
|
@@ -84,7 +93,7 @@ import uuid
|
|
|
84
93
|
from datetime import datetime, timezone
|
|
85
94
|
from pathlib import Path
|
|
86
95
|
|
|
87
|
-
VERSION = "0.
|
|
96
|
+
VERSION = "0.15.0"
|
|
88
97
|
|
|
89
98
|
# Overridable for tests / parallel installs; default is the shared user daemon.
|
|
90
99
|
# The dir keeps the old "firebase-ports" name: pre-rename binaries still installed
|
|
@@ -103,14 +112,62 @@ BROKER_HOST, BROKER_PORT = "127.0.0.1", int(os.environ.get("FBPORTS_PORT", "4999
|
|
|
103
112
|
POOL_BASE = int(os.environ.get("FBPORTS_POOL_BASE", "11000"))
|
|
104
113
|
POOL_BLOCKS = int(os.environ.get("FBPORTS_POOL_BLOCKS", "200"))
|
|
105
114
|
BLOCK = 20
|
|
115
|
+
# EVERY port an emulator suite can bind is on its block (0.15.0). firebase-tools
|
|
116
|
+
# (15.28, controller.js) binds more than firebase.json declares, and for any
|
|
117
|
+
# port it is not given it takes the default and, if that is held, probes upward
|
|
118
|
+
# — check-then-bind, so two suites booting in the same seconds pick the same
|
|
119
|
+
# port and the second dies with "An unexpected error has occurred." (SSB's Mac
|
|
120
|
+
# Studio, 2026-10-21: three suites, logging 4501/4502 twice; a walk2gether-expo
|
|
121
|
+
# CI suite holding tasks 9501). Started unasked: hub (always), ui (whenever a
|
|
122
|
+
# UI-supported emulator runs, declared or not), logging (with the ui), eventarc
|
|
123
|
+
# and tasks (whenever functions has a backend — an extension counts), firestore's
|
|
124
|
+
# websocket (9150), dataconnect's postgres (5432).
|
|
125
|
+
#
|
|
126
|
+
# +0 auth +5 hub +10 firestore websocket +15 hosting
|
|
127
|
+
# +1 firestore +6 logging +11 Metro (METRO_OFFSET) +16 apphosting
|
|
128
|
+
# +2 functions +7 pubsub +12 ┐ +17 dataconnect
|
|
129
|
+
# +3 storage +8 eventarc +13 ├ the caller's +18 dataconnect postgres
|
|
130
|
+
# +4 ui +9 database +14 ┘ +19 tasks
|
|
131
|
+
#
|
|
132
|
+
# 12-14 stay the caller's: SimonSays' acquire callers put their verify dev
|
|
133
|
+
# server / CDP / api / vite there (simon-says bin/lease-offsets.test.mjs,
|
|
134
|
+
# web/playwright.config.ts, bin/seeded-explore). Everything else in the block
|
|
135
|
+
# is harbormaster's. A reserved (`allocate`) block is the caller's whole, bar
|
|
136
|
+
# Metro: this table is only what an `acquire`/`exec` suite binds.
|
|
106
137
|
BLOCK_OFFSETS = {"auth": 0, "firestore": 1, "functions": 2, "storage": 3, "ui": 4,
|
|
107
138
|
"hub": 5, "logging": 6, "pubsub": 7, "eventarc": 8, "database": 9,
|
|
108
139
|
# Firestore's UI websocket is a SECOND port it needs (CLI default 9150).
|
|
109
140
|
# It must be pool-allocated too — otherwise every concurrent firestore
|
|
110
141
|
# emulator defaults to 9150 and they fight over it, even though their
|
|
111
142
|
# data ports are isolated. (Block is 20 wide, so +10 is in range.)
|
|
112
|
-
"firestore_websocket": 10
|
|
113
|
-
|
|
143
|
+
"firestore_websocket": 10,
|
|
144
|
+
"hosting": 15, "apphosting": 16, "dataconnect": 17,
|
|
145
|
+
# dataconnect's second port: its PGlite postgres (CLI default 5432).
|
|
146
|
+
"dataconnect_postgres": 18,
|
|
147
|
+
"tasks": 19}
|
|
148
|
+
METRO_OFFSET = 11 # Metro/Expo dev-server port in the block
|
|
149
|
+
CALLER_OFFSETS = (12, 13, 14) # never bound by a suite; the acquire caller's own
|
|
150
|
+
# What firebase-tools starts for a product named at the top level of
|
|
151
|
+
# firebase.json OR under `emulators` (its filterEmulatorTargets), and what it
|
|
152
|
+
# starts whether or not the file names it. eventarc/tasks are service emulators
|
|
153
|
+
# to the CLI too, but bound only for a functions backend — so they're implicit.
|
|
154
|
+
SERVICE_EMULATORS = ("auth", "firestore", "functions", "storage", "pubsub",
|
|
155
|
+
"database", "hosting", "apphosting", "dataconnect")
|
|
156
|
+
IMPLICIT_EMULATORS = ("hub", "ui", "logging", "eventarc", "tasks")
|
|
157
|
+
# The second port an emulator binds: (emulator, its config key) -> offset name.
|
|
158
|
+
SECOND_PORTS = {("firestore", "websocketPort"): "firestore_websocket",
|
|
159
|
+
("dataconnect", "postgresPort"): "dataconnect_postgres"}
|
|
160
|
+
# Keys under `emulators` that are settings, not emulators with a port of their
|
|
161
|
+
# own (`extensions` runs inside the functions emulator).
|
|
162
|
+
NOT_EMULATORS = ("singleProjectMode", "extensions")
|
|
163
|
+
# Android emulator console ports (2026-10-21). An emulator instance binds an
|
|
164
|
+
# even console port and adb = console + 1; adb discovers emulators only on
|
|
165
|
+
# 5554..5584 (sixteen instances) unless ADB_LOCAL_TRANSPORT_MAX_PORT is raised,
|
|
166
|
+
# and the emulator accepts -port up to 5682. A lease taken with --android gets
|
|
167
|
+
# one pair from this range, leased like a block: unique among live leases,
|
|
168
|
+
# both ports free when handed out, released with the lease.
|
|
169
|
+
ANDROID_CONSOLE_FIRST = int(os.environ.get("FBPORTS_ANDROID_CONSOLE_FIRST", "5554"))
|
|
170
|
+
ANDROID_CONSOLE_LAST = int(os.environ.get("FBPORTS_ANDROID_CONSOLE_LAST", "5584"))
|
|
114
171
|
DEFAULT_SIM_DEVICE = os.environ.get("FBPORTS_SIM_DEVICE", "iPhone 16")
|
|
115
172
|
DEFAULT_SIM_RUNTIME = os.environ.get("FBPORTS_SIM_RUNTIME",
|
|
116
173
|
"com.apple.CoreSimulator.SimRuntime.iOS-18-2")
|
|
@@ -358,7 +415,8 @@ def _block_ports(base):
|
|
|
358
415
|
|
|
359
416
|
|
|
360
417
|
def _service_ports(ports):
|
|
361
|
-
return {s: p for s, p in ports.items()
|
|
418
|
+
return {s: p for s, p in ports.items()
|
|
419
|
+
if s not in IMPLICIT_EMULATORS and s not in ("metro", "android_console", "android_adb")}
|
|
362
420
|
|
|
363
421
|
|
|
364
422
|
# A value that names a LEASED port: `{port:firestore}` becomes this lease's
|
|
@@ -370,14 +428,35 @@ def _service_ports(ports):
|
|
|
370
428
|
# unknown service is left as written, so a typo shows in the app's own error.
|
|
371
429
|
_PORT_REF = re.compile(r"\{port:([a-z_]+)\}")
|
|
372
430
|
|
|
431
|
+
def _android_env(ports):
|
|
432
|
+
"""What a caller (and a --spawn'd process) is told about a leased Android
|
|
433
|
+
pair: boot with `emulator -port $ANDROID_CONSOLE_PORT`, talk to it as
|
|
434
|
+
$ANDROID_SERIAL (adb's name for the instance; adb itself honours it)."""
|
|
435
|
+
c = ports["android_console"]
|
|
436
|
+
return {"ANDROID_CONSOLE_PORT": str(c), "ANDROID_ADB_PORT": str(ports["android_adb"]),
|
|
437
|
+
"ANDROID_SERIAL": f"emulator-{c}"}
|
|
438
|
+
|
|
439
|
+
|
|
373
440
|
def _with_ports(env, ports):
|
|
374
441
|
return {k: _PORT_REF.sub(lambda m: str(ports[m.group(1)]) if m.group(1) in ports else m.group(0), v)
|
|
375
442
|
for k, v in env.items()}
|
|
376
443
|
|
|
377
444
|
|
|
445
|
+
def _emulator_targets(cfg):
|
|
446
|
+
"""The service emulators firebase-tools would start for this config with no
|
|
447
|
+
--only: a product named at the top level or under `emulators`."""
|
|
448
|
+
emu = cfg.get("emulators") or {}
|
|
449
|
+
return [s for s in SERVICE_EMULATORS if s in cfg or s in emu]
|
|
450
|
+
|
|
451
|
+
|
|
378
452
|
def _write_injected_config(cwd, ports, only=None):
|
|
379
|
-
"""
|
|
380
|
-
|
|
453
|
+
"""Put EVERY port the CLI may bind for this config on the block: each
|
|
454
|
+
emulator the config starts (a product at the top level or under
|
|
455
|
+
`emulators`), the ones the CLI starts unasked (IMPLICIT_EMULATORS), and the
|
|
456
|
+
second ports (SECOND_PORTS). Never adds a service emulator the project
|
|
457
|
+
doesn't use — an `emulators.<x>` entry makes the CLI start <x>. A declared
|
|
458
|
+
emulator with no offset is refused by name (ValueError): no lease, no port.
|
|
459
|
+
Returns the temp config basename.
|
|
381
460
|
|
|
382
461
|
With an --only that leaves hosting out, the `hosting` block goes too: no
|
|
383
462
|
emulator a lease starts serves it, and firebase-tools still READS it. A
|
|
@@ -390,17 +469,23 @@ def _write_injected_config(cwd, ports, only=None):
|
|
|
390
469
|
if only and "hosting" not in only:
|
|
391
470
|
cfg.pop("hosting", None)
|
|
392
471
|
emu.pop("hosting", None)
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
472
|
+
known = set(SERVICE_EMULATORS) | set(IMPLICIT_EMULATORS) | set(NOT_EMULATORS)
|
|
473
|
+
unknown = [k for k, v in emu.items() if k not in known and isinstance(v, dict)
|
|
474
|
+
and (not only or k in only)]
|
|
475
|
+
if unknown:
|
|
476
|
+
raise ValueError(f"firebase.json declares emulator(s) harbormaster has no port for: "
|
|
477
|
+
f"{', '.join(unknown)} — add them to BLOCK_OFFSETS")
|
|
478
|
+
if "ui" in emu and isinstance(emu["ui"], dict):
|
|
479
|
+
emu["ui"]["enabled"] = True # declared: wanted on (as before 0.15.0)
|
|
480
|
+
for svc in [*_emulator_targets(cfg), *IMPLICIT_EMULATORS]:
|
|
481
|
+
if not isinstance(emu.get(svc), dict):
|
|
482
|
+
emu[svc] = {}
|
|
483
|
+
emu[svc]["port"] = ports[svc]
|
|
484
|
+
for (svc, key), name in SECOND_PORTS.items():
|
|
485
|
+
# Only on an emulator that starts: a key under emulators.<svc> would
|
|
486
|
+
# otherwise make the CLI start <svc>.
|
|
487
|
+
if svc in emu:
|
|
488
|
+
emu[svc][key] = ports[name]
|
|
404
489
|
emu["singleProjectMode"] = True
|
|
405
490
|
tmp = f".firebase.brokered-{ports['firestore']}.json"
|
|
406
491
|
(Path(cwd) / tmp).write_text(json.dumps(cfg, indent=2))
|
|
@@ -797,6 +882,26 @@ class Broker:
|
|
|
797
882
|
return base
|
|
798
883
|
raise RuntimeError("emulator port pool exhausted")
|
|
799
884
|
|
|
885
|
+
def _free_android_console(self):
|
|
886
|
+
"""An even console port in ANDROID_CONSOLE_FIRST..LAST whose pair
|
|
887
|
+
(console, console + 1 = adb) no live lease holds and nothing listens
|
|
888
|
+
on. Called under self.lock, so two callers never draw one pair; the
|
|
889
|
+
listen check keeps a hand-booted emulator's pair out of it."""
|
|
890
|
+
used = {l["ports"].get("android_console") for l in self.leases.values()
|
|
891
|
+
if isinstance(l.get("ports"), dict)}
|
|
892
|
+
first = ANDROID_CONSOLE_FIRST + (ANDROID_CONSOLE_FIRST % 2) # consoles are even
|
|
893
|
+
for console in range(first, ANDROID_CONSOLE_LAST + 1, 2):
|
|
894
|
+
if console in used:
|
|
895
|
+
continue
|
|
896
|
+
if not _port_open(console) and not _port_open(console + 1):
|
|
897
|
+
return console
|
|
898
|
+
raise RuntimeError(f"android console pool exhausted ({first}-{ANDROID_CONSOLE_LAST}: "
|
|
899
|
+
f"every pair is leased or listening)")
|
|
900
|
+
|
|
901
|
+
def _lease_android(self, ports):
|
|
902
|
+
console = self._free_android_console()
|
|
903
|
+
ports["android_console"], ports["android_adb"] = console, console + 1
|
|
904
|
+
|
|
800
905
|
def _boot(self, cwd, block, only, project_id, label, on_spawn=None, extra_env=None):
|
|
801
906
|
all_ports = _block_ports(block)
|
|
802
907
|
tmp = _write_injected_config(cwd, all_ports, only)
|
|
@@ -805,7 +910,12 @@ class Broker:
|
|
|
805
910
|
# we'd wait forever for ports (functions, pubsub, …) that never open.
|
|
806
911
|
declared = [k for k in json.loads((Path(cwd) / "firebase.json").read_text()).get("emulators", {}) if k != "singleProjectMode"]
|
|
807
912
|
run_ports = {s: all_ports[s] for s in (only or declared) if s in all_ports}
|
|
808
|
-
|
|
913
|
+
# Not waited on: the ones the CLI starts on its own terms (eventarc and
|
|
914
|
+
# tasks only for a functions backend), and hosting with no hosting
|
|
915
|
+
# config, which the CLI skips with a warning.
|
|
916
|
+
has_hosting = "hosting" in json.loads((Path(cwd) / tmp).read_text())
|
|
917
|
+
check = {s: p for s, p in run_ports.items()
|
|
918
|
+
if s not in IMPLICIT_EMULATORS and (s != "hosting" or has_hosting)}
|
|
809
919
|
log = STATE_DIR / f"{label}.log"
|
|
810
920
|
# Each suite gets its OWN temp dir. firebase-tools keeps the Storage
|
|
811
921
|
# emulator's blobs at `${os.tmpdir()}/firebase/storage/blobs` — one
|
|
@@ -934,7 +1044,9 @@ class Broker:
|
|
|
934
1044
|
actions.append(f"tree sweep killed {sorted(killed)}")
|
|
935
1045
|
# Port sweep: every port the lease covers. rec["ports"] catches legacy
|
|
936
1046
|
# records; the block range catches everything else (metro, websocket).
|
|
937
|
-
|
|
1047
|
+
android = {v for k, v in (rec.get("ports") or {}).items()
|
|
1048
|
+
if k in ("android_console", "android_adb") and isinstance(v, int)}
|
|
1049
|
+
ports = {v for v in (rec.get("ports") or {}).values() if isinstance(v, int)} - android
|
|
938
1050
|
if rec.get("block"):
|
|
939
1051
|
ports |= {rec["block"] + off for off in range(BLOCK)}
|
|
940
1052
|
if ports:
|
|
@@ -947,6 +1059,16 @@ class Broker:
|
|
|
947
1059
|
if port in ports and pids)
|
|
948
1060
|
if still:
|
|
949
1061
|
actions.append(f"ports STILL HELD after sweep: {still}")
|
|
1062
|
+
# The Android pair is OUTSIDE the pool, where a hand-booted emulator
|
|
1063
|
+
# lands by default (5554). So it is not port-swept: what the lease
|
|
1064
|
+
# spawned died with its group and recorded tree above; anything still
|
|
1065
|
+
# on the pair is reported, never signalled (the world sweep evicts it
|
|
1066
|
+
# only if it is provably ours).
|
|
1067
|
+
if android:
|
|
1068
|
+
held = {port: pids for port, pids in _listeners().items() if port in android and pids}
|
|
1069
|
+
if held:
|
|
1070
|
+
actions.append(f"android ports still held (not signalled): "
|
|
1071
|
+
+ ", ".join(f"{p} by {sorted(v)}" for p, v in sorted(held.items())))
|
|
950
1072
|
tmp = rec.get("tmp_config")
|
|
951
1073
|
if tmp and rec.get("cwd") and (Path(rec["cwd"]) / tmp).exists():
|
|
952
1074
|
(Path(rec["cwd"]) / tmp).unlink()
|
|
@@ -963,11 +1085,15 @@ class Broker:
|
|
|
963
1085
|
print(f"teardown {lid}: " + "; ".join(actions), flush=True)
|
|
964
1086
|
|
|
965
1087
|
def acquire(self, cwd, owner_pid, owner_start=None, only=None, ttl=DEFAULT_TTL,
|
|
966
|
-
metro=False, sim=False, sim_device=None, sim_runtime=None, env=None
|
|
1088
|
+
metro=False, sim=False, sim_device=None, sim_runtime=None, env=None,
|
|
1089
|
+
android=False):
|
|
967
1090
|
with self.lock:
|
|
968
1091
|
base = self._free_block()
|
|
969
1092
|
lid = f"run:{uuid.uuid4().hex[:12]}"
|
|
970
1093
|
project_id = _firebaserc_project(cwd)
|
|
1094
|
+
# Drawn before the boot, so an exhausted range costs no suite; the
|
|
1095
|
+
# lock is held throughout, so nobody else can draw it meanwhile.
|
|
1096
|
+
android_console = self._free_android_console() if android else None
|
|
971
1097
|
|
|
972
1098
|
def on_spawn(spawn_pid, spawn_tmp, spawn_log):
|
|
973
1099
|
# Provisional record, saved before the readiness wait: a daemon
|
|
@@ -998,6 +1124,8 @@ class Broker:
|
|
|
998
1124
|
# collision-free by construction and needs no health check.
|
|
999
1125
|
if metro or sim:
|
|
1000
1126
|
ports["metro"] = base + METRO_OFFSET
|
|
1127
|
+
if android_console:
|
|
1128
|
+
ports["android_console"], ports["android_adb"] = android_console, android_console + 1
|
|
1001
1129
|
sim_udid = sim_name = None
|
|
1002
1130
|
if sim:
|
|
1003
1131
|
sim_name = lid.replace(":", "-")
|
|
@@ -1025,7 +1153,7 @@ class Broker:
|
|
|
1025
1153
|
|
|
1026
1154
|
def allocate(self, owner_pid, owner_start=None, ttl=DEFAULT_TTL, label=None,
|
|
1027
1155
|
metro=False, sim=False, sim_device=None, sim_runtime=None,
|
|
1028
|
-
spawn=None, spawn_cwd=None):
|
|
1156
|
+
spawn=None, spawn_cwd=None, android=False):
|
|
1029
1157
|
with self.lock:
|
|
1030
1158
|
base = self._free_block()
|
|
1031
1159
|
lid = f"alloc:{uuid.uuid4().hex[:12]}"
|
|
@@ -1037,6 +1165,10 @@ class Broker:
|
|
|
1037
1165
|
# owns just the port + sim.
|
|
1038
1166
|
if metro or sim:
|
|
1039
1167
|
ports["metro"] = base + METRO_OFFSET
|
|
1168
|
+
# --android: an emulator console/adb pair, outside the pool (adb
|
|
1169
|
+
# only discovers 5554..5584) but leased the same way.
|
|
1170
|
+
if android:
|
|
1171
|
+
self._lease_android(ports)
|
|
1040
1172
|
sim_udid = sim_name = None
|
|
1041
1173
|
if sim:
|
|
1042
1174
|
sim_name = lid.replace(":", "-")
|
|
@@ -1054,6 +1186,8 @@ class Broker:
|
|
|
1054
1186
|
"FBPORTS_LEASE_ID": lid}
|
|
1055
1187
|
if ports.get("metro"):
|
|
1056
1188
|
env["METRO_PORT"] = str(ports["metro"])
|
|
1189
|
+
if ports.get("android_console"):
|
|
1190
|
+
env.update(_android_env(ports))
|
|
1057
1191
|
if sim_udid:
|
|
1058
1192
|
env["DETOX_UDID"] = sim_udid
|
|
1059
1193
|
env["DETOX_SIM_NAME"] = sim_name
|
|
@@ -1069,8 +1203,9 @@ class Broker:
|
|
|
1069
1203
|
if sim_udid:
|
|
1070
1204
|
_sim_teardown(sim_udid)
|
|
1071
1205
|
raise
|
|
1206
|
+
android_note = f" android {ports['android_console']}" if android else ""
|
|
1072
1207
|
print(f"leased {lid} ({label or 'no label'}): base {base}"
|
|
1073
|
-
f"{f' sim {sim_name}' if sim_udid else ''}"
|
|
1208
|
+
f"{f' sim {sim_name}' if sim_udid else ''}{android_note}"
|
|
1074
1209
|
f"{f' spawned pid {pid}' if pid else ''}", flush=True)
|
|
1075
1210
|
self.leases[lid] = {"leaseId": lid, "kind": "reserved", "cwd": spawn_cwd,
|
|
1076
1211
|
"block": base, "ports": ports, "pid": pid,
|
|
@@ -1242,9 +1377,14 @@ class Broker:
|
|
|
1242
1377
|
for l in self.leases.values():
|
|
1243
1378
|
if l.get("block"):
|
|
1244
1379
|
leased_ports |= {l["block"] + off for off in range(BLOCK)}
|
|
1380
|
+
for l in self.leases.values():
|
|
1381
|
+
for k in ("android_console", "android_adb"):
|
|
1382
|
+
if isinstance((l.get("ports") or {}).get(k), int):
|
|
1383
|
+
leased_ports.add(l["ports"][k])
|
|
1245
1384
|
pool_end = POOL_BASE + POOL_BLOCKS * BLOCK
|
|
1246
1385
|
for port, pids in _listeners().items():
|
|
1247
|
-
|
|
1386
|
+
in_android = ANDROID_CONSOLE_FIRST <= port <= ANDROID_CONSOLE_LAST + 1
|
|
1387
|
+
if (POOL_BASE <= port < pool_end or in_android) and port not in leased_ports:
|
|
1248
1388
|
for pid in pids:
|
|
1249
1389
|
if pid not in lease_pids:
|
|
1250
1390
|
cmd = _proc_field(pid, "command=")[:200]
|
|
@@ -1428,13 +1568,14 @@ class _Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1428
1568
|
d.get("only"), d.get("ttl", DEFAULT_TTL),
|
|
1429
1569
|
d.get("metro", False), d.get("sim", False),
|
|
1430
1570
|
d.get("sim_device"), d.get("sim_runtime"),
|
|
1431
|
-
d.get("env")))
|
|
1571
|
+
d.get("env"), d.get("android", False)))
|
|
1432
1572
|
if self.path == "/allocate":
|
|
1433
1573
|
return self._send(200, b.allocate(d.get("owner_pid"), d.get("owner_start"),
|
|
1434
1574
|
d.get("ttl", DEFAULT_TTL), d.get("label"),
|
|
1435
1575
|
d.get("metro", False), d.get("sim", False),
|
|
1436
1576
|
d.get("sim_device"), d.get("sim_runtime"),
|
|
1437
|
-
d.get("spawn"), d.get("spawn_cwd")
|
|
1577
|
+
d.get("spawn"), d.get("spawn_cwd"),
|
|
1578
|
+
d.get("android", False)))
|
|
1438
1579
|
if self.path == "/release":
|
|
1439
1580
|
return self._send(200, b.release(d.get("leaseId")))
|
|
1440
1581
|
if self.path == "/release-owned":
|
|
@@ -1726,9 +1867,14 @@ def _print(out, fmt):
|
|
|
1726
1867
|
if svc == "metro":
|
|
1727
1868
|
print(f"export METRO_PORT={port}") # Expo/Metro dev server + Detox bundle URL
|
|
1728
1869
|
continue
|
|
1870
|
+
if svc in ("android_console", "android_adb"):
|
|
1871
|
+
continue # below, as ANDROID_*
|
|
1729
1872
|
if svc in std:
|
|
1730
1873
|
print(f"export {std[svc]}=127.0.0.1:{port}")
|
|
1731
1874
|
print(f"export EMULATOR_{svc.upper()}_PORT={port}")
|
|
1875
|
+
if ports.get("android_console"):
|
|
1876
|
+
for k, v in _android_env(ports).items():
|
|
1877
|
+
print(f"export {k}={v}")
|
|
1732
1878
|
if sim:
|
|
1733
1879
|
print(f"export DETOX_UDID={sim['udid']}") # Detox targets this simulator by UDID
|
|
1734
1880
|
print(f"export DETOX_SIM_NAME={sim['name']}")
|
|
@@ -1800,7 +1946,8 @@ def main():
|
|
|
1800
1946
|
"metro": "metro" in flags, "sim": "sim" in flags,
|
|
1801
1947
|
"sim_device": opts.get("sim-device"),
|
|
1802
1948
|
"sim_runtime": opts.get("sim-runtime"),
|
|
1803
|
-
"env": dict(kv.split("=", 1) for kv in opts.get("fn-env", []))
|
|
1949
|
+
"env": dict(kv.split("=", 1) for kv in opts.get("fn-env", [])),
|
|
1950
|
+
"android": "android" in flags}), "acquire"), fmt)
|
|
1804
1951
|
elif cmd == "down":
|
|
1805
1952
|
if _up():
|
|
1806
1953
|
out = _call("POST", "/release-owned", {"owner_pid": os.getppid(), "cwd": _resolve_cwd(opts)})[1]
|
|
@@ -1829,7 +1976,8 @@ def main():
|
|
|
1829
1976
|
"sim_device": opts.get("sim-device"),
|
|
1830
1977
|
"sim_runtime": opts.get("sim-runtime"),
|
|
1831
1978
|
"spawn": spawn,
|
|
1832
|
-
"spawn_cwd": str(Path(opts["cwd"]).resolve()) if opts.get("cwd") else None
|
|
1979
|
+
"spawn_cwd": str(Path(opts["cwd"]).resolve()) if opts.get("cwd") else None,
|
|
1980
|
+
"android": "android" in flags}),
|
|
1833
1981
|
"allocate")
|
|
1834
1982
|
if fmt == "env":
|
|
1835
1983
|
# Reserved block, nothing booted — emit only the dev-env extras (plus
|
|
@@ -1838,6 +1986,9 @@ def main():
|
|
|
1838
1986
|
print(f"export FBPORTS_LEASE_ID={out['leaseId']}")
|
|
1839
1987
|
if out.get("ports", {}).get("metro"):
|
|
1840
1988
|
print(f"export METRO_PORT={out['ports']['metro']}")
|
|
1989
|
+
if out.get("ports", {}).get("android_console"):
|
|
1990
|
+
for k, v in _android_env(out["ports"]).items():
|
|
1991
|
+
print(f"export {k}={v}")
|
|
1841
1992
|
if out.get("sim"):
|
|
1842
1993
|
print(f"export DETOX_UDID={out['sim']['udid']}")
|
|
1843
1994
|
print(f"export DETOX_SIM_NAME={out['sim']['name']}")
|
|
@@ -1848,6 +1999,8 @@ def main():
|
|
|
1848
1999
|
else:
|
|
1849
2000
|
extra = f" metro {out['ports']['metro']}" if out.get("ports", {}).get("metro") else ""
|
|
1850
2001
|
extra += f" sim {out['sim']['udid']}" if out.get("sim") else ""
|
|
2002
|
+
extra += (f" android {out['ports']['android_console']}/{out['ports']['android_adb']}"
|
|
2003
|
+
if out.get("ports", {}).get("android_console") else "")
|
|
1851
2004
|
extra += f" spawned pid {out['spawnedPid']}" if out.get("spawnedPid") else ""
|
|
1852
2005
|
print(f"{out['leaseId']} base {out['base']}{extra}")
|
|
1853
2006
|
elif cmd == "exec":
|
|
@@ -1865,7 +2018,12 @@ def main():
|
|
|
1865
2018
|
me = os.getpid() # this process owns the reservation for the run
|
|
1866
2019
|
res = ok(_call("POST", "/allocate", {"owner_pid": me, "owner_start": _proc_start(me), "label": "exec"}), "exec")
|
|
1867
2020
|
base, lid = res["base"], res["leaseId"]
|
|
1868
|
-
|
|
2021
|
+
try:
|
|
2022
|
+
tmp = _write_injected_config(cwd, _block_ports(base), only)
|
|
2023
|
+
except ValueError as e:
|
|
2024
|
+
_call("POST", "/release", {"leaseId": lid})
|
|
2025
|
+
print(f"exec failed: {e}", file=sys.stderr)
|
|
2026
|
+
sys.exit(1)
|
|
1869
2027
|
try:
|
|
1870
2028
|
run = ["firebase", "emulators:exec", "--config", tmp]
|
|
1871
2029
|
if only:
|
|
@@ -1955,6 +2113,8 @@ def main():
|
|
|
1955
2113
|
where += f" metro:{l['ports']['metro']}"
|
|
1956
2114
|
if l.get("sim"):
|
|
1957
2115
|
where += f" sim:{l['sim'][:8]}"
|
|
2116
|
+
if l["ports"].get("android_console"):
|
|
2117
|
+
where += f" android:{l['ports']['android_console']}"
|
|
1958
2118
|
print(f"{l['leaseId']:<22} {l['kind']:<9} owner:{str(l.get('ownerPid') or '-'):<7} "
|
|
1959
2119
|
f"{'up' if l['up'] else '-':<3} {where}{ttl} "
|
|
1960
2120
|
f"{l.get('label') or ''} {l.get('cwd') or ''}".rstrip())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/harbormaster",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "A localhost daemon that leases each caller its own port block — isolated Firebase emulator sandboxes, Metro/Expo ports, private iOS simulators — reaped when the caller exits. No port collisions, no shared instances, no pkill.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"harbormaster": "harbormaster",
|