/** * connectWithPolicy plus a one-time version handshake that transparently replaces a stale * daemon. Split out of daemon-client.ts's own bundled concerns -- still zero runtime imports * outside this package's own daemon-client/* siblings (see daemon-client.ts's own module doc * comment for why that invariant matters for Pi's jiti-based extension loader). */ import { type ConnectPolicyOptions, type DaemonHandleLike } from "./connect-with-policy.js"; /** * A plain string is fine for a genuinely fixed/compiled version. Everything else should pass a * supplier instead: a plain string can never be "fresh" by construction, so caching one read * once at module load (the natural, obvious way to read "my own version") goes stale the * moment `npm update` rewrites package.json underneath an already-running process -- every * later connect then sees a permanent, never-self-healing false mismatch. See * createLiveVersionExpectation() (./version.ts) for the correct always-fresh supplier. */ export type ExpectedVersion = string | (() => string) | (() => Promise); export interface VersionCheckOptions { /** This extension's own expected daemon version/protocol identifier. Resolved fresh on every call -- see ExpectedVersion. */ expectedVersion: ExpectedVersion; /** Reads the connected daemon's reported version (e.g. via its /health response). A connection failure here is retried -- see connectRetry -- rather than propagated as-is; every other error still propagates unchanged, and an inconclusive read never triggers a kill. */ readVersion: (client: Client) => Promise; /** Best-effort graceful shutdown request against the stale daemon. Its failure is swallowed -- killStaleProcess is the real fallback that must always work. */ requestShutdown?: (client: Client) => Promise; /** Hard fallback: signal the stale daemon's process directly (e.g. `process.kill(handle.pid, "SIGTERM")`). Must not throw for an already-dead pid. */ killStaleProcess: (handle: Handle) => void; /** Bounded wait for the stale daemon's handle file to clear after shutdown/kill, before spawning its replacement. Defaults to 2000ms. */ shutdownTimeoutMs?: number; /** Poll interval while waiting for the handle file to clear. Defaults to 50ms. */ shutdownPollIntervalMs?: number; /** * Bounded retry/backoff around the initial connect+readVersion round trip -- closes a real * TOCTOU race: two concurrent callers can both connect to the same stale daemon, but only * one needs to actually kill it -- every other in-flight caller's own readVersion() then * hits a connection freshly closed out from under it and would otherwise throw, even though * the daemon is being correctly replaced. Retrying re-reads the handle fresh each attempt, * so it picks up whatever the current real state is (often an already-live replacement) * instead of propagating a transient failure that was never a real problem. Modeled on * connectPushChannel's own reconnect backoff. Sized larger than vehicle-client-pi's * registerVehicleTools handshake retry (which closes the analogous race one layer up, but * only needs to survive a daemon's ~100-300ms cold boot): this retry must survive a whole * concurrent kill-wait-respawn cycle, which shutdownTimeoutMs alone allows up to 2000ms for. * Defaults to attempts:8, initialDelayMs:100, maxDelayMs:1000, growFactor:1.8 (~5.2s worst * case). Set attempts:1 to restore the old immediate-failure behavior exactly. */ connectRetry?: ConnectVersionCheckRetryOptions; } export interface ConnectVersionCheckRetryOptions { /** Total attempts at the connect+readVersion round trip, including the first. Defaults to 4. */ readonly attempts?: number; /** Delay before the second attempt. Defaults to 50ms. */ readonly initialDelayMs?: number; /** No retry delay is ever allowed to exceed this. Defaults to 500ms. */ readonly maxDelayMs?: number; /** Multiplier applied to the delay after each failed attempt. Defaults to 2.5. */ readonly growFactor?: number; } /** * Dependency-free dotted-numeric version comparator (no semver package -- this file * deliberately has no imports of its own, see the module doc comment). Compares segments * numerically ("0.44.12" < "0.45.0"); a non-numeric segment on either side falls back to a * plain string comparison of that segment, which is still deterministic, just not * semver-aware (pre-release tags, build metadata). Missing trailing segments compare as 0 * ("1.2" === "1.2.0"). Returns negative/zero/positive like Array.prototype.sort's comparator. */ export declare function compareVersions(a: string, b: string): number; /** * Wraps connectWithPolicy with a one-time version handshake: an * auto-spawned daemon can outlive the extension package that spawned it -- * `pi update` upgrades the npm package on disk, but a daemon process * started yesterday keeps running with yesterday's code until something * notices. Left alone, the client silently talks to a stale daemon whose * wire protocol or schema may no longer match what this session expects. * * On every fresh connect (a new client instance, not a cached call), the * daemon's reported version is checked against `expectedVersion`, compared * with compareVersions (not string equality, so "which one is newer" is a * real, ordered question, not just a difference): * - Equal (or equal by comparison, e.g. "1.2" vs "1.2.0"): plain * connectWithPolicy's path, no extra latency beyond one readVersion() call. * - Running is OLDER than expected: this is the genuine staleness case * (`npm update` ran, the daemon didn't restart) -- replaced transparently * (graceful shutdown request, falling back to a direct kill signal), * reconnects against a freshly spawned one, no error surfaces. * - Running is NEWER than expected: this caller is the stale side, not the * daemon. Two different installed copies of the same consumer package can * coexist (a hoisted top-level copy plus another package's own undeduped * nested copy) and each resolve a different expectedVersion from their own * package.json -- without this direction check, they would kill and * respawn the daemon back and forth forever, each "fixing" what the other * had just "fixed". Refuses instead: never downgrades a live daemon, the * caller gets an actionable error naming the version to upgrade to. */ export declare function connectWithVersionCheck(policy: ConnectPolicyOptions, versionCheck: VersionCheckOptions): Promise;