@simon-says-build/harbormaster 0.14.1 → 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 +56 -2
- package/harbormaster +210 -36
- 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
|
|
|
@@ -155,6 +156,59 @@ env, under the caller's explicit `--fn-env`. `exec`: on top of the caller's
|
|
|
155
156
|
ambient env, so a key exported in some shell can't leak into the suite.
|
|
156
157
|
`python3 test/project_env.py`.
|
|
157
158
|
|
|
159
|
+
## A function env value can name a leased port (0.14.2)
|
|
160
|
+
|
|
161
|
+
`--fn-env KEY={port:firestore}`, or the same in `harbormaster.env`, becomes
|
|
162
|
+
this lease's firestore port, inside a value too (`127.0.0.1:{port:auth}`). A
|
|
163
|
+
caller can't know its ports before the lease, and an app whose functions read
|
|
164
|
+
their own variable for the emulator (Tapuat's `EMULATOR_PORT_FIRESTORE ||
|
|
165
|
+
"8080"`) otherwise talks to whatever holds the default. An unknown service is
|
|
166
|
+
left as written. `python3 test/port_env.py`.
|
|
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
|
+
|
|
158
212
|
## A lease never reads the hosting config it doesn't start (0.14.1)
|
|
159
213
|
|
|
160
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,12 +415,48 @@ 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")}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# A value that names a LEASED port: `{port:firestore}` becomes this lease's
|
|
423
|
+
# firestore port. The caller cannot know its ports before the lease, and an
|
|
424
|
+
# app whose functions read their own variable for the emulator (Tapuat's
|
|
425
|
+
# admin.ts: `127.0.0.1:${EMULATOR_PORT_FIRESTORE || "8080"}`, unconditionally)
|
|
426
|
+
# otherwise talks to whatever holds the default — on simon-linux-1, nothing;
|
|
427
|
+
# every callable 500'd (run 20261021-131438-exploration-everyday-user). An
|
|
428
|
+
# unknown service is left as written, so a typo shows in the app's own error.
|
|
429
|
+
_PORT_REF = re.compile(r"\{port:([a-z_]+)\}")
|
|
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
|
+
|
|
440
|
+
def _with_ports(env, ports):
|
|
441
|
+
return {k: _PORT_REF.sub(lambda m: str(ports[m.group(1)]) if m.group(1) in ports else m.group(0), v)
|
|
442
|
+
for k, v in env.items()}
|
|
443
|
+
|
|
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]
|
|
362
450
|
|
|
363
451
|
|
|
364
452
|
def _write_injected_config(cwd, ports, only=None):
|
|
365
|
-
"""
|
|
366
|
-
|
|
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.
|
|
367
460
|
|
|
368
461
|
With an --only that leaves hosting out, the `hosting` block goes too: no
|
|
369
462
|
emulator a lease starts serves it, and firebase-tools still READS it. A
|
|
@@ -376,17 +469,23 @@ def _write_injected_config(cwd, ports, only=None):
|
|
|
376
469
|
if only and "hosting" not in only:
|
|
377
470
|
cfg.pop("hosting", None)
|
|
378
471
|
emu.pop("hosting", None)
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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]
|
|
390
489
|
emu["singleProjectMode"] = True
|
|
391
490
|
tmp = f".firebase.brokered-{ports['firestore']}.json"
|
|
392
491
|
(Path(cwd) / tmp).write_text(json.dumps(cfg, indent=2))
|
|
@@ -783,6 +882,26 @@ class Broker:
|
|
|
783
882
|
return base
|
|
784
883
|
raise RuntimeError("emulator port pool exhausted")
|
|
785
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
|
+
|
|
786
905
|
def _boot(self, cwd, block, only, project_id, label, on_spawn=None, extra_env=None):
|
|
787
906
|
all_ports = _block_ports(block)
|
|
788
907
|
tmp = _write_injected_config(cwd, all_ports, only)
|
|
@@ -791,7 +910,12 @@ class Broker:
|
|
|
791
910
|
# we'd wait forever for ports (functions, pubsub, …) that never open.
|
|
792
911
|
declared = [k for k in json.loads((Path(cwd) / "firebase.json").read_text()).get("emulators", {}) if k != "singleProjectMode"]
|
|
793
912
|
run_ports = {s: all_ports[s] for s in (only or declared) if s in all_ports}
|
|
794
|
-
|
|
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)}
|
|
795
919
|
log = STATE_DIR / f"{label}.log"
|
|
796
920
|
# Each suite gets its OWN temp dir. firebase-tools keeps the Storage
|
|
797
921
|
# emulator's blobs at `${os.tmpdir()}/firebase/storage/blobs` — one
|
|
@@ -814,9 +938,9 @@ class Broker:
|
|
|
814
938
|
# daemon's env — the functions emulator runtime inherits it, which is
|
|
815
939
|
# the only way a CALLER can set per-suite env (e.g. LLM_CACHE_DIR)
|
|
816
940
|
# when the daemon, not the caller, spawns the emulator.
|
|
817
|
-
spawn_env = {**os.environ, **project_env(cwd), "TMPDIR": str(suite_tmp)}
|
|
941
|
+
spawn_env = {**os.environ, **_with_ports(project_env(cwd), all_ports), "TMPDIR": str(suite_tmp)}
|
|
818
942
|
if extra_env:
|
|
819
|
-
spawn_env.update({str(k): str(v) for k, v in dict(extra_env).items()})
|
|
943
|
+
spawn_env.update(_with_ports({str(k): str(v) for k, v in dict(extra_env).items()}, all_ports))
|
|
820
944
|
with open(log, "w") as lf:
|
|
821
945
|
proc = subprocess.Popen(cmd, cwd=cwd, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True, env=spawn_env)
|
|
822
946
|
if on_spawn:
|
|
@@ -920,7 +1044,9 @@ class Broker:
|
|
|
920
1044
|
actions.append(f"tree sweep killed {sorted(killed)}")
|
|
921
1045
|
# Port sweep: every port the lease covers. rec["ports"] catches legacy
|
|
922
1046
|
# records; the block range catches everything else (metro, websocket).
|
|
923
|
-
|
|
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
|
|
924
1050
|
if rec.get("block"):
|
|
925
1051
|
ports |= {rec["block"] + off for off in range(BLOCK)}
|
|
926
1052
|
if ports:
|
|
@@ -933,6 +1059,16 @@ class Broker:
|
|
|
933
1059
|
if port in ports and pids)
|
|
934
1060
|
if still:
|
|
935
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())))
|
|
936
1072
|
tmp = rec.get("tmp_config")
|
|
937
1073
|
if tmp and rec.get("cwd") and (Path(rec["cwd"]) / tmp).exists():
|
|
938
1074
|
(Path(rec["cwd"]) / tmp).unlink()
|
|
@@ -949,11 +1085,15 @@ class Broker:
|
|
|
949
1085
|
print(f"teardown {lid}: " + "; ".join(actions), flush=True)
|
|
950
1086
|
|
|
951
1087
|
def acquire(self, cwd, owner_pid, owner_start=None, only=None, ttl=DEFAULT_TTL,
|
|
952
|
-
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):
|
|
953
1090
|
with self.lock:
|
|
954
1091
|
base = self._free_block()
|
|
955
1092
|
lid = f"run:{uuid.uuid4().hex[:12]}"
|
|
956
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
|
|
957
1097
|
|
|
958
1098
|
def on_spawn(spawn_pid, spawn_tmp, spawn_log):
|
|
959
1099
|
# Provisional record, saved before the readiness wait: a daemon
|
|
@@ -984,6 +1124,8 @@ class Broker:
|
|
|
984
1124
|
# collision-free by construction and needs no health check.
|
|
985
1125
|
if metro or sim:
|
|
986
1126
|
ports["metro"] = base + METRO_OFFSET
|
|
1127
|
+
if android_console:
|
|
1128
|
+
ports["android_console"], ports["android_adb"] = android_console, android_console + 1
|
|
987
1129
|
sim_udid = sim_name = None
|
|
988
1130
|
if sim:
|
|
989
1131
|
sim_name = lid.replace(":", "-")
|
|
@@ -1011,7 +1153,7 @@ class Broker:
|
|
|
1011
1153
|
|
|
1012
1154
|
def allocate(self, owner_pid, owner_start=None, ttl=DEFAULT_TTL, label=None,
|
|
1013
1155
|
metro=False, sim=False, sim_device=None, sim_runtime=None,
|
|
1014
|
-
spawn=None, spawn_cwd=None):
|
|
1156
|
+
spawn=None, spawn_cwd=None, android=False):
|
|
1015
1157
|
with self.lock:
|
|
1016
1158
|
base = self._free_block()
|
|
1017
1159
|
lid = f"alloc:{uuid.uuid4().hex[:12]}"
|
|
@@ -1023,6 +1165,10 @@ class Broker:
|
|
|
1023
1165
|
# owns just the port + sim.
|
|
1024
1166
|
if metro or sim:
|
|
1025
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)
|
|
1026
1172
|
sim_udid = sim_name = None
|
|
1027
1173
|
if sim:
|
|
1028
1174
|
sim_name = lid.replace(":", "-")
|
|
@@ -1040,6 +1186,8 @@ class Broker:
|
|
|
1040
1186
|
"FBPORTS_LEASE_ID": lid}
|
|
1041
1187
|
if ports.get("metro"):
|
|
1042
1188
|
env["METRO_PORT"] = str(ports["metro"])
|
|
1189
|
+
if ports.get("android_console"):
|
|
1190
|
+
env.update(_android_env(ports))
|
|
1043
1191
|
if sim_udid:
|
|
1044
1192
|
env["DETOX_UDID"] = sim_udid
|
|
1045
1193
|
env["DETOX_SIM_NAME"] = sim_name
|
|
@@ -1055,8 +1203,9 @@ class Broker:
|
|
|
1055
1203
|
if sim_udid:
|
|
1056
1204
|
_sim_teardown(sim_udid)
|
|
1057
1205
|
raise
|
|
1206
|
+
android_note = f" android {ports['android_console']}" if android else ""
|
|
1058
1207
|
print(f"leased {lid} ({label or 'no label'}): base {base}"
|
|
1059
|
-
f"{f' sim {sim_name}' if sim_udid else ''}"
|
|
1208
|
+
f"{f' sim {sim_name}' if sim_udid else ''}{android_note}"
|
|
1060
1209
|
f"{f' spawned pid {pid}' if pid else ''}", flush=True)
|
|
1061
1210
|
self.leases[lid] = {"leaseId": lid, "kind": "reserved", "cwd": spawn_cwd,
|
|
1062
1211
|
"block": base, "ports": ports, "pid": pid,
|
|
@@ -1228,9 +1377,14 @@ class Broker:
|
|
|
1228
1377
|
for l in self.leases.values():
|
|
1229
1378
|
if l.get("block"):
|
|
1230
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])
|
|
1231
1384
|
pool_end = POOL_BASE + POOL_BLOCKS * BLOCK
|
|
1232
1385
|
for port, pids in _listeners().items():
|
|
1233
|
-
|
|
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:
|
|
1234
1388
|
for pid in pids:
|
|
1235
1389
|
if pid not in lease_pids:
|
|
1236
1390
|
cmd = _proc_field(pid, "command=")[:200]
|
|
@@ -1414,13 +1568,14 @@ class _Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1414
1568
|
d.get("only"), d.get("ttl", DEFAULT_TTL),
|
|
1415
1569
|
d.get("metro", False), d.get("sim", False),
|
|
1416
1570
|
d.get("sim_device"), d.get("sim_runtime"),
|
|
1417
|
-
d.get("env")))
|
|
1571
|
+
d.get("env"), d.get("android", False)))
|
|
1418
1572
|
if self.path == "/allocate":
|
|
1419
1573
|
return self._send(200, b.allocate(d.get("owner_pid"), d.get("owner_start"),
|
|
1420
1574
|
d.get("ttl", DEFAULT_TTL), d.get("label"),
|
|
1421
1575
|
d.get("metro", False), d.get("sim", False),
|
|
1422
1576
|
d.get("sim_device"), d.get("sim_runtime"),
|
|
1423
|
-
d.get("spawn"), d.get("spawn_cwd")
|
|
1577
|
+
d.get("spawn"), d.get("spawn_cwd"),
|
|
1578
|
+
d.get("android", False)))
|
|
1424
1579
|
if self.path == "/release":
|
|
1425
1580
|
return self._send(200, b.release(d.get("leaseId")))
|
|
1426
1581
|
if self.path == "/release-owned":
|
|
@@ -1712,9 +1867,14 @@ def _print(out, fmt):
|
|
|
1712
1867
|
if svc == "metro":
|
|
1713
1868
|
print(f"export METRO_PORT={port}") # Expo/Metro dev server + Detox bundle URL
|
|
1714
1869
|
continue
|
|
1870
|
+
if svc in ("android_console", "android_adb"):
|
|
1871
|
+
continue # below, as ANDROID_*
|
|
1715
1872
|
if svc in std:
|
|
1716
1873
|
print(f"export {std[svc]}=127.0.0.1:{port}")
|
|
1717
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}")
|
|
1718
1878
|
if sim:
|
|
1719
1879
|
print(f"export DETOX_UDID={sim['udid']}") # Detox targets this simulator by UDID
|
|
1720
1880
|
print(f"export DETOX_SIM_NAME={sim['name']}")
|
|
@@ -1786,7 +1946,8 @@ def main():
|
|
|
1786
1946
|
"metro": "metro" in flags, "sim": "sim" in flags,
|
|
1787
1947
|
"sim_device": opts.get("sim-device"),
|
|
1788
1948
|
"sim_runtime": opts.get("sim-runtime"),
|
|
1789
|
-
"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)
|
|
1790
1951
|
elif cmd == "down":
|
|
1791
1952
|
if _up():
|
|
1792
1953
|
out = _call("POST", "/release-owned", {"owner_pid": os.getppid(), "cwd": _resolve_cwd(opts)})[1]
|
|
@@ -1815,7 +1976,8 @@ def main():
|
|
|
1815
1976
|
"sim_device": opts.get("sim-device"),
|
|
1816
1977
|
"sim_runtime": opts.get("sim-runtime"),
|
|
1817
1978
|
"spawn": spawn,
|
|
1818
|
-
"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}),
|
|
1819
1981
|
"allocate")
|
|
1820
1982
|
if fmt == "env":
|
|
1821
1983
|
# Reserved block, nothing booted — emit only the dev-env extras (plus
|
|
@@ -1824,6 +1986,9 @@ def main():
|
|
|
1824
1986
|
print(f"export FBPORTS_LEASE_ID={out['leaseId']}")
|
|
1825
1987
|
if out.get("ports", {}).get("metro"):
|
|
1826
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}")
|
|
1827
1992
|
if out.get("sim"):
|
|
1828
1993
|
print(f"export DETOX_UDID={out['sim']['udid']}")
|
|
1829
1994
|
print(f"export DETOX_SIM_NAME={out['sim']['name']}")
|
|
@@ -1834,6 +1999,8 @@ def main():
|
|
|
1834
1999
|
else:
|
|
1835
2000
|
extra = f" metro {out['ports']['metro']}" if out.get("ports", {}).get("metro") else ""
|
|
1836
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 "")
|
|
1837
2004
|
extra += f" spawned pid {out['spawnedPid']}" if out.get("spawnedPid") else ""
|
|
1838
2005
|
print(f"{out['leaseId']} base {out['base']}{extra}")
|
|
1839
2006
|
elif cmd == "exec":
|
|
@@ -1851,7 +2018,12 @@ def main():
|
|
|
1851
2018
|
me = os.getpid() # this process owns the reservation for the run
|
|
1852
2019
|
res = ok(_call("POST", "/allocate", {"owner_pid": me, "owner_start": _proc_start(me), "label": "exec"}), "exec")
|
|
1853
2020
|
base, lid = res["base"], res["leaseId"]
|
|
1854
|
-
|
|
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)
|
|
1855
2027
|
try:
|
|
1856
2028
|
run = ["firebase", "emulators:exec", "--config", tmp]
|
|
1857
2029
|
if only:
|
|
@@ -1860,7 +2032,7 @@ def main():
|
|
|
1860
2032
|
# The project's env beats the caller's AMBIENT env here: a key
|
|
1861
2033
|
# exported in some shell for a one-off script is exactly what
|
|
1862
2034
|
# harbormaster.env exists to keep out of the suite (project_env).
|
|
1863
|
-
rc = subprocess.call(run, cwd=cwd, env={**os.environ, **project_env(cwd)})
|
|
2035
|
+
rc = subprocess.call(run, cwd=cwd, env={**os.environ, **_with_ports(project_env(cwd), _block_ports(base))})
|
|
1864
2036
|
finally:
|
|
1865
2037
|
try:
|
|
1866
2038
|
_call("POST", "/release", {"leaseId": lid})
|
|
@@ -1941,6 +2113,8 @@ def main():
|
|
|
1941
2113
|
where += f" metro:{l['ports']['metro']}"
|
|
1942
2114
|
if l.get("sim"):
|
|
1943
2115
|
where += f" sim:{l['sim'][:8]}"
|
|
2116
|
+
if l["ports"].get("android_console"):
|
|
2117
|
+
where += f" android:{l['ports']['android_console']}"
|
|
1944
2118
|
print(f"{l['leaseId']:<22} {l['kind']:<9} owner:{str(l.get('ownerPid') or '-'):<7} "
|
|
1945
2119
|
f"{'up' if l['up'] else '-':<3} {where}{ttl} "
|
|
1946
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",
|