@simon-says-build/harbormaster 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +146 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/harbormaster +1024 -0
- package/harbormaster.example.json +27 -0
- package/package.json +40 -0
package/DESIGN.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# harbormaster — design (v2: caller-owned sandboxes)
|
|
2
|
+
|
|
3
|
+
Status: **live on `main`.** Caller-owned sandboxes are the shipped model. Validated
|
|
4
|
+
end-to-end: acquire/exec boot + owner-death reap + PID-reuse guard, and concurrent
|
|
5
|
+
same-project suites coexist with isolated data *and* websocket ports (the 9150-collision
|
|
6
|
+
fix). Consumers (impulse + walk2gether CI/test lanes) run against it.
|
|
7
|
+
|
|
8
|
+
## The problem
|
|
9
|
+
|
|
10
|
+
One machine runs emulators for an open-ended set of Firebase projects — dev, CI, and
|
|
11
|
+
the agent-qa runner spinning up *customer* apps on demand. Two things kept biting:
|
|
12
|
+
|
|
13
|
+
1. **Port collisions.** Multiple processes booting `firebase emulators:start` on the
|
|
14
|
+
same ports (a shared per-project port, or the default 8080/4400) fight and kill each
|
|
15
|
+
other. `pkill` "fixes" it by nuking *everyone's* emulators — the exact antipattern a
|
|
16
|
+
coordinator must eliminate.
|
|
17
|
+
2. **Data collisions.** Two processes sharing one project's emulator share its data —
|
|
18
|
+
e.g. a `clearFirestoreData` in one wipes the other, or docs accumulate across test
|
|
19
|
+
cases. Concurrent callers of "the impulse emulator" must NOT share an instance.
|
|
20
|
+
|
|
21
|
+
## The model: caller-owned sandboxes
|
|
22
|
+
|
|
23
|
+
There is **one primitive** — a caller asks for a sandbox and the daemon gives it a
|
|
24
|
+
private one, reclaimed when the caller exits:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
acquire { cwd, owner_pid, owner_start, only? } -> { leaseId, ports, projectId }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **Issuance is unique per caller, not per project.** Two processes that both "want an
|
|
31
|
+
impulse emulator" get two independent suites (own block, own fresh data). No sharing,
|
|
32
|
+
so no collision.
|
|
33
|
+
- **The lease is owned by the caller's process.** The daemon reaps the sandbox — kills
|
|
34
|
+
the emulators, frees the block — when `owner_pid` dies. The caller's death *is* the
|
|
35
|
+
release; there is nothing to remember to release, and never a reason to `pkill`.
|
|
36
|
+
- **No registry.** A "project" is just the `firebase.json` in `cwd`. `project_id` comes
|
|
37
|
+
from `.firebaserc`. Ports come from a pool block. Nothing is hand-authored.
|
|
38
|
+
- **`firebase.json` is portless** — the daemon injects the block's ports into a temp
|
|
39
|
+
config (`.firebase.brokered-*.json`, git-ignored). A bare `firebase emulators:start`
|
|
40
|
+
is intentionally unsupported; everything goes through the daemon.
|
|
41
|
+
- **Discovery is the `acquire` response.** The owner gets its ports back and passes them
|
|
42
|
+
to its app/tests via env (`acquire --env` prints `export FIREBASE_*_EMULATOR_PORT` +
|
|
43
|
+
`GCLOUD_PROJECT`). There is no shared "project ports" lookup, because there is no
|
|
44
|
+
shared instance.
|
|
45
|
+
|
|
46
|
+
## Three flavors
|
|
47
|
+
|
|
48
|
+
- **`acquire` — the daemon boots the suite.** For callers that just want a working
|
|
49
|
+
backend (a test process, an interactive dev shell). Returns the ports.
|
|
50
|
+
- **`allocate` — the daemon reserves a block; the caller boots its own suite.** For the
|
|
51
|
+
agent-qa run driver (`envs/_lib/backends/firebase-emulators.sh`), which has bespoke
|
|
52
|
+
build/wipe/seed logic. Returns a base; the driver derives ports by the block offsets.
|
|
53
|
+
- **`allocate --spawn CMD` — the daemon RUNS the lease's process.** For long-lived
|
|
54
|
+
dev-env processes (Metro for e2e) that must outlive the calling shell — in
|
|
55
|
+
particular agent sessions, whose harness kills any process the agent backgrounds.
|
|
56
|
+
The lease is ownerless (daemon-owned): liveness is the spawned pid (zombie-aware —
|
|
57
|
+
the daemon `waitpid`s its own children, since a zombie passes `kill -0`), the TTL is
|
|
58
|
+
the backstop, and `release <id>` is the off switch. Spawn output goes to the lease
|
|
59
|
+
log in the state dir.
|
|
60
|
+
|
|
61
|
+
`acquire` and plain `allocate` carry an `owner_pid` and are reaped the same way;
|
|
62
|
+
`--spawn`/`--detach` leases default to no owner, because their *immediate* parent
|
|
63
|
+
exits within seconds of the call and would reap the lease with it.
|
|
64
|
+
|
|
65
|
+
That default proved too coarse. "Daemonised" decides who reaps the child; it does
|
|
66
|
+
not mean nobody is interested. `--owner auto` walks up from the CLI past the
|
|
67
|
+
transient shell/npm wrappers and records the first ancestor that outlives the
|
|
68
|
+
call — under an agent harness, the agent process — so the lease dies with that
|
|
69
|
+
session instead of lingering for its full multi-day TTL. It deliberately returns
|
|
70
|
+
*no* owner when the walk reaches a boundary like an editor or a login session,
|
|
71
|
+
rather than pretending a process that lives for days is a meaningful owner.
|
|
72
|
+
|
|
73
|
+
## Daemon version handover
|
|
74
|
+
|
|
75
|
+
Any copy of the CLI (a global install, a repo checkout, some project's
|
|
76
|
+
`node_modules`) may be the first to start the daemon, so the daemon on port 4999 can
|
|
77
|
+
be arbitrarily stale. `/health` reports the daemon version; `_ensure` in a NEWER
|
|
78
|
+
client POSTs `/shutdown` to an older daemon and starts its own copy. This is safe by
|
|
79
|
+
construction: leases live in the ledger on disk, and every leased process (suites,
|
|
80
|
+
spawns) runs in its own session via `start_new_session`, so the successor daemon
|
|
81
|
+
reloads the ledger and carries on — the handover tears nothing down. Older clients
|
|
82
|
+
never downgrade a newer daemon.
|
|
83
|
+
|
|
84
|
+
## Ownership & teardown (coordinated only — never `pkill`)
|
|
85
|
+
|
|
86
|
+
A lease is reclaimed when ANY of:
|
|
87
|
+
|
|
88
|
+
1. **Owner death** — `owner_pid` is gone. This is the primary, expected path.
|
|
89
|
+
2. **PID reuse guard** — "alive" means *same pid AND same start time* (`ps -o lstart`),
|
|
90
|
+
so a recycled PID number doesn't keep a dead owner's sandbox alive.
|
|
91
|
+
3. **Working directory gone** — the lease's `cwd` no longer exists. A deleted
|
|
92
|
+
worktree can never need its Metro again, and this is the one signal that is
|
|
93
|
+
unambiguous for daemon-spawned leases. Only a *confirmed* absence counts: if
|
|
94
|
+
the parent directory is missing too, that reads as an unmounted volume rather
|
|
95
|
+
than a deleted project, and the lease is left alone.
|
|
96
|
+
4. **TTL** — a backstop for a caller that never dies (misconfigured / detached).
|
|
97
|
+
5. **Emulator liveness** — dead suite process → reaped immediately; ports down (with
|
|
98
|
+
the process alive) only reap after a grace window of continuous downtime, for both
|
|
99
|
+
daemon-booted suites and reserved (caller-booted) blocks. A single failed connect
|
|
100
|
+
check on a loaded machine must never kill a healthy suite.
|
|
101
|
+
|
|
102
|
+
Teardown always `killpg`s the whole process group so the emulator's **JVM children are
|
|
103
|
+
reaped** — a v1 bug leaked Firestore/Storage JVMs because only the node parent was
|
|
104
|
+
killed (verify this in v2). The daemon owns lifecycle end-to-end; callers use
|
|
105
|
+
`acquire`/`release`, never `pkill`.
|
|
106
|
+
|
|
107
|
+
## Owning PID is the caller, not the CLI
|
|
108
|
+
|
|
109
|
+
`harbormaster acquire` is ephemeral — it exits the moment it has the response. So the
|
|
110
|
+
CLI defaults `owner_pid` to **its parent** (`os.getppid()` — the calling shell or test
|
|
111
|
+
runner), or the caller passes `--owner PID` explicitly (`$$` in a shell, `process.pid`
|
|
112
|
+
in node). The sandbox lives as long as that owner does.
|
|
113
|
+
|
|
114
|
+
## API
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
POST /acquire {cwd, owner_pid, owner_start, only?, ttl?} -> {leaseId, ports, projectId}
|
|
118
|
+
POST /allocate {owner_pid, owner_start, ttl?, label?} -> {leaseId, base}
|
|
119
|
+
POST /release {leaseId} -> {released}
|
|
120
|
+
POST /heartbeat {leaseId, ttl?} -> {ok} # extend TTL
|
|
121
|
+
GET /status -> {leases:[...]}
|
|
122
|
+
GET /health
|
|
123
|
+
POST /shutdown
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
CLI: `acquire` / `up` (alias) / `down` (release your lease) / `release <id>` / `status`
|
|
127
|
+
/ `leases` / `allocate` / `serve`. Pool: blocks of 20 ports from 11000–14999, offsets
|
|
128
|
+
`auth=+0 firestore=+1 functions=+2 storage=+3 ui=+4 hub=+5 logging=+6 pubsub=+7
|
|
129
|
+
eventarc=+8 database=+9` (kept in sync with the run driver's derivation).
|
|
130
|
+
|
|
131
|
+
## What changes from v1
|
|
132
|
+
|
|
133
|
+
- Drop the registry (`~/.config/harbormaster.json`) and `list`/`apply`/`check`.
|
|
134
|
+
- Drop sticky-per-project (`up`/`ports` as a shared instance). Everything is a
|
|
135
|
+
caller-owned lease.
|
|
136
|
+
- `firebase.json` everywhere is portless (already done for agent-qa/impulse/walk2gether/
|
|
137
|
+
tuturno on their branches).
|
|
138
|
+
- Consumers (`app.config`, scrub scripts, CLAUDE.md tables) stop hard-coding ports and
|
|
139
|
+
instead `eval "$(harbormaster acquire --cwd . --env)"` (owner = their process).
|
|
140
|
+
|
|
141
|
+
## Open items before this goes live
|
|
142
|
+
|
|
143
|
+
- Validate `acquire` boots + owner-death reap + PID-reuse guard on a quiet machine.
|
|
144
|
+
- Confirm `killpg` reaps JVM children (fix the v1 leak if not).
|
|
145
|
+
- Migrate the agent-qa run driver to pass `owner_pid` to `/allocate`.
|
|
146
|
+
- Migrate the app.config / scrub-script / CLAUDE.md consumers to `acquire --env`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Dawson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# harbormaster
|
|
2
|
+
|
|
3
|
+
> Formerly `firebase-ports`. The scope outgrew the name: leases now cover Metro/Expo
|
|
4
|
+
> ports and private iOS simulators too, not just Firebase emulators. The npm package
|
|
5
|
+
> still installs a `firebase-ports` bin alias, and the daemon state dir is unchanged,
|
|
6
|
+
> so existing consumers keep working.
|
|
7
|
+
|
|
8
|
+
A localhost daemon that leases each **caller** its own isolated port block — a
|
|
9
|
+
Firebase emulator sandbox with fresh data, a Metro port, a private simulator —
|
|
10
|
+
and reclaims it when the caller's process exits.
|
|
11
|
+
|
|
12
|
+
If you run several Firebase projects locally (dev, tests, CI, agents) you know the
|
|
13
|
+
pain: emulators fight over ports, a bare `firebase emulators:start` collides with
|
|
14
|
+
another suite, and `pkill` "fixes" it by nuking *everyone's* emulators. harbormaster
|
|
15
|
+
removes the collision entirely — two callers that both want "an impulse emulator" get
|
|
16
|
+
two independent suites (own ports, own data), so they never step on each other.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
$ eval "$(harbormaster acquire --cwd ./my-app --only auth,firestore --env)"
|
|
20
|
+
$ echo $FIRESTORE_EMULATOR_HOST
|
|
21
|
+
127.0.0.1:11001
|
|
22
|
+
# ...run your tests against the suite...
|
|
23
|
+
# it's torn down automatically when this shell exits
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- **Python 3.8+** — the daemon is a single Python script (`#!/usr/bin/env python3`).
|
|
29
|
+
npm just ships and links the executable; it must be able to find `python3` on PATH.
|
|
30
|
+
- **firebase-tools** (`firebase`) on PATH — it boots the actual emulators.
|
|
31
|
+
- macOS or Linux.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install -g harbormaster # global CLI
|
|
37
|
+
# or add it to a project so it ships with your app/agent:
|
|
38
|
+
npm install harbormaster # → node_modules/.bin/harbormaster
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
The daemon auto-starts on the first command. `firebase.json` ports are **optional** —
|
|
44
|
+
the daemon injects (and overrides) a pool block into a temp config per lease, so you
|
|
45
|
+
never hand-assign a port.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Boot a private suite for the project in DIR, owned by the calling shell,
|
|
49
|
+
# reaped when it exits. --env prints exports to eval into your shell:
|
|
50
|
+
eval "$(harbormaster acquire --cwd ./my-app --only auth,firestore,functions --env)"
|
|
51
|
+
# → FIRESTORE_EMULATOR_HOST, FIREBASE_AUTH_EMULATOR_HOST, EMULATOR_<SVC>_PORT,
|
|
52
|
+
# GCLOUD_PROJECT (from .firebaserc)
|
|
53
|
+
|
|
54
|
+
# Run a command inside a fresh suite that tears down after (great for CI):
|
|
55
|
+
harbormaster exec --cwd ./my-app --only auth,firestore "npm test"
|
|
56
|
+
|
|
57
|
+
# Reserve a block WITHOUT booting — for a caller that starts its own suite:
|
|
58
|
+
harbormaster allocate --json # → { "leaseId": "...", "base": 11000 }
|
|
59
|
+
|
|
60
|
+
# Have the DAEMON run a long-lived process (Metro, a dev server) as the lease:
|
|
61
|
+
# daemon-owned, so it survives the calling shell / agent session. Liveness is the
|
|
62
|
+
# spawned pid (+ TTL backstop); stop it with `release`. CMD sees METRO_PORT,
|
|
63
|
+
# DETOX_UDID/DETOX_SIM_NAME (with --sim), FBPORTS_BLOCK_BASE, FBPORTS_LEASE_ID.
|
|
64
|
+
eval "$(harbormaster allocate --sim --ttl 604800 \
|
|
65
|
+
--spawn 'exec npx expo start --port "$METRO_PORT"' --cwd ./my-app --env)"
|
|
66
|
+
harbormaster release "$FBPORTS_LEASE_ID" # tears down Metro + the sim
|
|
67
|
+
|
|
68
|
+
# Reuse gate for long-lived sessions: exit 0 iff the lease is FULLY alive
|
|
69
|
+
# (known to the daemon, process running, sim still present, ports up) — anything
|
|
70
|
+
# less means clean up your session state and re-lease:
|
|
71
|
+
harbormaster verify "$FBPORTS_LEASE_ID" || re_lease
|
|
72
|
+
|
|
73
|
+
harbormaster status # everything the daemon has out
|
|
74
|
+
harbormaster down --cwd ./my-app # release this shell's lease early
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`--json` on `acquire`/`allocate` returns the ports/base as JSON for scripting.
|
|
78
|
+
|
|
79
|
+
Version skew is self-healing: the daemon reports its version on `/health`, and a
|
|
80
|
+
newer client replaces an older daemon in place (leases persist in the ledger;
|
|
81
|
+
every leased process runs in its own session, so nothing is torn down by the
|
|
82
|
+
handover). A stale `node_modules` copy can no longer pin the machine to an old
|
|
83
|
+
daemon.
|
|
84
|
+
|
|
85
|
+
Leased simulators are part of a lease's liveness: if a sim is deleted externally
|
|
86
|
+
(Xcode cleanup, `simctl delete`, a device reset), the reaper notices within
|
|
87
|
+
`FBPORTS_SIM_CHECK_INTERVAL` (60s) and reaps the lease *whole* — killing its
|
|
88
|
+
spawned process too — so a half-alive session (sim gone, Metro still squatting
|
|
89
|
+
its port) can never linger for callers to "reuse".
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
- **Caller-owned.** One primitive: `acquire {cwd, owner_pid} → {ports, projectId}`.
|
|
94
|
+
Issuance is unique per *caller*, not per project — so concurrent callers of the
|
|
95
|
+
same project get independent suites.
|
|
96
|
+
- **Reaped on exit.** The daemon kills the suite (the whole process group, so the JVM
|
|
97
|
+
children die too) when `owner_pid` dies. The caller's death *is* the release —
|
|
98
|
+
nothing to remember, and never a reason to `pkill`. A TTL and emulator-liveness are
|
|
99
|
+
backstops.
|
|
100
|
+
- **Pool ports.** Blocks of 20 from 11000–14999; each firestore suite also gets a
|
|
101
|
+
private websocket port, so concurrent suites never collide on the default 9150.
|
|
102
|
+
- **No registry, no shared instances.** A "project" is just the `firebase.json` in
|
|
103
|
+
`cwd`; the project id comes from `.firebaserc`.
|
|
104
|
+
|
|
105
|
+
See [DESIGN.md](./DESIGN.md) for the full architecture.
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT — see [LICENSE](./LICENSE).
|