@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 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
 
@@ -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] [--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.2"
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,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() 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")}
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
- """Set ports on the emulators the portless firebase.json DECLARES (+ hub/logging).
380
- 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.
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
- for svc in [k for k in emu if k != "singleProjectMode"]:
394
- if svc in ports and isinstance(emu[svc], dict):
395
- emu[svc]["port"] = ports[svc]
396
- if svc == "ui":
397
- emu[svc]["enabled"] = True
398
- if svc == "firestore":
399
- # Give firestore's UI websocket a private port too, else concurrent
400
- # suites all default to 9150 and collide (isolated data port isn't enough).
401
- emu[svc]["websocketPort"] = ports["firestore_websocket"]
402
- for svc in ("hub", "logging"):
403
- 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]
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
- 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)}
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
- 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
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
- 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:
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", []))}), "acquire"), fmt)
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
- 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)
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.14.2",
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",