@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 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, offsets
173
- `auth=+0 firestore=+1 functions=+2 storage=+3 ui=+4 hub=+5 logging=+6 pubsub=+7
174
- eventarc=+8 database=+9` (kept in sync with the run driver's derivation).
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; each firestore suite also gets a
96
- private websocket port, so concurrent suites never collide on the default 9150.
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] [--spawn CMD] [--cwd DIR]
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) for e2e Metro
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 when leased.
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.14.1"
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
- METRO_OFFSET = 11 # Metro/Expo dev-server port in the block (offsets 0-10 are emulators)
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() if s not in ("ui", "hub", "logging")}
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
- """Set ports on the emulators the portless firebase.json DECLARES (+ hub/logging).
366
- Never adds emulators the project doesn't use. Returns the temp config basename.
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
- for svc in [k for k in emu if k != "singleProjectMode"]:
380
- if svc in ports and isinstance(emu[svc], dict):
381
- emu[svc]["port"] = ports[svc]
382
- if svc == "ui":
383
- emu[svc]["enabled"] = True
384
- if svc == "firestore":
385
- # Give firestore's UI websocket a private port too, else concurrent
386
- # suites all default to 9150 and collide (isolated data port isn't enough).
387
- emu[svc]["websocketPort"] = ports["firestore_websocket"]
388
- for svc in ("hub", "logging"):
389
- emu.setdefault(svc, {})["port"] = ports[svc]
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
- check = {s: p for s, p in run_ports.items() if s not in ("ui", "hub", "logging")}
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
- ports = {v for v in (rec.get("ports") or {}).values() if isinstance(v, int)}
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
- if POOL_BASE <= port < pool_end and port not in leased_ports:
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", []))}), "acquire"), fmt)
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
- tmp = _write_injected_config(cwd, _block_ports(base), only)
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.14.1",
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",