diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 12:46:57 -0400 |
|---|---|---|
| committer | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 12:46:57 -0400 |
| commit | 2ac665ec31fe156b372540e8ac38aa42765b3001 (patch) | |
| tree | e21d16dd67e5f3d518a21cf6e7967fba964989e0 /docs | |
| parent | d9f2d027bda8cd2e2c42fa54a843c477fa18db05 (diff) | |
0.17.0: standalone daemon, no more owner-vs-client topology
The Chrome bridge is now a separate detached daemon at
~/.cache/pi-chrome/<version>/daemon.cjs. Every Pi session is a uniform client;
daemon lifecycle is decoupled from any Pi session. Killing a Pi never breaks
Chrome control, and pi update followed by any new Pi session auto-replaces
an incompatible daemon.
Architecture:
- New extensions/chrome-profile-bridge/daemon.cjs: ~700 LOC, zero deps, runs
detached. Owns the HTTP listener, BridgeAuth state, command queue, /pair
handshake. Idle-shuts-down after 10 min of no activity.
- ChromeProfileBridge on Pi side becomes a pure client: start() probes /status,
spawns daemon if missing/incompatible, kills+respawns if version mismatch.
send() always uses signed /command. No more server-mode branch.
- New signed /admin RPC for pair-window/reset/status state mutations the
daemon owns. Pi clients sign with broker key (same contract as /command).
- New /heartbeat for daemon idle-timer keepalive.
- daemon.log at ~/.cache/pi-chrome/<version>/daemon.log.
/chrome pair, /chrome unpair, /chrome onboard now go through /admin RPCs
instead of mutating in-process bridge state.
Removed the foreign-owner takeover dance from /chrome onboard (auto-handled
at session_start now).
Wire protocol is byte-identical to 0.16.x; existing paired extensions don't
need to re-pair. See docs/DESIGN-0.17-daemon.md for the full write-up.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/DESIGN-0.17-daemon.md | 106 |
1 files changed, 106 insertions, 0 deletions
diff --git a/docs/DESIGN-0.17-daemon.md b/docs/DESIGN-0.17-daemon.md new file mode 100644 index 0000000..2e7f115 --- /dev/null +++ b/docs/DESIGN-0.17-daemon.md @@ -0,0 +1,106 @@ +# pi-chrome 0.17 — standalone versioned daemon + +**Status:** design draft. Implementing in this same release branch. + +## Goals + +1. **Decouple the bridge from any Pi session lifecycle.** Killing a Pi session must never break Chrome control for other sessions. +2. **Eliminate the "owner vs client" topology.** Every Pi session should be a uniform client. +3. **Make upgrades self-healing.** When `pi update` lands a new pi-chrome version, the next session that starts must Just Work without any manual `kill` or `/chrome onboard` ritual. +4. **No new runtime deps.** Keep the zero-deps promise. + +## Non-goals (deferred to 0.18) + +- Transport change. Stays HTTP + long-poll for 0.17. WebSocket lands separately on top of the daemon. +- Native messaging. Not pursued; see SECURITY.md threat model rationale. + +## Architecture + +``` + ┌─────────────┐ ┌────────────────────────────┐ + │ Chrome ext │ HTTPS │ pi-chrome daemon │ ─────┐ + │ (SW polls ├────────►│ ~/.cache/pi-chrome/ │ │ + │ /next) │ │ v0.17.0/daemon.cjs │ │ stat() + └─────────────┘ │ Listens :17318 │ │ on each + │ Owns BridgeAuth + state │ │ accept + └─────────────┬──────────────┘ │ + │ │ + signed /command (HTTP) │ + │ │ + ┌─────────────┴──────────────┐ │ + │ Pi session N (any version) │ ──────┘ + │ extension/index.ts as │ + │ daemon supervisor + client │ + └────────────────────────────┘ +``` + +### Lifecycle + +- **First Pi session in a pi-chrome version family** spawns the daemon if no compatible one is running. Spawn is detached (`spawn(node, [daemon.cjs], { detached: true, stdio: 'ignore' })`), the parent Pi doesn't own the daemon's process group, and `unref()`'s it so Pi can exit independently. +- **Daemon stays alive** as long as it has work to do (active extension poll, or pending peer connections). After `IDLE_SHUTDOWN_MS` (default: 10 min) of zero activity, daemon exits cleanly. +- **Subsequent Pi sessions** at `session_start` probe `/status`: + - Healthy + compatible version → use as-is. + - Unresponsive or incompatible version → kill it, spawn the right-version daemon. +- **`pi update`** changes the on-disk pi-chrome version. Next Pi session sees mismatch, kills old daemon, spawns new. **No in-process hot reload** — the old daemon's in-flight commands are allowed to drain (or time out normally) and the next request to the new daemon retries fresh. + +### Versioned daemon path + +Daemon is copied to `~/.cache/pi-chrome/<version>/daemon.cjs` on Pi session_start (idempotent, only writes if absent or shasum mismatches). Pi spawns the daemon from that path, never directly from the npm install location: + +- Survives `nvm use` switches. +- Multiple pi-chrome versions can coexist on disk (the active daemon corresponds to the most recent Pi session that started). Old version dirs are garbage-collected when the daemon detects them. +- The daemon executable is a self-contained CommonJS bundle (built at publish time, or generated by a postinstall hook). No `require()` from anywhere except `node:` builtins. + +### Why not ESM? + +CommonJS keeps the daemon a single file with no entry-point ceremony, no `package.json` next to it, no resolver surprises across Node versions. The cost is irrelevant — the daemon has no third-party deps. + +## Protocol (unchanged from 0.16.1) + +The HTTP endpoints and signed-envelope contract are byte-identical to 0.16.1. The daemon is structurally a refactor of the existing `ChromeProfileBridge` class: + +- `/status` (now adds `daemonPid`, `daemonStartedAt`) +- `/next` (long-poll, signed `bridge->ext` response) +- `/result` (signed `ext->bridge` body) +- `/command` (signed `peer->owner` / `owner->peer`) +- `/pair` (invite window, signed response with invite secret) + +The only addition is a new `/heartbeat` endpoint (cheap GET, returns `{ok:true,ts}`) used by Pi clients to keep the daemon's idle timer alive and to detect daemon health without consuming a full command round-trip. + +### Configuration file unchanged + +`~/.config/pi/chrome-bridge.json` keeps the same shape (`{protocol, bridgeId, extensionId, extensionPairKey, brokerKey}`). The daemon owns the read/write of that file; Pi clients only consult it to derive `brokerKey` for signing peer→owner requests. + +The cross-process `fs.watch` + `refreshIfStale` machinery becomes daemon-local — `chrome-bridge.json` is now exclusively the daemon's data file. Pi clients receive pairing state via `/status` instead of reading the file directly. + +## Extension changes + +**None.** The extension still polls `127.0.0.1:17318`. From its perspective the only thing that changed is "the process behind that port restarts itself occasionally during pi-chrome upgrades." + +The extension's existing version-mismatch reload path (`x-pi-chrome-version` header → `chrome.runtime.reload()`) handles its own upgrade story independently. + +## Pi-side client surface + +`extensions/chrome-profile-bridge/index.ts` is split into: + +1. **`bridge-client.ts`** — Pi-side talker. `BridgeClient.send(action, params, timeoutMs)` signs and posts to `/command` of the local daemon, verifies the response signature, returns the result. No long-poll loop, no HTTP server. ~200 LOC. +2. **`daemon-supervisor.ts`** — at `session_start`: ensures daemon is running, spawns if not, registers a graceful keepalive. ~100 LOC. +3. **`daemon.cjs`** (built artifact) — the bridge process. Internally identical to today's `ChromeProfileBridge` minus the "client mode" branch. Single self-contained file shipped in npm tarball + copied to versioned cache dir on first use. +4. **`index.ts`** — pi-extension entry. Imports `bridge-client` and `daemon-supervisor`, registers the chrome_* tools, wires `/chrome` commands. ~500 LOC (everything else moved out). + +## Open questions + +1. **Daemon log location.** Default: `~/.cache/pi-chrome/<version>/daemon.log` (rotated at 1 MiB). Exposed via `/chrome doctor --logs`. +2. **What happens during a takeover with in-flight Pi commands?** Old daemon dies → its in-flight `/command` returns a TCP RST to the Pi client → Pi client retries against the new daemon. Implement explicit retry-once on connection-refused/RST inside `BridgeClient.send` with a budget (5 s) before surfacing the error. +3. **Pairing across daemon restarts.** `~/.config/pi/chrome-bridge.json` persists. New daemon `load()`s it, picks up the same `extensionPairKey`/`brokerKey`. Extension never has to re-pair across a daemon restart. +4. **Concurrent daemon spawn race.** Two Pi sessions starting simultaneously could each try to spawn a daemon. Mitigation: spawn-then-bind, second one hits EADDRINUSE inside the daemon itself and exits cleanly; supervisor probes `/status` after spawn to confirm one is up. +5. **Garbage-collecting old versioned dirs.** Daemon at startup deletes `~/.cache/pi-chrome/v*/` directories older than 30 days. + +## Migration + +0.16.1 → 0.17: +- First Pi session on 0.17 detects an existing 0.16.x bridge on `:17318`, version-mismatches, kills it, spawns the 0.17 daemon. Same as the existing auto-takeover code in 0.16.1. +- Extension picks up new bridge on next poll, sees same `expectedExtensionVersion`, no reload. +- Pairing state in `~/.config/pi/chrome-bridge.json` is preserved across the transition. + +User-visible: nothing. `pi update` → next Pi session → done. |
