From 75d61a422bf23d07df1e6918c59f63abfa7c54da Mon Sep 17 00:00:00 2001 From: pixel Date: Thu, 3 Sep 2026 02:37:45 +0300 Subject: [PATCH] feat: remember stopOnExit:false processes so skip and restart apply across OpenCode restarts --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .gitignore | 4 + README.md | 54 +- dist/core.d.ts | 24 +- dist/core.d.ts.map | 2 +- dist/core.js | 743 +++++++--- dist/core.js.map | 2 +- dist/durable-lock.d.ts | 36 + dist/durable-lock.d.ts.map | 1 + dist/durable-lock.js | 240 ++++ dist/durable-lock.js.map | 1 + dist/durable-registry.d.ts | 56 + dist/durable-registry.d.ts.map | 1 + dist/durable-registry.js | 188 +++ dist/durable-registry.js.map | 1 + dist/error-code.d.ts | 2 + dist/error-code.d.ts.map | 1 + dist/error-code.js | 8 + dist/error-code.js.map | 1 + dist/logger.d.ts | 32 +- dist/logger.d.ts.map | 2 +- dist/logger.js | 24 + dist/logger.js.map | 2 +- dist/process-identity.d.ts | 48 + dist/process-identity.d.ts.map | 1 + dist/process-identity.js | 228 +++ dist/process-identity.js.map | 1 + dist/process-tree.d.ts.map | 2 +- dist/process-tree.js | 8 +- dist/process-tree.js.map | 2 +- dist/server-internal.d.ts | 5 + dist/server-internal.d.ts.map | 2 +- dist/server-internal.js | 30 +- dist/server-internal.js.map | 2 +- dist/server.d.ts.map | 2 +- dist/server.js | 14 + dist/server.js.map | 2 +- package.json | 4 +- scripts/release-package.mjs | 10 +- src/core.ts | 1100 ++++++++++++--- src/durable-lock.ts | 327 +++++ src/durable-registry.ts | 299 ++++ src/error-code.ts | 7 + src/logger.ts | 80 +- src/process-identity.ts | 355 +++++ src/process-tree.ts | 10 +- src/server-internal.ts | 51 +- src/server.ts | 18 + tests/community-files.test.ts | 2 +- tests/core-durable.test.ts | 1856 +++++++++++++++++++++++++ tests/core.test.ts | 257 +--- tests/durable-integration.test.ts | 544 ++++++++ tests/durable-lock.test.ts | 758 ++++++++++ tests/durable-registry.test.ts | 739 ++++++++++ tests/fixtures/durable-child.mjs | 14 + tests/helpers/core-fixtures.ts | 436 ++++++ tests/logger.test.ts | 70 + tests/manifest.test.ts | 4 +- tests/process-identity.test.ts | 444 ++++++ tests/readme.test.ts | 136 +- tests/release-draft.test.ts | 10 +- tests/release-package.test.ts | 26 +- tests/server.test.ts | 300 ++++ tests/workflows.test.ts | 41 +- 64 files changed, 8997 insertions(+), 675 deletions(-) create mode 100644 dist/durable-lock.d.ts create mode 100644 dist/durable-lock.d.ts.map create mode 100644 dist/durable-lock.js create mode 100644 dist/durable-lock.js.map create mode 100644 dist/durable-registry.d.ts create mode 100644 dist/durable-registry.d.ts.map create mode 100644 dist/durable-registry.js create mode 100644 dist/durable-registry.js.map create mode 100644 dist/error-code.d.ts create mode 100644 dist/error-code.d.ts.map create mode 100644 dist/error-code.js create mode 100644 dist/error-code.js.map create mode 100644 dist/process-identity.d.ts create mode 100644 dist/process-identity.d.ts.map create mode 100644 dist/process-identity.js create mode 100644 dist/process-identity.js.map create mode 100644 src/durable-lock.ts create mode 100644 src/durable-registry.ts create mode 100644 src/error-code.ts create mode 100644 src/process-identity.ts create mode 100644 tests/core-durable.test.ts create mode 100644 tests/durable-integration.test.ts create mode 100644 tests/durable-lock.test.ts create mode 100644 tests/durable-registry.test.ts create mode 100644 tests/fixtures/durable-child.mjs create mode 100644 tests/helpers/core-fixtures.ts create mode 100644 tests/process-identity.test.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 78c0cf7..2a80f42 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -12,7 +12,7 @@ body: id: plugin-version attributes: label: Plugin version - placeholder: 1.1.0 + placeholder: 1.2.0 validations: required: true - type: input diff --git a/.gitignore b/.gitignore index 665a164..42fca48 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ Thumbs.db *.tsbuildinfo /docs/ + +# Local agent workspaces +.claude/ +.superpowers/ diff --git a/README.md b/README.md index 46bb4bb..438f169 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,13 @@ This is an unofficial third-party plugin and is not affiliated with the OpenCode - Loads dedicated global and project command files in global-first order without blocking on child readiness. - Deduplicates launches for each OpenCode process and normalized project root. +- Remembers `stopOnExit: false` processes across OpenCode restarts and re-adopts them instead of starting duplicates. - Spawns the executable with its ordered arguments directly, without an implicit shell. - Supports Linux, macOS, and Windows with sanitized console and rotating file logs. ## Compatibility -Supports OpenCode `1.18.x` and is compiled and directly tested against `1.18.25`. +Supports OpenCode `1.18.x` and is compiled and directly tested against `1.18.28`. ## Installation @@ -24,7 +25,7 @@ Register the exact npm release in `opencode.json` or `opencode.jsonc`: ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-startup-commands@1.1.0"] + "plugin": ["opencode-startup-commands@1.2.0"] } ``` @@ -33,7 +34,7 @@ Alternatively, register the immutable Git tag: ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-startup-commands@git+https://github.com/PixelWinner/opencode-startup-commands.git#v1.1.0"] + "plugin": ["opencode-startup-commands@git+https://github.com/PixelWinner/opencode-startup-commands.git#v1.2.0"] } ``` @@ -80,10 +81,10 @@ Project commands receive the original OpenCode worktree root as `cwd`; only the - `stopOnExit: false` records remain tracked when ownerless for later `skip` or `restart`; `stopOnExit: true` records wait for the final owner, then stop. A same-process reopen can attach a new owner to a still-tracked record. Confirmed successful final cleanup instead releases the identity, so reopening in the same OpenCode process starts a new process. - Failed launches are not retried in that process; launch failures, final natural exits, and unconfirmed stale cleanup create tombstones and blockers that block same-process retry as applicable. - A partial restart leaves safely addressable survivors in degraded state, transfers orphaned owners to the oldest survivor, and starts no replacement. -- Tracking covers only processes the plugin launched during the current OpenCode process; it does not scan the OS to discover or adopt other processes. +- Tracking covers processes the plugin launched during the current OpenCode process, plus the `stopOnExit: false` processes it recorded in its own registry; it does not scan the OS to discover or adopt processes it did not launch. - Bare `opencode serve` does not launch commands until a project or directory initializes the plugin. -A full OpenCode restart is required after updating plugin code or changing registration, revision, or command files; the plugin cannot rediscover previously launched processes after that restart because tracking is in memory. +A full OpenCode restart is required after updating plugin code or changing registration, revision, or command files; after that restart the plugin cannot rediscover `stopOnExit: true` processes, which are tracked only in memory, and re-adopts only the `stopOnExit: false` processes recorded in its registry whose `(pid, startToken)` pair still matches. ### Process stopping @@ -92,6 +93,49 @@ A full OpenCode restart is required after updating plugin code or changing regis - On Windows, if the tracked root exits before cleanup, its descendants cannot be addressed safely. The plugin fails closed, which may leave descendants running, blocks restart for that identity, and recovery requires a full OpenCode restart. - Cleanup is best-effort for deliberately detached or escaped descendants, forced OpenCode termination, OS crash, and power loss. +## Process lifetime across OpenCode restarts + +A `stopOnExit: false` process survives an OpenCode exit, and the next OpenCode process re-adopts it from a registry file instead of starting a second copy. A `stopOnExit: true` process is stopped when its final owner disposes, is never recorded, and is never re-adopted. + +The table covers the first activation after a full OpenCode exit, with the command's configuration unchanged since the previous start; within one OpenCode process the policies behave as described above. + +| `onExistingProcess` | `stopOnExit` | Behavior at the next OpenCode start | +| --- | --- | --- | +| `skip` | `false` | Reuses the surviving process and starts nothing. | +| `start` | `false` | Keeps the surviving process and starts one more beside it; both are remembered. | +| `restart` | `false` | Stops the surviving process, waits for confirmed cleanup, then starts exactly one replacement. | +| `skip` | `true` | Nothing survived, so one process starts and is stopped again on exit. | +| `start` | `true` | Nothing survived, so one process starts and is stopped again on exit. | +| `restart` | `true` | Nothing survived, so one process starts and is stopped again on exit. | + +- Changing `stopOnExit` from `false` to `true` takes effect at the next activation, not the running one: the surviving process is stopped, one replacement starts in per-session mode, and that replacement is stopped when its session closes. +- Removing a command from a cleanly loaded configuration stops its durable process. The record is kept unless that stop is confirmed, so a process that cannot be confirmed stopped is retried on every later cleanly loaded activation, and each retry waits out the stop budget before other durable work proceeds. A configuration error stops nothing: adoption still runs, because refusing to adopt over a typo would create the duplicate processes this section exists to prevent, while stopping is destructive and requires both the global and the project file to load with no error at all, including a single invalid entry. +- If the plugin cannot determine whether a remembered process is alive, such as when a probe is denied permission, it fails closed: it neither stops that process nor starts a duplicate, and retries at a later activation. +- No external runtime, daemon, or supervisor process is added, and the plugin declares no runtime dependencies. + +### What is remembered + +A registry file records, for each surviving process, a hash of the command identity, its PID, and a start token: + +| Platform | Registry file | +| --- | --- | +| Windows | `%LOCALAPPDATA%\opencode\startup-commands\\registry.json` | +| macOS | `~/Library/Application Support/OpenCode/startup-commands//registry.json` | +| Linux | `$XDG_STATE_HOME/opencode/startup-commands//registry.json`, or `~/.local/state/opencode/startup-commands//registry.json` | + +The registry is kept per host, under a sanitized `` directory, so a home directory shared between machines keeps each machine's processes separate. Because that key is the hostname, renaming the machine starts a new registry: helpers recorded under the old name are forgotten, never stopped, and the next start launches fresh ones beside them, so stop the old ones once by hand after a rename. A remembered process is re-identified by the `(pid, startToken)` pair, never by the token alone. A start token is only a start timestamp at the platform's own granularity: 100-nanosecond ticks on Windows, one kernel clock tick on Linux (10 ms on the kernels measured), and one second on macOS. No token contains a PID, so two processes started inside one such window can carry the same token; that is safe because every comparison the plugin makes is keyed by PID as well. + +A deleted registry file, or a record whose `(pid, startToken)` pair no longer matches, for example after a reboot, means the process it described is forgotten rather than stopped, and the command starts fresh. A registry file that cannot be parsed is set aside under a `.corrupt-` name and the plugin continues with an empty registry, so the processes it described are likewise forgotten rather than stopped. A registry file that cannot be read at all is left in place instead, and `stopOnExit: false` commands are skipped for that activation rather than started. Executable paths, arguments, environment variables, and project paths are never recorded. + +### Upgrading from 1.1.0 + +Version 1.1.0 kept no record of the processes it left running, so 1.2.0 cannot identify them. Once, before upgrading: + +1. Stop any helpers left running by `stopOnExit: false`. +2. Fully exit every OpenCode process. +3. Install 1.2.0. +4. Start OpenCode. New `stopOnExit: false` commands are now remembered. + ## Security > [!WARNING] diff --git a/dist/core.d.ts b/dist/core.d.ts index 7c44ab2..64804b0 100644 --- a/dist/core.d.ts +++ b/dist/core.d.ts @@ -1,5 +1,7 @@ import type { ConfiguredCommand } from "./config.js"; +import type { DurableLockHolder, DurableRegistry } from "./durable-registry.js"; import type { Logger } from "./logger.js"; +import type { ProcessIdentityController } from "./process-identity.js"; import type { ProcessTreeController, ProcessTreeStopResult } from "./process-tree.js"; export interface SpawnedChild { readonly pid?: number; @@ -16,8 +18,7 @@ interface ManagedCommandContext { index: number; name: string; } -interface ProcessRecord { - child: SpawnedChild; +interface ProcessRecordBase { pid?: number; context: ManagedCommandContext; creationOrder: number; @@ -27,6 +28,13 @@ interface ProcessRecord { stopPromise?: Promise; rootExited: boolean; } +type ProcessRecord = (ProcessRecordBase & { + origin: "spawned"; + child: SpawnedChild; +}) | (ProcessRecordBase & { + origin: "adopted"; + startToken: string; +}); interface IdentityEntry { processKey: string; records: ProcessRecord[]; @@ -51,13 +59,21 @@ export interface StartupDependencies { state: StartupState; processTree: ProcessTreeController; logger: Logger; + identity?: ProcessIdentityController; + registry?: DurableRegistry; } export interface StartupActivation { dispose(): Promise; } +export interface StartupRunOptions { + authoritative: boolean; + projectRootHash: string; + resolveOwner?: () => Promise; +} export declare function createStartupState(): StartupState; export declare function getOrCreateProcessState(registry: Record): StartupState; export declare const processState: StartupState; -export declare function runStartupCommands(commands: readonly ConfiguredCommand[], dependencies: StartupDependencies): Promise; -export {}; +declare function getNormalizedProjectRoot(projectRoot: string): string; +export { getNormalizedProjectRoot as normalizeProjectRoot }; +export declare function runStartupCommands(commands: readonly ConfiguredCommand[], dependencies: StartupDependencies, options?: StartupRunOptions): Promise; //# sourceMappingURL=core.d.ts.map \ No newline at end of file diff --git a/dist/core.d.ts.map b/dist/core.d.ts.map index aae68e3..a5e7af8 100644 --- a/dist/core.d.ts.map +++ b/dist/core.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,KAAK,EAGV,MAAM,EACP,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EACV,qBAAqB,EACrB,qBAAqB,EACtB,MAAM,mBAAmB,CAAC;AAM3B,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,YAAY,CAAC;IACrE,IAAI,CACF,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,CACR,IAAI,EAAE,MAAM,GAAG,IAAI,EACnB,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,KAC1B,IAAI,GACR,YAAY,CAAC;IAChB,KAAK,IAAI,IAAI,CAAC;CACf;AAED,KAAK,UAAU,GAAG,MAAM,CAAC;AACzB,KAAK,cAAc,GAAG,cAAc,GAAG,cAAc,CAAC;AACtD,KAAK,cAAc,GAAG,QAAQ,GAAG,YAAY,GAAG,UAAU,CAAC;AAC3D,KAAK,mBAAmB,GAAG,QAAQ,GAAG,UAAU,CAAC;AAEjD,UAAU,qBAAqB;IAC7B,KAAK,EAAE,QAAQ,GAAG,SAAS,CAAC;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,aAAa;IACrB,KAAK,EAAE,YAAY,CAAC;IACpB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,qBAAqB,CAAC;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IACxB,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,mBAAmB,CAAC;IAC5B,WAAW,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC7C,UAAU,EAAE,OAAO,CAAC;CACrB;AAED,UAAU,aAAa;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,kBAAkB,EAAE,OAAO,CAAC;IAC5B,MAAM,EAAE,cAAc,CAAC;IACvB,cAAc,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,MAAM,YAAY,GAAG,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;AAEtD,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,IAAI,CAAC;IACf,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,EAAE,QAAQ,CAAC;IAChB,WAAW,EAAE,IAAI,CAAC;CACnB;AAED,MAAM,MAAM,aAAa,GAAG,CAC1B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,mBAAmB,KACzB,YAAY,CAAC;AAElB,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,aAAa,CAAC;IACrB,KAAK,EAAE,YAAY,CAAC;IACpB,WAAW,EAAE,qBAAqB,CAAC;IACnC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAoLD,wBAAgB,kBAAkB,IAAI,YAAY,CAEjD;AAqCD,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,YAAY,CAWd;AAED,eAAO,MAAM,YAAY,cAAyC,CAAC;AAmUnE,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,EACtC,YAAY,EAAE,mBAAmB,GAChC,OAAO,CAAC,iBAAiB,CAAC,CA8Q5B"} \ No newline at end of file +{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/core.js b/dist/core.js index cf16c25..8f40c96 100644 --- a/dist/core.js +++ b/dist/core.js @@ -1,4 +1,5 @@ import { normalize, resolve } from "node:path"; +import { getErrorCode as getRawErrorCode } from "./error-code.js"; const ALLOWED_ERROR_CODES = new Set(["EACCES", "ENOENT", "EPERM"]); const PROCESS_STATE_KEY = Symbol.for("opencode.startup-commands.state"); const processGlobal = globalThis; @@ -64,15 +65,24 @@ function isProcessRecord(value) { return false; } try { - return (isSpawnedChild(value.child) && - isOptionalPid(value.pid) && + const sharedFieldsValid = isOptionalPid(value.pid) && isManagedCommandContext(value.context) && isNonnegativeSafeInteger(value.creationOrder) && isOwnerSet(value.owners) && typeof value.stopOnExit === "boolean" && (value.status === "active" || value.status === "stopping") && (value.stopPromise === undefined || isPromiseLike(value.stopPromise)) && - typeof value.rootExited === "boolean"); + typeof value.rootExited === "boolean"; + if (!sharedFieldsValid) { + return false; + } + if (value.origin === "adopted") { + return typeof value.startToken === "string" && value.startToken !== ""; + } + if (value.origin === undefined || value.origin === "spawned") { + return isSpawnedChild(value.child); + } + return false; } catch { return false; @@ -191,6 +201,7 @@ function getNormalizedProjectRoot(projectRoot) { ? normalizedRoot.toLowerCase() : normalizedRoot; } +export { getNormalizedProjectRoot as normalizeProjectRoot }; function getProcessKey(command, signature) { if (command.scope === "global") { return `global:${signature}`; @@ -198,20 +209,14 @@ function getProcessKey(command, signature) { return `project:${getNormalizedProjectRoot(command.projectRoot)}:${signature}`; } function getErrorCode(error) { - return (typeof error === "object" && - error !== null && - "code" in error && - typeof error.code === "string" && - ALLOWED_ERROR_CODES.has(error.code) - ? error.code - : undefined); + const code = getRawErrorCode(error); + return (code !== undefined && ALLOWED_ERROR_CODES.has(code) ? code : undefined); } function writeLog(logger, event) { try { logger.write(event); } catch { - // Logging must not affect command execution or child lifecycle callbacks. } } function pruneIdentityIfEmpty(state, entry) { @@ -286,6 +291,7 @@ function spawnRecord(command, owner, entry, dependencies) { } const pid = getValidatedPid(child); const record = { + origin: "spawned", child, pid, context: { @@ -327,7 +333,6 @@ function spawnRecord(command, owner, entry, dependencies) { }); } catch { - // A spawned child remains successful even if lifecycle hooks are unavailable. } try { child.once("exit", (exitCode, signal) => { @@ -355,13 +360,11 @@ function spawnRecord(command, owner, entry, dependencies) { }); } catch { - // A spawned child remains successful even if lifecycle hooks are unavailable. } try { child.unref(); } catch { - // The child already started, so an unref failure is not a spawn failure. } return record; } @@ -376,6 +379,35 @@ function requestRecordStop(record, trigger, dependencies) { pid: record.pid, trigger, }); + if (record.origin === "spawned") { + record.stopPromise = stopThroughTree(record, trigger, dependencies); + return record.stopPromise; + } + record.stopPromise = resolveRootExited(record, dependencies) + .then((rootExitedAtEntry) => { + record.rootExited = record.rootExited || rootExitedAtEntry; + return stopThroughTree(record, trigger, dependencies); + }) + .catch(() => ({ + status: "failed", + reason: "unconfirmed", + addressability: record.rootExited ? "lost" : "safe", + })); + return record.stopPromise; +} +async function resolveRootExited(record, dependencies) { + if (!dependencies.identity) { + return true; + } + try { + const probed = await dependencies.identity.probe(record.pid, record.startToken); + return probed.status !== "alive"; + } + catch { + return true; + } +} +function stopThroughTree(record, trigger, dependencies) { let stopPromise; try { stopPromise = Promise.resolve(dependencies.processTree.stop(record.pid, { @@ -397,7 +429,7 @@ function requestRecordStop(record, trigger, dependencies) { addressability: record.rootExited ? "lost" : "safe", }); } - record.stopPromise = stopPromise + return stopPromise .catch(() => ({ status: "failed", reason: "unconfirmed", @@ -415,7 +447,6 @@ function requestRecordStop(record, trigger, dependencies) { } return result; }); - return record.stopPromise; } function applyRecordStopResult(entry, record, result) { if (result.status === "stopped") { @@ -431,42 +462,328 @@ function applyRecordStopResult(entry, record, result) { record.status = "active"; record.stopPromise = undefined; } -export async function runStartupCommands(commands, dependencies) { - const owner = Symbol("startup-command-owner"); - const claimedKeys = new Set(); - let disposePromise; - const activation = { - dispose() { - disposePromise ??= Promise.all([...claimedKeys].map((processKey) => withIdentityTransition(dependencies.state, processKey, async (entry) => { - const recordsToStop = []; - for (const record of entry.records) { - if (record.owners.delete(owner) && - record.owners.size === 0 && - record.stopOnExit) { - recordsToStop.push(record); - } - } - const results = await Promise.all(recordsToStop.map((record) => requestRecordStop(record, "scope-disposed", dependencies))); - results.forEach((result, index) => { - applyRecordStopResult(entry, recordsToStop[index], result); - }); - }))) - .then(() => undefined) - .catch(() => undefined); - return disposePromise; - }, +function durableKeyFor(part) { + return part.scope === "global" + ? `identity:${part.identityHash}` + : `identity:${part.identityHash}:root:${part.projectRootHash}`; +} +function trackedPidKey(processKey, pid) { + return `${processKey}:${pid}`; +} +function adoptedRecordFrom(stored, context) { + return { + origin: "adopted", + pid: stored.pid, + startToken: stored.startToken, + context, + creationOrder: stored.creationOrder, + owners: new Set(), + stopOnExit: false, + status: "active", + rootExited: false, }; - writeLog(dependencies.logger, { - type: "plugin.initialized", - commandCount: commands.length, - }); - if (commands.length === 0) { +} +function toDurableRecord(scopeInfo, pid, startToken, creationOrder, recordedAt) { + return scopeInfo.scope === "global" + ? { + scope: "global", + identityHash: scopeInfo.identityHash, + pid, + startToken, + stopOnExit: false, + creationOrder, + recordedAt, + } + : { + scope: "project", + identityHash: scopeInfo.identityHash, + projectRootHash: scopeInfo.projectRootHash, + pid, + startToken, + stopOnExit: false, + creationOrder, + recordedAt, + }; +} +const DURABLE_CAUSE_BY_REASON = { + unreadable: "registry-unreadable", + malformed: "registry-malformed", + "unsupported-schema": "registry-unsupported-schema", +}; +function durableCauseFor(reason) { + return DURABLE_CAUSE_BY_REASON[reason]; +} +function finalizePersistedRecords(records) { + const deduped = new Map(); + for (const stored of records) { + deduped.set(`${durableKeyFor(stored)}:${stored.pid}`, stored); + } + const groups = new Map(); + const groupOrder = []; + for (const stored of deduped.values()) { + const key = durableKeyFor(stored); + let group = groups.get(key); + if (!group) { + group = []; + groups.set(key, group); + groupOrder.push(key); + } + group.push(stored); + } + const sorted = []; + for (const key of groupOrder) { + const group = groups.get(key); + group.sort((left, right) => left.creationOrder - right.creationOrder); + sorted.push(...group); + } + return sorted; +} +async function safeDescribe(pid, identity) { + try { + return await identity.describe(pid); + } + catch { + return { status: "unknown", reason: "unconfirmed" }; + } +} +async function safeProbe(pid, startToken, identity) { + try { + return await identity.probe(pid, startToken); + } + catch { + return { status: "unknown", reason: "unconfirmed" }; + } +} +async function describeToken(pid, identity) { + const described = await safeDescribe(pid, identity); + return described.status === "described" ? described.token : undefined; +} +async function resolveLockHolder(resolveOwner, identity) { + let owner; + try { + owner = await resolveOwner?.(); + } + catch { + owner = undefined; + } + if (owner) { + return { status: "resolved", holder: owner }; + } + const resolved = await describeToken(process.pid, identity); + return resolved === undefined + ? { status: "unresolved" } + : { + status: "resolved", + holder: { pid: process.pid, startToken: resolved }, + }; +} +function indexDurableCommands(uniqueCommands, registry, projectRootHash) { + const durableScopes = new Map(); + const durableProcessKeys = new Set(); + const commandByDurableKey = new Map(); + for (const { command, signature } of uniqueCommands) { + const processKey = getProcessKey(command, signature); + const identityHash = registry.hashIdentity(signature); + if (!command.stopOnExit) { + durableProcessKeys.add(processKey); + } + let scopeInfo; + if (command.scope === "global") { + scopeInfo = { scope: "global", identityHash }; + } + else { + if (projectRootHash === undefined) { + continue; + } + scopeInfo = { scope: "project", identityHash, projectRootHash }; + } + durableScopes.set(processKey, scopeInfo); + if (!command.stopOnExit) { + commandByDurableKey.set(durableKeyFor(scopeInfo), { + processKey, + context: { + scope: command.scope, + index: command.index, + name: command.name, + }, + }); + } + } + return { durableScopes, durableProcessKeys, commandByDurableKey }; +} +function partitionByProjectRoot(records, projectRootHash) { + const foreign = []; + const current = []; + for (const stored of records) { + if (stored.scope === "global" || + stored.projectRootHash === projectRootHash) { + current.push(stored); + } + else { + foreign.push(stored); + } + } + return { foreign, current }; +} +async function buildDurablePlan(uniqueCommands, dependencies, options, registry, identity) { + const { durableScopes, durableProcessKeys, commandByDurableKey } = indexDurableCommands(uniqueCommands, registry, options?.projectRootHash); + const reconciledCounts = { + global: { stopped: 0 }, + project: { stopped: 0 }, + }; + function unavailable(cause) { writeLog(dependencies.logger, { - type: "batch.skipped", - reason: "no-valid-commands", + type: "durable.unavailable", + cause, }); - return activation; + return { + plan: { enabled: false, blocked: durableProcessKeys }, + durableScopes, + }; + } + const holderResolution = await resolveLockHolder(options?.resolveOwner, identity); + if (holderResolution.status === "unresolved") { + return unavailable("identity-unavailable"); + } + const lockResult = await registry.acquireLock(holderResolution.holder); + if (lockResult.status === "unavailable") { + return unavailable("lock-unavailable"); + } + const handle = lockResult.handle; + try { + const readResult = registry.read(); + if (readResult.status === "unavailable") { + const cause = durableCauseFor(readResult.reason); + const result = unavailable(cause); + handle.release(); + return result; + } + if (readResult.quarantined) { + writeLog(dependencies.logger, { + type: "durable.registry-quarantined", + }); + } + const { foreign: foreignRecords, current: currentScopeRecords } = partitionByProjectRoot(readResult.records, options?.projectRootHash); + const nextCreationOrderByKey = new Map(); + for (const stored of currentScopeRecords) { + const key = durableKeyFor(stored); + nextCreationOrderByKey.set(key, Math.max(nextCreationOrderByKey.get(key) ?? 0, stored.creationOrder + 1)); + } + const carriedRecords = []; + const fallbackTokens = new Map(); + const seededEntries = new Map(); + function seededEntryFor(processKey, nextOrderFloor) { + let entry = seededEntries.get(processKey); + if (!entry) { + entry = + dependencies.state.get(processKey) ?? + createIdentityEntry(processKey); + dependencies.state.set(processKey, entry); + seededEntries.set(processKey, entry); + } + entry.nextCreationOrder = Math.max(entry.nextCreationOrder, nextOrderFloor); + return entry; + } + for (const stored of currentScopeRecords) { + const key = durableKeyFor(stored); + const lookup = commandByDurableKey.get(key); + const nextOrderFloor = nextCreationOrderByKey.get(key) ?? stored.creationOrder + 1; + const owningCommand = lookup + ? { + processKey: lookup.processKey, + entry: seededEntryFor(lookup.processKey, nextOrderFloor), + context: lookup.context, + } + : undefined; + const probeResult = await safeProbe(stored.pid, stored.startToken, identity); + if (probeResult.status === "alive") { + if (!owningCommand) { + if (options?.authoritative !== true) { + carriedRecords.push(stored); + continue; + } + const orphanProcessRecord = adoptedRecordFrom(stored, { + scope: stored.scope, + index: -1, + name: "(orphaned)", + }); + const stopResult = await requestRecordStop(orphanProcessRecord, "restart", dependencies); + if (stopResult.status !== "stopped") { + carriedRecords.push(stored); + continue; + } + reconciledCounts[stored.scope].stopped += 1; + continue; + } + const alreadyTracked = owningCommand.entry.records.some((existing) => existing.pid === stored.pid); + if (alreadyTracked) { + fallbackTokens.set(trackedPidKey(owningCommand.processKey, stored.pid), stored.startToken); + continue; + } + owningCommand.entry.records.push(adoptedRecordFrom(stored, owningCommand.context)); + writeLog(dependencies.logger, { + type: "durable.record-adopted", + scope: owningCommand.context.scope, + index: owningCommand.context.index, + name: owningCommand.context.name, + pid: stored.pid, + }); + continue; + } + if (probeResult.status === "gone") { + writeLog(dependencies.logger, { + type: "durable.record-dropped", + scope: stored.scope, + index: owningCommand?.context.index, + name: owningCommand?.context.name, + pid: stored.pid, + }); + continue; + } + if (owningCommand) { + owningCommand.entry.cleanupUnconfirmed = true; + owningCommand.entry.status = "degraded"; + } + carriedRecords.push(stored); + writeLog(dependencies.logger, { + type: "durable.record-unverifiable", + scope: stored.scope, + index: owningCommand?.context.index, + name: owningCommand?.context.name, + pid: stored.pid, + }); + } + for (const scope of ["global", "project"]) { + const counts = reconciledCounts[scope]; + if (counts.stopped === 0) { + continue; + } + writeLog(dependencies.logger, { + type: "durable.reconciled", + scope, + stoppedCount: counts.stopped, + }); + } + return { + plan: { + enabled: true, + registry, + identity, + handle, + foreignRecords, + carriedRecords, + fallbackTokens, + blocked: new Set(), + }, + durableScopes, + }; + } + catch (error) { + handle.release(); + throw error; } +} +function dedupeCommands(commands, logger) { const globalCommands = new Map(); const projectCommands = new Map(); const duplicateCommands = []; @@ -501,11 +818,10 @@ export async function runStartupCommands(commands, dependencies) { ...globalCommands.values(), ...uniqueProjectCommands, ]; - let commandAttempted = false; duplicateCommands .sort((left, right) => left.order - right.order) .forEach(({ command }) => { - writeLog(dependencies.logger, { + writeLog(logger, { type: "command.skipped", scope: command.scope, index: command.index, @@ -513,131 +829,244 @@ export async function runStartupCommands(commands, dependencies) { reason: "duplicate", }); }); - for (const { command, signature } of uniqueCommands) { - const processKey = getProcessKey(command, signature); - const attempted = await withIdentityTransition(dependencies.state, processKey, async (entry) => { - if (entry.records.length === 0) { - if (entry.retryTombstone !== undefined || - entry.cleanupUnconfirmed) { - writeLog(dependencies.logger, { - type: "command.skipped", - scope: command.scope, - index: command.index, - name: command.name, - reason: "already-started", - }); - return false; - } - const record = spawnRecord(command, owner, entry, dependencies); - if (record) { - claimedKeys.add(processKey); + return uniqueCommands; +} +function createActivation(claimedKeys, owner, dependencies) { + let disposePromise; + return { + dispose() { + disposePromise ??= Promise.all([...claimedKeys].map((processKey) => withIdentityTransition(dependencies.state, processKey, async (entry) => { + const recordsToStop = []; + for (const record of entry.records) { + if (record.owners.delete(owner) && + record.owners.size === 0 && + record.stopOnExit) { + recordsToStop.push(record); + } } - return true; + const results = await Promise.all(recordsToStop.map((record) => requestRecordStop(record, "scope-disposed", dependencies))); + results.forEach((result, index) => { + applyRecordStopResult(entry, recordsToStop[index], result); + }); + }))) + .then(() => undefined) + .catch(() => undefined); + return disposePromise; + }, + }; +} +async function restartIdentity(entry, command, processKey, owner, claimedKeys, dependencies) { + entry.status = "restarting"; + const snapshot = entry.records.map((record) => ({ + record, + owners: new Set(record.owners), + })); + const ownerUnion = new Set([owner]); + for (const { owners } of snapshot) { + for (const recordOwner of owners) { + ownerUnion.add(recordOwner); + } + } + const results = await Promise.all(snapshot.map(({ record }) => requestRecordStop(record, "restart", dependencies))); + const safeSurvivors = []; + const transferredOwners = new Set([owner]); + let stopFailed = false; + results.forEach((result, index) => { + const { record, owners } = snapshot[index]; + if (result.status === "stopped") { + for (const recordOwner of owners) { + transferredOwners.add(recordOwner); } - if (command.onExistingProcess === "start") { - const record = spawnRecord(command, owner, entry, dependencies); - if (record) { - claimedKeys.add(processKey); - } - return true; + return; + } + stopFailed = true; + if (record.rootExited || result.addressability === "lost") { + entry.cleanupUnconfirmed = true; + for (const recordOwner of owners) { + transferredOwners.add(recordOwner); } - if (command.onExistingProcess === "skip") { - const activeRecord = entry.records.find((record) => record.status === "active"); - if (activeRecord) { - activeRecord.owners.add(owner); - claimedKeys.add(processKey); - } + return; + } + record.owners.clear(); + for (const recordOwner of owners) { + record.owners.add(recordOwner); + } + record.status = "active"; + record.stopPromise = undefined; + safeSurvivors.push(record); + }); + entry.records = safeSurvivors; + if (!stopFailed) { + const replacement = spawnRecord(command, owner, entry, dependencies); + if (replacement) { + for (const recordOwner of ownerUnion) { + replacement.owners.add(recordOwner); + } + entry.retryTombstone = undefined; + claimedKeys.add(processKey); + } + entry.status = "stable"; + return true; + } + const oldestSurvivor = safeSurvivors[0]; + if (oldestSurvivor) { + for (const recordOwner of transferredOwners) { + oldestSurvivor.owners.add(recordOwner); + } + claimedKeys.add(processKey); + } + entry.status = "degraded"; + return true; +} +async function persistDurableRecords(plan, durableScopes, dependencies) { + const persisted = [...plan.carriedRecords]; + for (const [processKey, entry] of dependencies.state) { + const scopeInfo = durableScopes.get(processKey); + if (!scopeInfo) { + continue; + } + for (const record of entry.records) { + if (record.stopOnExit) { + continue; + } + if (record.pid === undefined) { writeLog(dependencies.logger, { - type: "command.skipped", - scope: command.scope, - index: command.index, - name: command.name, - reason: "already-started", + type: "durable.record-unverifiable", + ...record.context, + pid: undefined, }); - return false; + continue; } - if (entry.cleanupUnconfirmed) { - entry.status = "degraded"; + const describedToken = record.origin === "adopted" + ? record.startToken + : await describeToken(record.pid, plan.identity); + const startToken = describedToken ?? + plan.fallbackTokens.get(trackedPidKey(processKey, record.pid)); + if (startToken === undefined) { writeLog(dependencies.logger, { - type: "command.stop-failed", - scope: command.scope, - index: command.index, - name: command.name, - pid: undefined, - trigger: "restart", - reason: "unconfirmed", + type: "durable.record-unverifiable", + ...record.context, + pid: record.pid, }); - return true; + continue; } - entry.status = "restarting"; - const snapshot = entry.records.map((record) => ({ - record, - owners: new Set(record.owners), - })); - const ownerUnion = new Set([owner]); - for (const { owners } of snapshot) { - for (const recordOwner of owners) { - ownerUnion.add(recordOwner); - } + const recordedAt = new Date().toISOString(); + persisted.push(toDurableRecord(scopeInfo, record.pid, startToken, record.creationOrder, recordedAt)); + } + } + const finalRecords = [ + ...plan.foreignRecords, + ...finalizePersistedRecords(persisted), + ]; + const persistedSuccessfully = plan.registry.write(finalRecords); + if (!persistedSuccessfully) { + writeLog(dependencies.logger, { + type: "durable.unavailable", + cause: "registry-unwritable", + }); + } +} +export async function runStartupCommands(commands, dependencies, options) { + const owner = Symbol("startup-command-owner"); + const claimedKeys = new Set(); + const activation = createActivation(claimedKeys, owner, dependencies); + writeLog(dependencies.logger, { + type: "plugin.initialized", + commandCount: commands.length, + }); + const uniqueCommands = dedupeCommands(commands, dependencies.logger); + let commandAttempted = false; + let durablePlan; + let durableScopes; + const registry = dependencies.registry; + if (registry && + dependencies.identity && + (uniqueCommands.some(({ command }) => !command.stopOnExit) || + registry.exists())) { + const built = await buildDurablePlan(uniqueCommands, dependencies, options, registry, dependencies.identity); + durablePlan = built.plan; + durableScopes = built.durableScopes; + } + else { + durablePlan = { enabled: false, blocked: new Set() }; + durableScopes = new Map(); + } + function logSkipped(command, reason) { + writeLog(dependencies.logger, { + type: "command.skipped", + scope: command.scope, + index: command.index, + name: command.name, + reason, + }); + } + try { + for (const { command, signature } of uniqueCommands) { + const processKey = getProcessKey(command, signature); + if (durablePlan.blocked.has(processKey)) { + logSkipped(command, "durable-unavailable"); + continue; } - const results = await Promise.all(snapshot.map(({ record }) => requestRecordStop(record, "restart", dependencies))); - const safeSurvivors = []; - const transferredOwners = new Set([owner]); - let stopFailed = false; - results.forEach((result, index) => { - const { record, owners } = snapshot[index]; - if (result.status === "stopped") { - for (const recordOwner of owners) { - transferredOwners.add(recordOwner); + const attempted = await withIdentityTransition(dependencies.state, processKey, async (entry) => { + if (entry.records.length === 0) { + if (entry.retryTombstone !== undefined || + entry.cleanupUnconfirmed) { + logSkipped(command, "already-started"); + return false; } - return; - } - stopFailed = true; - if (record.rootExited || result.addressability === "lost") { - entry.cleanupUnconfirmed = true; - for (const recordOwner of owners) { - transferredOwners.add(recordOwner); + const record = spawnRecord(command, owner, entry, dependencies); + if (record) { + claimedKeys.add(processKey); } - return; + return true; } - record.owners.clear(); - for (const recordOwner of owners) { - record.owners.add(recordOwner); + if (command.onExistingProcess === "start") { + const record = spawnRecord(command, owner, entry, dependencies); + if (record) { + claimedKeys.add(processKey); + } + return true; } - record.status = "active"; - record.stopPromise = undefined; - safeSurvivors.push(record); - }); - entry.records = safeSurvivors; - if (!stopFailed) { - const replacement = spawnRecord(command, owner, entry, dependencies); - if (replacement) { - for (const recordOwner of ownerUnion) { - replacement.owners.add(recordOwner); + if (command.onExistingProcess === "skip") { + const activeRecord = entry.records.find((record) => record.status === "active"); + if (activeRecord) { + activeRecord.owners.add(owner); + claimedKeys.add(processKey); } - entry.retryTombstone = undefined; - claimedKeys.add(processKey); + logSkipped(command, "already-started"); + return false; } - entry.status = "stable"; - return true; - } - const oldestSurvivor = safeSurvivors[0]; - if (oldestSurvivor) { - for (const recordOwner of transferredOwners) { - oldestSurvivor.owners.add(recordOwner); + if (entry.cleanupUnconfirmed) { + entry.status = "degraded"; + writeLog(dependencies.logger, { + type: "command.stop-failed", + scope: command.scope, + index: command.index, + name: command.name, + pid: undefined, + trigger: "restart", + reason: "unconfirmed", + }); + return true; } - claimedKeys.add(processKey); - } - entry.status = "degraded"; - return true; - }); - commandAttempted ||= attempted; + return await restartIdentity(entry, command, processKey, owner, claimedKeys, dependencies); + }); + commandAttempted ||= attempted; + } + if (!commandAttempted) { + writeLog(dependencies.logger, { + type: "batch.skipped", + reason: commands.length === 0 ? "no-valid-commands" : "already-started", + }); + } + if (durablePlan.enabled) { + await persistDurableRecords(durablePlan, durableScopes, dependencies); + } } - if (!commandAttempted) { - writeLog(dependencies.logger, { - type: "batch.skipped", - reason: "already-started", - }); + finally { + if (durablePlan.enabled) { + durablePlan.handle.release(); + } } return activation; } diff --git a/dist/core.js.map b/dist/core.js.map index c758ed6..e9aa47e 100644 --- a/dist/core.js.map +++ b/dist/core.js.map @@ -1 +1 @@ -{"version":3,"file":"core.js","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgG/C,MAAM,mBAAmB,GAAG,IAAI,GAAG,CAAC,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC;AACnE,MAAM,iBAAiB,GAAG,MAAM,CAAC,GAAG,CAAC,iCAAiC,CAAC,CAAC;AACxE,MAAM,aAAa,GAAG,UAAgD,CAAC;AAEvE,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,CAAC;AACrD,CAAC;AAED,SAAS,wBAAwB,CAAC,KAAc;IAC9C,OAAO,CACL,OAAO,KAAK,KAAK,QAAQ;QACzB,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC;QAC3B,KAAK,IAAI,CAAC,CACX,CAAC;AACJ,CAAC;AAED,SAAS,aAAa,CAAC,KAAc;IACnC,OAAO,CACL,KAAK,KAAK,SAAS;QACnB,CAAC,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,CAAC,CACxE,CAAC;AACJ,CAAC;AAED,SAAS,aAAa,CAAC,KAAc;IACnC,IAAI,CAAC;QACH,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,UAAU,CAAC;IAC7D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,uBAAuB,CAC9B,KAAc;IAEd,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QACrB,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,CAAC;QACH,OAAO,CACL,CAAC,KAAK,CAAC,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,CAAC;YACvD,wBAAwB,CAAC,KAAK,CAAC,KAAK,CAAC;YACrC,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,CAC/B,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,cAAc,CAAC,KAAc;IACpC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QACrB,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,CAAC;QACH,OAAO,CACL,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC;YACxB,OAAO,KAAK,CAAC,IAAI,KAAK,UAAU;YAChC,OAAO,KAAK,CAAC,KAAK,KAAK,UAAU,CAClC,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,KAAc;IAChC,IAAI,CAAC,CAAC,KAAK,YAAY,GAAG,CAAC,EAAE,CAAC;QAC5B,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,CAAC;QACH,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC;IAChE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,eAAe,CAAC,KAAc;IACrC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QACrB,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,CAAC;QACH,OAAO,CACL,cAAc,CAAC,KAAK,CAAC,KAAK,CAAC;YAC3B,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC;YACxB,uBAAuB,CAAC,KAAK,CAAC,OAAO,CAAC;YACtC,wBAAwB,CAAC,KAAK,CAAC,aAAa,CAAC;YAC7C,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC;YACxB,OAAO,KAAK,CAAC,UAAU,KAAK,SAAS;YACrC,CAAC,KAAK,CAAC,MAAM,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,UAAU,CAAC;YAC1D,CAAC,KAAK,CAAC,WAAW,KAAK,SAAS,IAAI,aAAa,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;YACrE,OAAO,KAAK,CAAC,UAAU,KAAK,SAAS,CACtC,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,eAAe,CACtB,KAAc,EACd,UAAkB;IAElB,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QACrB,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,CAAC;QACH,IACE,KAAK,CAAC,UAAU,KAAK,UAAU;YAC/B,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC;YAC7B,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC;YACrC,CAAC,KAAK,CAAC,cAAc,KAAK,SAAS;gBACjC,KAAK,CAAC,cAAc,KAAK,cAAc;gBACvC,KAAK,CAAC,cAAc,KAAK,cAAc,CAAC;YAC1C,OAAO,KAAK,CAAC,kBAAkB,KAAK,SAAS;YAC7C,CAAC,KAAK,CAAC,MAAM,KAAK,QAAQ;gBACxB,KAAK,CAAC,MAAM,KAAK,YAAY;gBAC7B,KAAK,CAAC,MAAM,KAAK,UAAU,CAAC;YAC9B,CAAC,aAAa,CAAC,KAAK,CAAC,cAAc,CAAC;YACpC,CAAC,wBAAwB,CAAC,KAAK,CAAC,kBAAkB,CAAC;YACnD,CAAC,wBAAwB,CAAC,KAAK,CAAC,iBAAiB,CAAC;YAClD,CAAC,KAAK,CAAC,cAAc,KAAK,SAAS,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,EAChE,CAAC;YACD,OAAO,KAAK,CAAC;QACf,CAAC;QAED,IAAI,qBAAqB,GAAG,CAAC,CAAC,CAAC;QAC/B,KAAK,MAAM,MAAM,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;YACnC,IACE,MAAM,CAAC,aAAa,IAAI,qBAAqB;gBAC7C,MAAM,CAAC,aAAa,IAAI,KAAK,CAAC,iBAAiB,EAC/C,CAAC;gBACD,OAAO,KAAK,CAAC;YACf,CAAC;YACD,qBAAqB,GAAG,MAAM,CAAC,aAAa,CAAC;QAC/C,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,cAAc,CAAC,KAAc;IACpC,IAAI,CAAC,CAAC,KAAK,YAAY,GAAG,CAAC,EAAE,CAAC;QAC5B,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,CAAC;QACH,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC,KAAK,CACrB,CAAC,CAAC,UAAU,EAAE,KAAK,CAAC,EAAE,EAAE,CACtB,OAAO,UAAU,KAAK,QAAQ;YAC9B,eAAe,CAAC,KAAK,EAAE,UAAU,CAAC,CACrC,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,mBAAmB,CAAC,UAAkB;IAC7C,OAAO;QACL,UAAU;QACV,OAAO,EAAE,EAAE;QACX,kBAAkB,EAAE,KAAK;QACzB,MAAM,EAAE,QAAQ;QAChB,cAAc,EAAE,OAAO,CAAC,OAAO,EAAE;QACjC,kBAAkB,EAAE,CAAC;QACrB,iBAAiB,EAAE,CAAC;KACrB,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,kBAAkB;IAChC,OAAO,IAAI,GAAG,EAAyB,CAAC;AAC1C,CAAC;AAED,SAAS,kBAAkB,CAAC,KAAc;IACxC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QACrB,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,IAAI,OAAgB,CAAC;IACrB,IAAI,CAAC;QACH,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,IAAI,CAAC,CAAC,OAAO,YAAY,GAAG,CAAC,EAAE,CAAC;QAC9B,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,IAAI,WAAsB,CAAC;IAC3B,IAAI,CAAC;QACH,WAAW,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC,OAAO,UAAU,KAAK,QAAQ,CAAC,EAAE,CAAC;QACvE,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,KAAK,GAAG,kBAAkB,EAAE,CAAC;IACnC,KAAK,MAAM,UAAU,IAAI,WAAuB,EAAE,CAAC;QACjD,MAAM,KAAK,GAAG,mBAAmB,CAAC,UAAU,CAAC,CAAC;QAC9C,KAAK,CAAC,kBAAkB,GAAG,IAAI,CAAC;QAChC,KAAK,CAAC,MAAM,GAAG,UAAU,CAAC;QAC1B,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IAC/B,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,uBAAuB,CACrC,QAAiC;IAEjC,MAAM,aAAa,GAAG,QAAQ,CAAC,iBAAiB,CAAC,CAAC;IAElD,IAAI,cAAc,CAAC,aAAa,CAAC,EAAE,CAAC;QAClC,OAAO,aAAa,CAAC;IACvB,CAAC;IAED,MAAM,KAAK,GAAG,kBAAkB,CAAC,aAAa,CAAC,IAAI,kBAAkB,EAAE,CAAC;IACxE,QAAQ,CAAC,iBAAiB,CAAC,GAAG,KAAK,CAAC;IAEpC,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,CAAC,MAAM,YAAY,GAAG,uBAAuB,CAAC,aAAa,CAAC,CAAC;AAEnE,SAAS,mBAAmB,CAAC,OAA0B;IACrD,OAAO,IAAI,CAAC,SAAS,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;AAC5D,CAAC;AAED,SAAS,wBAAwB,CAAC,WAAmB;IACnD,MAAM,cAAc,GAAG,SAAS,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC;IAEvD,OAAO,OAAO,CAAC,QAAQ,KAAK,OAAO;QACjC,CAAC,CAAC,cAAc,CAAC,WAAW,EAAE;QAC9B,CAAC,CAAC,cAAc,CAAC;AACrB,CAAC;AAED,SAAS,aAAa,CACpB,OAA0B,EAC1B,SAAiB;IAEjB,IAAI,OAAO,CAAC,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC/B,OAAO,UAAU,SAAS,EAAE,CAAC;IAC/B,CAAC;IAED,OAAO,WAAW,wBAAwB,CAAC,OAAO,CAAC,WAAW,CAAC,IAAI,SAAS,EAAE,CAAC;AACjF,CAAC;AAED,SAAS,YAAY,CAAC,KAAc;IAClC,OAAO,CACL,OAAO,KAAK,KAAK,QAAQ;QACzB,KAAK,KAAK,IAAI;QACd,MAAM,IAAI,KAAK;QACf,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ;QAC9B,mBAAmB,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC;QACjC,CAAC,CAAC,KAAK,CAAC,IAAI;QACZ,CAAC,CAAC,SAAS,CACc,CAAC;AAChC,CAAC;AAED,SAAS,QAAQ,CAAC,MAAc,EAAE,KAAe;IAC/C,IAAI,CAAC;QACH,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACtB,CAAC;IAAC,MAAM,CAAC;QACP,0EAA0E;IAC5E,CAAC;AACH,CAAC;AAED,SAAS,oBAAoB,CAC3B,KAAmB,EACnB,KAAoB;IAEpB,IACE,KAAK,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC;QAC1B,KAAK,CAAC,cAAc,KAAK,SAAS;QAClC,CAAC,KAAK,CAAC,kBAAkB;QACzB,KAAK,CAAC,kBAAkB,KAAK,CAAC;QAC9B,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,KAAK,KAAK,EACrC,CAAC;QACD,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IACjC,CAAC;AACH,CAAC;AAED,KAAK,UAAU,sBAAsB,CACnC,KAAmB,EACnB,UAAkB,EAClB,UAAgD;IAEhD,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,mBAAmB,CAAC,UAAU,CAAC,CAAC;IACvE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC;QAC3B,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IAC/B,CAAC;IAED,KAAK,CAAC,kBAAkB,IAAI,CAAC,CAAC;IAC9B,MAAM,QAAQ,GAAG,KAAK,CAAC,cAAc,CAAC;IACtC,IAAI,OAAoB,CAAC;IACzB,KAAK,CAAC,cAAc,GAAG,IAAI,OAAO,CAAO,CAAC,iBAAiB,EAAE,EAAE;QAC7D,OAAO,GAAG,iBAAiB,CAAC;IAC9B,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC;QACH,MAAM,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QACvD,OAAO,MAAM,UAAU,CAAC,KAAK,CAAC,CAAC;IACjC,CAAC;YAAS,CAAC;QACT,KAAK,CAAC,kBAAkB,IAAI,CAAC,CAAC;QAC9B,OAAO,EAAE,CAAC;QACV,oBAAoB,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;IACrC,CAAC;AACH,CAAC;AAED,SAAS,eAAe,CAAC,KAAmB;IAC1C,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC;QACtB,OAAO,OAAO,GAAG,KAAK,QAAQ;YAC5B,MAAM,CAAC,aAAa,CAAC,GAAG,CAAC;YACzB,GAAG,GAAG,CAAC;YACP,CAAC,CAAC,GAAG;YACL,CAAC,CAAC,SAAS,CAAC;IAChB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,SAAS,WAAW,CAClB,OAA0B,EAC1B,KAAiB,EACjB,KAAoB,EACpB,YAAiC;IAEjC,MAAM,YAAY,GAAwB;QACxC,QAAQ,EAAE,IAAI;QACd,KAAK,EAAE,KAAK;QACZ,KAAK,EAAE,QAAQ;QACf,WAAW,EAAE,IAAI;KAClB,CAAC;IAEF,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;QAChC,YAAY,CAAC,GAAG,GAAG,OAAO,CAAC,WAAW,CAAC;IACzC,CAAC;IAED,IAAI,KAAmB,CAAC;IACxB,IAAI,CAAC;QACH,KAAK,GAAG,YAAY,CAAC,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;IAC7E,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;YAC5B,IAAI,EAAE,sBAAsB;YAC5B,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,IAAI,EAAE,OAAO,CAAC,IAAI;YAClB,IAAI,EAAE,YAAY,CAAC,KAAK,CAAC;SAC1B,CAAC,CAAC;QACH,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC/B,KAAK,CAAC,cAAc,GAAG,cAAc,CAAC;QACxC,CAAC;QACD,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,GAAG,GAAG,eAAe,CAAC,KAAK,CAAC,CAAC;IACnC,MAAM,MAAM,GAAkB;QAC5B,KAAK;QACL,GAAG;QACH,OAAO,EAAE;YACP,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,IAAI,EAAE,OAAO,CAAC,IAAI;SACnB;QACD,aAAa,EAAE,KAAK,CAAC,iBAAiB;QACtC,MAAM,EAAE,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;QACxB,UAAU,EAAE,OAAO,CAAC,UAAU;QAC9B,MAAM,EAAE,QAAQ;QAChB,UAAU,EAAE,KAAK;KAClB,CAAC;IACF,KAAK,CAAC,iBAAiB,IAAI,CAAC,CAAC;IAC7B,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAE3B,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;QAC5B,IAAI,EAAE,iBAAiB;QACvB,GAAG,MAAM,CAAC,OAAO;QACjB,GAAG;KACJ,CAAC,CAAC;IAEH,IAAI,CAAC;QACH,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE;YAC5B,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;gBAC5B,IAAI,EAAE,qBAAqB;gBAC3B,GAAG,MAAM,CAAC,OAAO;gBACjB,IAAI,EAAE,YAAY,CAAC,KAAK,CAAC;aAC1B,CAAC,CAAC;YAEH,IAAI,MAAM,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;gBAC7B,KAAK,sBAAsB,CACzB,YAAY,CAAC,KAAK,EAClB,KAAK,CAAC,UAAU,EAChB,KAAK,EAAE,YAAY,EAAE,EAAE;oBACrB,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;wBAC3C,OAAO;oBACT,CAAC;oBAED,YAAY,CAAC,OAAO,GAAG,YAAY,CAAC,OAAO,CAAC,MAAM,CAChD,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,KAAK,MAAM,CACpC,CAAC;oBACF,IAAI,YAAY,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;wBACtC,YAAY,CAAC,cAAc,GAAG,cAAc,CAAC;oBAC/C,CAAC;gBACH,CAAC,CACF,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;YAC3B,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;QACP,8EAA8E;IAChF,CAAC;IAED,IAAI,CAAC;QACH,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE;YACtC,MAAM,CAAC,UAAU,GAAG,IAAI,CAAC;YACzB,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;gBAC5B,IAAI,EAAE,gBAAgB;gBACtB,GAAG,MAAM,CAAC,OAAO;gBACjB,QAAQ;gBACR,MAAM;aACP,CAAC,CAAC;YAEH,KAAK,sBAAsB,CACzB,YAAY,CAAC,KAAK,EAClB,KAAK,CAAC,UAAU,EAChB,KAAK,EAAE,YAAY,EAAE,EAAE;gBACrB,IACE,CAAC,YAAY,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;oBACtC,MAAM,CAAC,WAAW,KAAK,SAAS,EAChC,CAAC;oBACD,OAAO;gBACT,CAAC;gBAED,MAAM,MAAM,GAAG,MAAM,iBAAiB,CACpC,MAAM,EACN,aAAa,EACb,YAAY,CACb,CAAC;gBACF,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;oBAC3C,OAAO;gBACT,CAAC;gBAED,qBAAqB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;gBACpD,IAAI,YAAY,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBACtC,YAAY,CAAC,cAAc,GAAG,cAAc,CAAC;gBAC/C,CAAC;YACH,CAAC,CACF,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAC3B,CAAC,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;QACP,8EAA8E;IAChF,CAAC;IAED,IAAI,CAAC;QACH,KAAK,CAAC,KAAK,EAAE,CAAC;IAChB,CAAC;IAAC,MAAM,CAAC;QACP,yEAAyE;IAC3E,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,iBAAiB,CACxB,MAAqB,EACrB,OAA2B,EAC3B,YAAiC;IAEjC,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC;QACvB,OAAO,MAAM,CAAC,WAAW,CAAC;IAC5B,CAAC;IAED,MAAM,CAAC,MAAM,GAAG,UAAU,CAAC;IAC3B,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;QAC5B,IAAI,EAAE,wBAAwB;QAC9B,GAAG,MAAM,CAAC,OAAO;QACjB,GAAG,EAAE,MAAM,CAAC,GAAG;QACf,OAAO;KACR,CAAC,CAAC;IAEH,IAAI,WAA2C,CAAC;IAChD,IAAI,CAAC;QACH,WAAW,GAAG,OAAO,CAAC,OAAO,CAC3B,YAAY,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,EAAE;YACxC,YAAY,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,UAAU;YACrC,OAAO,EAAE,GAAG,EAAE;gBACZ,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;oBAC5B,IAAI,EAAE,qBAAqB;oBAC3B,GAAG,MAAM,CAAC,OAAO;oBACjB,GAAG,EAAE,MAAM,CAAC,GAAG;oBACf,OAAO;iBACR,CAAC,CAAC;YACL,CAAC;SACF,CAAC,CACH,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC;YAC5B,MAAM,EAAE,QAAQ;YAChB,MAAM,EAAE,aAAa;YACrB,cAAc,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM;SACpD,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CAAC,WAAW,GAAG,WAAW;SAC7B,KAAK,CACJ,GAA0B,EAAE,CAAC,CAAC;QAC5B,MAAM,EAAE,QAAQ;QAChB,MAAM,EAAE,aAAa;QACrB,cAAc,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM;KACpD,CAAC,CACH;SACA,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE;QACf,IAAI,MAAM,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YAC/B,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;gBAC5B,IAAI,EAAE,qBAAqB;gBAC3B,GAAG,MAAM,CAAC,OAAO;gBACjB,GAAG,EAAE,MAAM,CAAC,GAAG;gBACf,OAAO;gBACP,MAAM,EAAE,MAAM,CAAC,MAAM;aACtB,CAAC,CAAC;QACL,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC,CAAC,CAAC;IACL,OAAO,MAAM,CAAC,WAAW,CAAC;AAC5B,CAAC;AAED,SAAS,qBAAqB,CAC5B,KAAoB,EACpB,MAAqB,EACrB,MAA6B;IAE7B,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QAChC,KAAK,CAAC,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,KAAK,MAAM,CAAC,CAAC;QAC1E,OAAO;IACT,CAAC;IAED,IAAI,MAAM,CAAC,UAAU,IAAI,MAAM,CAAC,cAAc,KAAK,MAAM,EAAE,CAAC;QAC1D,KAAK,CAAC,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,KAAK,MAAM,CAAC,CAAC;QAC1E,KAAK,CAAC,kBAAkB,GAAG,IAAI,CAAC;QAChC,KAAK,CAAC,MAAM,GAAG,UAAU,CAAC;QAC1B,OAAO;IACT,CAAC;IAED,MAAM,CAAC,MAAM,GAAG,QAAQ,CAAC;IACzB,MAAM,CAAC,WAAW,GAAG,SAAS,CAAC;AACjC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,QAAsC,EACtC,YAAiC;IAEjC,MAAM,KAAK,GAAG,MAAM,CAAC,uBAAuB,CAAC,CAAC;IAC9C,MAAM,WAAW,GAAG,IAAI,GAAG,EAAU,CAAC;IACtC,IAAI,cAAyC,CAAC;IAC9C,MAAM,UAAU,GAAsB;QACpC,OAAO;YACL,cAAc,KAAK,OAAO,CAAC,GAAG,CAC5B,CAAC,GAAG,WAAW,CAAC,CAAC,GAAG,CAAC,CAAC,UAAU,EAAE,EAAE,CAClC,sBAAsB,CACpB,YAAY,CAAC,KAAK,EAClB,UAAU,EACV,KAAK,EAAE,KAAK,EAAE,EAAE;gBACd,MAAM,aAAa,GAAoB,EAAE,CAAC;gBAC1C,KAAK,MAAM,MAAM,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;oBACnC,IACE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC;wBAC3B,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC;wBACxB,MAAM,CAAC,UAAU,EACjB,CAAC;wBACD,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;oBAC7B,CAAC;gBACH,CAAC;gBAED,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,GAAG,CAC/B,aAAa,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAC3B,iBAAiB,CACf,MAAM,EACN,gBAAgB,EAChB,YAAY,CACb,CACF,CACF,CAAC;gBACF,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE;oBAChC,qBAAqB,CACnB,KAAK,EACL,aAAa,CAAC,KAAK,CAAE,EACrB,MAAM,CACP,CAAC;gBACJ,CAAC,CAAC,CAAC;YACL,CAAC,CACF,CACF,CACF;iBACE,IAAI,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC;iBACrB,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;YAC1B,OAAO,cAAc,CAAC;QACxB,CAAC;KACF,CAAC;IAEF,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;QAC5B,IAAI,EAAE,oBAAoB;QAC1B,YAAY,EAAE,QAAQ,CAAC,MAAM;KAC9B,CAAC,CAAC;IAEH,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;YAC5B,IAAI,EAAE,eAAe;YACrB,MAAM,EAAE,mBAAmB;SAC5B,CAAC,CAAC;QACH,OAAO,UAAU,CAAC;IACpB,CAAC;IAED,MAAM,cAAc,GAAG,IAAI,GAAG,EAA0B,CAAC;IACzD,MAAM,eAAe,GAAG,IAAI,GAAG,EAA0B,CAAC;IAC1D,MAAM,iBAAiB,GAAqB,EAAE,CAAC;IAE/C,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE;QAClC,MAAM,SAAS,GAAG,mBAAmB,CAAC,OAAO,CAAC,CAAC;QAC/C,MAAM,cAAc,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;QAErD,IAAI,OAAO,CAAC,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC/B,IAAI,cAAc,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC;gBAClC,iBAAiB,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;YACzC,CAAC;iBAAM,CAAC;gBACN,cAAc,CAAC,GAAG,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;YAChD,CAAC;YACD,OAAO;QACT,CAAC;QAED,MAAM,UAAU,GAAG,aAAa,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;QACrD,IAAI,eAAe,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC;YACpC,iBAAiB,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QACzC,CAAC;aAAM,CAAC;YACN,eAAe,CAAC,GAAG,CAAC,UAAU,EAAE,cAAc,CAAC,CAAC;QAClD,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,MAAM,qBAAqB,GAAG,CAAC,GAAG,eAAe,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAChE,CAAC,cAAc,EAAE,EAAE;QACjB,IAAI,cAAc,CAAC,GAAG,CAAC,cAAc,CAAC,SAAS,CAAC,EAAE,CAAC;YACjD,iBAAiB,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;YACvC,OAAO,KAAK,CAAC;QACf,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC,CACF,CAAC;IACF,MAAM,cAAc,GAAG;QACrB,GAAG,cAAc,CAAC,MAAM,EAAE;QAC1B,GAAG,qBAAqB;KACzB,CAAC;IACF,IAAI,gBAAgB,GAAG,KAAK,CAAC;IAE7B,iBAAiB;SACd,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC;SAC/C,OAAO,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE;QACvB,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;YAC5B,IAAI,EAAE,iBAAiB;YACvB,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,IAAI,EAAE,OAAO,CAAC,IAAI;YAClB,MAAM,EAAE,WAAW;SACpB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEL,KAAK,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,IAAI,cAAc,EAAE,CAAC;QACpD,MAAM,UAAU,GAAG,aAAa,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;QACrD,MAAM,SAAS,GAAG,MAAM,sBAAsB,CAC5C,YAAY,CAAC,KAAK,EAClB,UAAU,EACV,KAAK,EAAE,KAAK,EAAE,EAAE;YACd,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBAC/B,IACE,KAAK,CAAC,cAAc,KAAK,SAAS;oBAClC,KAAK,CAAC,kBAAkB,EACxB,CAAC;oBACD,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;wBAC5B,IAAI,EAAE,iBAAiB;wBACvB,KAAK,EAAE,OAAO,CAAC,KAAK;wBACpB,KAAK,EAAE,OAAO,CAAC,KAAK;wBACpB,IAAI,EAAE,OAAO,CAAC,IAAI;wBAClB,MAAM,EAAE,iBAAiB;qBAC1B,CAAC,CAAC;oBACH,OAAO,KAAK,CAAC;gBACf,CAAC;gBAED,MAAM,MAAM,GAAG,WAAW,CAAC,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,YAAY,CAAC,CAAC;gBAChE,IAAI,MAAM,EAAE,CAAC;oBACX,WAAW,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;gBAC9B,CAAC;gBACD,OAAO,IAAI,CAAC;YACd,CAAC;YAED,IAAI,OAAO,CAAC,iBAAiB,KAAK,OAAO,EAAE,CAAC;gBAC1C,MAAM,MAAM,GAAG,WAAW,CAAC,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,YAAY,CAAC,CAAC;gBAChE,IAAI,MAAM,EAAE,CAAC;oBACX,WAAW,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;gBAC9B,CAAC;gBACD,OAAO,IAAI,CAAC;YACd,CAAC;YAED,IAAI,OAAO,CAAC,iBAAiB,KAAK,MAAM,EAAE,CAAC;gBACzC,MAAM,YAAY,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CACrC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,KAAK,QAAQ,CACvC,CAAC;gBACF,IAAI,YAAY,EAAE,CAAC;oBACjB,YAAY,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;oBAC/B,WAAW,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;gBAC9B,CAAC;gBACD,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;oBAC5B,IAAI,EAAE,iBAAiB;oBACvB,KAAK,EAAE,OAAO,CAAC,KAAK;oBACpB,KAAK,EAAE,OAAO,CAAC,KAAK;oBACpB,IAAI,EAAE,OAAO,CAAC,IAAI;oBAClB,MAAM,EAAE,iBAAiB;iBAC1B,CAAC,CAAC;gBACH,OAAO,KAAK,CAAC;YACf,CAAC;YAED,IAAI,KAAK,CAAC,kBAAkB,EAAE,CAAC;gBAC7B,KAAK,CAAC,MAAM,GAAG,UAAU,CAAC;gBAC1B,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;oBAC5B,IAAI,EAAE,qBAAqB;oBAC3B,KAAK,EAAE,OAAO,CAAC,KAAK;oBACpB,KAAK,EAAE,OAAO,CAAC,KAAK;oBACpB,IAAI,EAAE,OAAO,CAAC,IAAI;oBAClB,GAAG,EAAE,SAAS;oBACd,OAAO,EAAE,SAAS;oBAClB,MAAM,EAAE,aAAa;iBACtB,CAAC,CAAC;gBACH,OAAO,IAAI,CAAC;YACd,CAAC;YAED,KAAK,CAAC,MAAM,GAAG,YAAY,CAAC;YAC5B,MAAM,QAAQ,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;gBAC9C,MAAM;gBACN,MAAM,EAAE,IAAI,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC;aAC/B,CAAC,CAAC,CAAC;YACJ,MAAM,UAAU,GAAG,IAAI,GAAG,CAAa,CAAC,KAAK,CAAC,CAAC,CAAC;YAChD,KAAK,MAAM,EAAE,MAAM,EAAE,IAAI,QAAQ,EAAE,CAAC;gBAClC,KAAK,MAAM,WAAW,IAAI,MAAM,EAAE,CAAC;oBACjC,UAAU,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;gBAC9B,CAAC;YACH,CAAC;YACD,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,GAAG,CAC/B,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAC1B,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,YAAY,CAAC,CACnD,CACF,CAAC;YAEF,MAAM,aAAa,GAAoB,EAAE,CAAC;YAC1C,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAa,CAAC,KAAK,CAAC,CAAC,CAAC;YACvD,IAAI,UAAU,GAAG,KAAK,CAAC;YACvB,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE;gBAChC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,QAAQ,CAAC,KAAK,CAAE,CAAC;gBAC5C,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;oBAChC,KAAK,MAAM,WAAW,IAAI,MAAM,EAAE,CAAC;wBACjC,iBAAiB,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;oBACrC,CAAC;oBACD,OAAO;gBACT,CAAC;gBAED,UAAU,GAAG,IAAI,CAAC;gBAClB,IAAI,MAAM,CAAC,UAAU,IAAI,MAAM,CAAC,cAAc,KAAK,MAAM,EAAE,CAAC;oBAC1D,KAAK,CAAC,kBAAkB,GAAG,IAAI,CAAC;oBAChC,KAAK,MAAM,WAAW,IAAI,MAAM,EAAE,CAAC;wBACjC,iBAAiB,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;oBACrC,CAAC;oBACD,OAAO;gBACT,CAAC;gBAED,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;gBACtB,KAAK,MAAM,WAAW,IAAI,MAAM,EAAE,CAAC;oBACjC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;gBACjC,CAAC;gBACD,MAAM,CAAC,MAAM,GAAG,QAAQ,CAAC;gBACzB,MAAM,CAAC,WAAW,GAAG,SAAS,CAAC;gBAC/B,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YAC7B,CAAC,CAAC,CAAC;YACH,KAAK,CAAC,OAAO,GAAG,aAAa,CAAC;YAE9B,IAAI,CAAC,UAAU,EAAE,CAAC;gBAChB,MAAM,WAAW,GAAG,WAAW,CAC7B,OAAO,EACP,KAAK,EACL,KAAK,EACL,YAAY,CACb,CAAC;gBACF,IAAI,WAAW,EAAE,CAAC;oBAChB,KAAK,MAAM,WAAW,IAAI,UAAU,EAAE,CAAC;wBACrC,WAAW,CAAC,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;oBACtC,CAAC;oBACD,KAAK,CAAC,cAAc,GAAG,SAAS,CAAC;oBACjC,WAAW,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;gBAC9B,CAAC;gBACD,KAAK,CAAC,MAAM,GAAG,QAAQ,CAAC;gBACxB,OAAO,IAAI,CAAC;YACd,CAAC;YAED,MAAM,cAAc,GAAG,aAAa,CAAC,CAAC,CAAC,CAAC;YACxC,IAAI,cAAc,EAAE,CAAC;gBACnB,KAAK,MAAM,WAAW,IAAI,iBAAiB,EAAE,CAAC;oBAC5C,cAAc,CAAC,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;gBACzC,CAAC;gBACD,WAAW,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;YAC9B,CAAC;YACD,KAAK,CAAC,MAAM,GAAG,UAAU,CAAC;YAC1B,OAAO,IAAI,CAAC;QACd,CAAC,CACF,CAAC;QACF,gBAAgB,KAAK,SAAS,CAAC;IACjC,CAAC;IAED,IAAI,CAAC,gBAAgB,EAAE,CAAC;QACtB,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE;YAC5B,IAAI,EAAE,eAAe;YACrB,MAAM,EAAE,iBAAiB;SAC1B,CAAC,CAAC;IACL,CAAC;IAED,OAAO,UAAU,CAAC;AACpB,CAAC"} \ No newline at end of file +{"version":3,"file":"core.js","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/durable-lock.d.ts b/dist/durable-lock.d.ts new file mode 100644 index 0000000..b5d1229 --- /dev/null +++ b/dist/durable-lock.d.ts @@ -0,0 +1,36 @@ +import type { ProcessIdentityController } from "./process-identity.js"; +export interface DurableLockHolder { + pid: number; + startToken: string; +} +export interface DurableLockHandle { + release(): void; +} +export type DurableLockResult = { + status: "acquired"; + handle: DurableLockHandle; +} | { + status: "unavailable"; +}; +export interface DurableLock { + acquireLock(holder: DurableLockHolder): Promise; +} +export interface DurableLockOptions { + lockDirectory: string; + lockHolderPath: string; + identity?: ProcessIdentityController; + delay?: (milliseconds: number) => Promise; + lockTimeoutMs?: number; + lockPollIntervalMs?: number; + nowMs?: () => number; + random?: () => string; + mkdir?: (path: string) => void; + writeFile?: (path: string, content: string) => void; + readFile?: (path: string) => string; + rm?: (path: string) => void; + stat?: (path: string) => { + mtimeMs: number; + }; +} +export declare function createDurableLock(options: DurableLockOptions): DurableLock; +//# sourceMappingURL=durable-lock.d.ts.map \ No newline at end of file diff --git a/dist/durable-lock.d.ts.map b/dist/durable-lock.d.ts.map new file mode 100644 index 0000000..6941aa6 --- /dev/null +++ b/dist/durable-lock.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"durable-lock.d.ts","sourceRoot":"","sources":["../src/durable-lock.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,yBAAyB,EAE1B,MAAM,uBAAuB,CAAC;AAG/B,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,MAAM,iBAAiB,GACzB;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,iBAAiB,CAAA;CAAE,GACjD;IAAE,MAAM,EAAE,aAAa,CAAA;CAAE,CAAC;AAE9B,MAAM,WAAW,WAAW;IAC1B,WAAW,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;CACpE;AAED,MAAM,WAAW,kBAAkB;IACjC,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,yBAAyB,CAAC;IACrC,KAAK,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,EAAE,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9C;AAcD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CA2Q1E"} \ No newline at end of file diff --git a/dist/durable-lock.js b/dist/durable-lock.js new file mode 100644 index 0000000..4137c26 --- /dev/null +++ b/dist/durable-lock.js @@ -0,0 +1,240 @@ +import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { getErrorCode } from "./error-code.js"; +const DEFAULT_LOCK_TIMEOUT_MS = 15_000; +const DEFAULT_LOCK_POLL_INTERVAL_MS = 50; +const GUARD_STALE_AFTER_MS = 1_000; +const RECLAIM_GUARD_SUFFIX = ".reclaim"; +const DIRECTORY_MODE = 0o700; +const FILE_MODE = 0o600; +export function createDurableLock(options) { + const { lockDirectory, lockHolderPath } = options; + const identity = options.identity; + const delay = options.delay ?? + ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS; + const lockPollIntervalMs = options.lockPollIntervalMs ?? DEFAULT_LOCK_POLL_INTERVAL_MS; + const nowMs = options.nowMs ?? Date.now; + const random = options.random ?? (() => randomBytes(8).toString("hex")); + const mkdir = options.mkdir ?? + ((path) => mkdirSync(path, { mode: DIRECTORY_MODE })); + const writeFile = options.writeFile ?? + ((path, content) => writeFileSync(path, content, { encoding: "utf8", mode: FILE_MODE })); + const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8")); + const rm = options.rm ?? + ((path) => rmSync(path, { recursive: true, force: true })); + const stat = options.stat ?? ((path) => statSync(path)); + const reclaimGuardDirectory = `${lockDirectory}${RECLAIM_GUARD_SUFFIX}`; + function removeLock() { + try { + rm(lockDirectory); + return true; + } + catch { + return false; + } + } + function isLockDirectoryEmpty() { + try { + readFile(lockHolderPath); + return false; + } + catch (error) { + return getErrorCode(error) === "ENOENT"; + } + } + function classifyHolderFile() { + let content; + try { + content = readFile(lockHolderPath); + } + catch (error) { + return getErrorCode(error) === "ENOENT" + ? { kind: "missing" } + : { kind: "malformed", content: undefined }; + } + let parsed; + try { + parsed = JSON.parse(content); + } + catch { + return { kind: "malformed", content }; + } + if (typeof parsed !== "object" || parsed === null) { + return { kind: "malformed", content }; + } + const candidate = parsed; + const generation = candidate.generation; + const pid = candidate.pid; + const startToken = candidate.startToken; + if (typeof generation !== "string" || + !generation || + typeof pid !== "number" || + !Number.isSafeInteger(pid) || + pid <= 0 || + typeof startToken !== "string" || + !startToken) { + return { kind: "malformed", content }; + } + return { kind: "valid", generation, pid, startToken }; + } + function isGuardStale() { + let mtimeMs; + try { + mtimeMs = stat(reclaimGuardDirectory).mtimeMs; + } + catch { + return false; + } + return nowMs() - mtimeMs > GUARD_STALE_AFTER_MS; + } + function tryAcquireGuard() { + try { + mkdir(reclaimGuardDirectory); + return true; + } + catch (error) { + if (getErrorCode(error) !== "EEXIST") { + return false; + } + if (!isGuardStale()) { + return false; + } + try { + rm(reclaimGuardDirectory); + mkdir(reclaimGuardDirectory); + return true; + } + catch { + return false; + } + } + } + function releaseGuard() { + try { + rm(reclaimGuardDirectory); + } + catch { } + } + async function attemptGuardedRemoval(expectation) { + if (!tryAcquireGuard()) { + return false; + } + try { + const fresh = classifyHolderFile(); + if (expectation.kind === "valid") { + return fresh.kind === "valid" && + fresh.generation === expectation.generation + ? removeLock() + : false; + } + if (expectation.kind === "malformed") { + return fresh.kind === "malformed" && + fresh.content === expectation.content + ? removeLock() + : false; + } + return fresh.kind === "missing" ? removeLock() : false; + } + finally { + releaseGuard(); + } + } + function createHandle(generation) { + return { + release() { + try { + const parsed = JSON.parse(readFile(lockHolderPath)); + if (parsed.generation !== generation) { + return; + } + } + catch { + return; + } + removeLock(); + }, + }; + } + return { + async acquireLock(holder) { + const generation = random(); + const deadline = nowMs() + lockTimeoutMs; + let missingHolderStreak = 0; + async function tryReclaim() { + const classification = classifyHolderFile(); + if (classification.kind === "missing") { + missingHolderStreak += 1; + if (missingHolderStreak < 2) { + return false; + } + } + else { + missingHolderStreak = 0; + } + let reclaimed; + if (classification.kind === "valid") { + if (!identity) { + return false; + } + let probed; + try { + probed = await identity.probe(classification.pid, classification.startToken); + } + catch { + return false; + } + if (probed.status !== "gone") { + return false; + } + reclaimed = await attemptGuardedRemoval({ + kind: "valid", + generation: classification.generation, + }); + } + else if (classification.kind === "malformed") { + reclaimed = await attemptGuardedRemoval({ + kind: "malformed", + content: classification.content, + }); + } + else { + reclaimed = await attemptGuardedRemoval({ kind: "missing" }); + } + if (reclaimed) { + missingHolderStreak = 0; + } + return reclaimed; + } + for (;;) { + let createdLockDirectory = false; + try { + mkdir(lockDirectory); + createdLockDirectory = true; + writeFile(lockHolderPath, JSON.stringify({ + generation, + pid: holder.pid, + startToken: holder.startToken, + })); + return { status: "acquired", handle: createHandle(generation) }; + } + catch (error) { + if (getErrorCode(error) !== "EEXIST") { + if (createdLockDirectory && isLockDirectoryEmpty()) { + removeLock(); + } + return { status: "unavailable" }; + } + } + if (nowMs() >= deadline) { + return { status: "unavailable" }; + } + if (await tryReclaim()) { + continue; + } + await delay(lockPollIntervalMs + Math.floor(Math.random() * lockPollIntervalMs)); + } + }, + }; +} +//# sourceMappingURL=durable-lock.js.map \ No newline at end of file diff --git a/dist/durable-lock.js.map b/dist/durable-lock.js.map new file mode 100644 index 0000000..997d77f --- /dev/null +++ b/dist/durable-lock.js.map @@ -0,0 +1 @@ +{"version":3,"file":"durable-lock.js","sourceRoot":"","sources":["../src/durable-lock.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/durable-registry.d.ts b/dist/durable-registry.d.ts new file mode 100644 index 0000000..f39a5ee --- /dev/null +++ b/dist/durable-registry.d.ts @@ -0,0 +1,56 @@ +import { type DurableLockHolder, type DurableLockResult } from "./durable-lock.js"; +import type { ProcessIdentityController } from "./process-identity.js"; +export type { DurableLockHandle, DurableLockHolder, DurableLockResult, } from "./durable-lock.js"; +interface DurableRecordFields { + identityHash: string; + pid: number; + startToken: string; + stopOnExit: false; + creationOrder: number; + recordedAt: string; +} +export type DurableRecord = (DurableRecordFields & { + scope: "global"; + projectRootHash?: never; +}) | (DurableRecordFields & { + scope: "project"; + projectRootHash: string; +}); +export type DurableUnavailableReason = "unreadable" | "malformed" | "unsupported-schema"; +export type DurableReadResult = { + status: "loaded"; + records: DurableRecord[]; + quarantined?: true; +} | { + status: "unavailable"; + reason: DurableUnavailableReason; +}; +export interface DurableRegistry { + hashIdentity(signature: string): string; + hashProjectRoot(normalizedRoot: string): string; + exists(): boolean; + read(): DurableReadResult; + write(records: readonly DurableRecord[]): boolean; + acquireLock(holder: DurableLockHolder): Promise; +} +export interface DurableRegistryOptions { + root?: string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + home?: string; + hostname?: string; + random?: () => string; + identity?: ProcessIdentityController; + delay?: (milliseconds: number) => Promise; + lockTimeoutMs?: number; + lockPollIntervalMs?: number; + clock?: { + nowMs: () => number; + stat: (path: string) => { + mtimeMs: number; + }; + }; +} +export declare function resolveDurableStateRoot(options?: Pick): string; +export declare function createDurableRegistry(options?: DurableRegistryOptions): DurableRegistry; +//# sourceMappingURL=durable-registry.d.ts.map \ No newline at end of file diff --git a/dist/durable-registry.d.ts.map b/dist/durable-registry.d.ts.map new file mode 100644 index 0000000..c07ef93 --- /dev/null +++ b/dist/durable-registry.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"durable-registry.d.ts","sourceRoot":"","sources":["../src/durable-registry.ts"],"names":[],"mappings":"AAWA,OAAO,EAEL,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACvB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,uBAAuB,CAAC;AAGvE,YAAY,EACV,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,mBAAmB,CAAC;AAE3B,UAAU,mBAAmB;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,KAAK,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,aAAa,GACrB,CAAC,mBAAmB,GAAG;IAAE,KAAK,EAAE,QAAQ,CAAC;IAAC,eAAe,CAAC,EAAE,KAAK,CAAA;CAAE,CAAC,GACpE,CAAC,mBAAmB,GAAG;IAAE,KAAK,EAAE,SAAS,CAAC;IAAC,eAAe,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAE1E,MAAM,MAAM,wBAAwB,GAChC,YAAY,GACZ,WAAW,GACX,oBAAoB,CAAC;AAEzB,MAAM,MAAM,iBAAiB,GACzB;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,aAAa,EAAE,CAAC;IAAC,WAAW,CAAC,EAAE,IAAI,CAAA;CAAE,GAClE;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,wBAAwB,CAAA;CAAE,CAAC;AAEhE,MAAM,WAAW,eAAe;IAC9B,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IACxC,eAAe,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAAC;IAChD,MAAM,IAAI,OAAO,CAAC;IAClB,IAAI,IAAI,iBAAiB,CAAC;IAC1B,KAAK,CAAC,OAAO,EAAE,SAAS,aAAa,EAAE,GAAG,OAAO,CAAC;IAClD,WAAW,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;CACpE;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,yBAAyB,CAAC;IACrC,KAAK,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,KAAK,CAAC,EAAE;QACN,KAAK,EAAE,MAAM,MAAM,CAAC;QACpB,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;YAAE,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC;KAC7C,CAAC;CACH;AAsBD,wBAAgB,uBAAuB,CACrC,OAAO,GAAE,IAAI,CACX,sBAAsB,EACtB,UAAU,GAAG,KAAK,GAAG,MAAM,GAAG,UAAU,CACpC,GACL,MAAM,CAgCR;AAqCD,wBAAgB,qBAAqB,CACnC,OAAO,GAAE,sBAA2B,GACnC,eAAe,CAiIjB"} \ No newline at end of file diff --git a/dist/durable-registry.js b/dist/durable-registry.js new file mode 100644 index 0000000..4c3893c --- /dev/null +++ b/dist/durable-registry.js @@ -0,0 +1,188 @@ +import { createHash, randomBytes } from "node:crypto"; +import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs"; +import { homedir, hostname } from "node:os"; +import { posix, win32 } from "node:path"; +import { createDurableLock, } from "./durable-lock.js"; +import { getErrorCode } from "./error-code.js"; +const SCHEMA_VERSION = 1; +const REGISTRY_FILE_NAME = "registry.json"; +const LOCK_DIRECTORY_NAME = "registry.lock"; +const LOCK_HOLDER_FILE_NAME = "holder.json"; +const DIRECTORY_MODE = 0o700; +const FILE_MODE = 0o600; +const IDENTITY_HASH_PREFIX = "identity:"; +const PROJECT_ROOT_HASH_PREFIX = "project-root:"; +const HOST_SEGMENT_MAX_LENGTH = 64; +const UNKNOWN_HOST_SEGMENT = "unknown-host"; +function sanitizeHostSegment(rawHostname) { + const segment = rawHostname + .toLowerCase() + .replace(/[^a-z0-9.-]/g, "-") + .slice(0, HOST_SEGMENT_MAX_LENGTH) + .replace(/^[.-]+|[.-]+$/g, ""); + return segment || UNKNOWN_HOST_SEGMENT; +} +export function resolveDurableStateRoot(options = {}) { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const home = options.home ?? homedir(); + const host = sanitizeHostSegment(options.hostname ?? hostname()); + if (platform === "win32") { + const configuredRoot = env.LOCALAPPDATA?.trim(); + const root = configuredRoot && win32.isAbsolute(configuredRoot) + ? configuredRoot + : win32.join(home, "AppData", "Local"); + return win32.join(root, "opencode", "startup-commands", host); + } + if (platform === "darwin") { + return posix.join(home, "Library", "Application Support", "OpenCode", "startup-commands", host); + } + const configuredRoot = env.XDG_STATE_HOME?.trim(); + const root = configuredRoot && posix.isAbsolute(configuredRoot) + ? configuredRoot + : posix.join(home, ".local", "state"); + return posix.join(root, "opencode", "startup-commands", host); +} +function isHexDigest(value) { + return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); +} +function isValidRecord(value) { + if (typeof value !== "object" || value === null) { + return false; + } + const candidate = value; + if (!isHexDigest(candidate.identityHash) || + typeof candidate.pid !== "number" || + !Number.isSafeInteger(candidate.pid) || + candidate.pid <= 0 || + typeof candidate.startToken !== "string" || + !candidate.startToken || + candidate.stopOnExit !== false || + typeof candidate.creationOrder !== "number" || + !Number.isSafeInteger(candidate.creationOrder) || + candidate.creationOrder < 0 || + typeof candidate.recordedAt !== "string") { + return false; + } + if (candidate.scope === "global") { + return candidate.projectRootHash === undefined; + } + if (candidate.scope === "project") { + return isHexDigest(candidate.projectRootHash); + } + return false; +} +export function createDurableRegistry(options = {}) { + const root = options.root ?? resolveDurableStateRoot(options); + const random = options.random ?? (() => randomBytes(8).toString("hex")); + const nowMs = options.clock?.nowMs ?? Date.now; + const platform = options.platform ?? process.platform; + const joinPath = platform === "win32" ? win32.join : posix.join; + const registryPath = joinPath(root, REGISTRY_FILE_NAME); + const lockDirectory = joinPath(root, LOCK_DIRECTORY_NAME); + const lockHolderPath = joinPath(lockDirectory, LOCK_HOLDER_FILE_NAME); + const lock = createDurableLock({ + lockDirectory, + lockHolderPath, + identity: options.identity, + delay: options.delay, + lockTimeoutMs: options.lockTimeoutMs, + lockPollIntervalMs: options.lockPollIntervalMs, + nowMs: options.clock?.nowMs, + stat: options.clock?.stat, + random, + }); + function quarantineCorruptRegistry() { + try { + renameSync(registryPath, `${registryPath}.corrupt-${nowMs()}`); + } + catch { + return { status: "unavailable", reason: "malformed" }; + } + return { status: "loaded", records: [], quarantined: true }; + } + return { + hashIdentity(signature) { + return createHash("sha256") + .update(IDENTITY_HASH_PREFIX + signature, "utf8") + .digest("hex"); + }, + hashProjectRoot(normalizedRoot) { + return createHash("sha256") + .update(PROJECT_ROOT_HASH_PREFIX + normalizedRoot, "utf8") + .digest("hex"); + }, + exists() { + try { + statSync(registryPath); + return true; + } + catch { + return false; + } + }, + read() { + let content; + try { + content = readFileSync(registryPath, "utf8"); + } + catch (error) { + return getErrorCode(error) === "ENOENT" + ? { status: "loaded", records: [] } + : { status: "unavailable", reason: "unreadable" }; + } + let document; + try { + document = JSON.parse(content); + } + catch { + return quarantineCorruptRegistry(); + } + if (typeof document !== "object" || document === null) { + return quarantineCorruptRegistry(); + } + const shape = document; + if (shape.schemaVersion !== SCHEMA_VERSION) { + return typeof shape.schemaVersion === "number" + ? { status: "unavailable", reason: "unsupported-schema" } + : quarantineCorruptRegistry(); + } + if (!Array.isArray(shape.records) || + !shape.records.every(isValidRecord)) { + return quarantineCorruptRegistry(); + } + return { status: "loaded", records: [...shape.records] }; + }, + write(records) { + if (!records.every(isValidRecord)) { + return false; + } + const temporaryPath = `${registryPath}.${random()}.tmp`; + try { + mkdirSync(root, { recursive: true, mode: DIRECTORY_MODE }); + writeFileSync(temporaryPath, `${JSON.stringify({ schemaVersion: SCHEMA_VERSION, records }, undefined, 2)}\n`, { encoding: "utf8", mode: FILE_MODE }); + renameSync(temporaryPath, registryPath); + return true; + } + catch { + return false; + } + finally { + try { + rmSync(temporaryPath, { force: true }); + } + catch { } + } + }, + async acquireLock(holder) { + try { + mkdirSync(root, { recursive: true, mode: DIRECTORY_MODE }); + } + catch { + return { status: "unavailable" }; + } + return lock.acquireLock(holder); + }, + }; +} +//# sourceMappingURL=durable-registry.js.map \ No newline at end of file diff --git a/dist/durable-registry.js.map b/dist/durable-registry.js.map new file mode 100644 index 0000000..45f33fa --- /dev/null +++ b/dist/durable-registry.js.map @@ -0,0 +1 @@ +{"version":3,"file":"durable-registry.js","sourceRoot":"","sources":["../src/durable-registry.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/error-code.d.ts b/dist/error-code.d.ts new file mode 100644 index 0000000..d855bba --- /dev/null +++ b/dist/error-code.d.ts @@ -0,0 +1,2 @@ +export declare function getErrorCode(error: unknown): string | undefined; +//# sourceMappingURL=error-code.d.ts.map \ No newline at end of file diff --git a/dist/error-code.d.ts.map b/dist/error-code.d.ts.map new file mode 100644 index 0000000..def3b8c --- /dev/null +++ b/dist/error-code.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"error-code.d.ts","sourceRoot":"","sources":["../src/error-code.ts"],"names":[],"mappings":"AAAA,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAM/D"} \ No newline at end of file diff --git a/dist/error-code.js b/dist/error-code.js new file mode 100644 index 0000000..e509192 --- /dev/null +++ b/dist/error-code.js @@ -0,0 +1,8 @@ +export function getErrorCode(error) { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + const code = error.code; + return typeof code === "string" ? code : undefined; +} +//# sourceMappingURL=error-code.js.map \ No newline at end of file diff --git a/dist/error-code.js.map b/dist/error-code.js.map new file mode 100644 index 0000000..8f10f1a --- /dev/null +++ b/dist/error-code.js.map @@ -0,0 +1 @@ +{"version":3,"file":"error-code.js","sourceRoot":"","sources":["../src/error-code.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,CAAC,MAAM,IAAI,KAAK,CAAC,EAAE,CAAC;QACtE,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,IAAI,GAAI,KAA4B,CAAC,IAAI,CAAC;IAChD,OAAO,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;AACrD,CAAC"} \ No newline at end of file diff --git a/dist/logger.d.ts b/dist/logger.d.ts index 9611dfc..62709f8 100644 --- a/dist/logger.d.ts +++ b/dist/logger.d.ts @@ -2,6 +2,7 @@ import type { ProcessTreeFailureReason } from "./process-tree.js"; type LogErrorCode = "EACCES" | "ENOENT" | "EPERM"; type LogScope = "global" | "project"; export type CommandStopTrigger = "scope-disposed" | "root-exited" | "restart"; +export type DurableUnavailableCause = "registry-unreadable" | "registry-malformed" | "registry-unsupported-schema" | "lock-unavailable" | "identity-unavailable" | "registry-unwritable"; interface CommandStopContext { scope: LogScope; index: number; @@ -29,7 +30,7 @@ export type LogEvent = { scope: LogScope; index: number; name: string; - reason: "duplicate" | "already-started"; + reason: "duplicate" | "already-started" | "durable-unavailable"; } | { type: "command.spawned"; scope: LogScope; @@ -62,7 +63,34 @@ export type LogEvent = { } & CommandStopContext) | ({ type: "command.stop-failed"; reason: ProcessTreeFailureReason; -} & CommandStopContext); +} & CommandStopContext) | { + type: "durable.record-adopted"; + scope: LogScope; + index: number; + name: string; + pid: number; +} | { + type: "durable.record-dropped"; + scope: LogScope; + index?: number; + name?: string; + pid: number; +} | { + type: "durable.record-unverifiable"; + scope: LogScope; + index?: number; + name?: string; + pid?: number; +} | { + type: "durable.reconciled"; + scope: LogScope; + stoppedCount: number; +} | { + type: "durable.registry-quarantined"; +} | { + type: "durable.unavailable"; + cause: DurableUnavailableCause; +}; export interface Logger { write(event: LogEvent): void; } diff --git a/dist/logger.d.ts.map b/dist/logger.d.ts.map index aa3a1d9..67a2c3f 100644 --- a/dist/logger.d.ts.map +++ b/dist/logger.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/logger.js b/dist/logger.js index cb43818..a0d1730 100644 --- a/dist/logger.js +++ b/dist/logger.js @@ -47,6 +47,18 @@ function formatConsoleEvent(event) { return `stop forced ${formatStopContext(event)}`; case "command.stop-failed": return `stop failed ${formatStopContext(event)} reason=${event.reason}`; + case "durable.record-adopted": + return `record adopted scope=${event.scope} index=${event.index} name=${formatName(event.name)} pid=${event.pid}`; + case "durable.record-dropped": + return `record dropped scope=${event.scope}${event.index === undefined ? "" : ` index=${event.index}`}${event.name === undefined ? "" : ` name=${formatName(event.name)}`} pid=${event.pid}`; + case "durable.record-unverifiable": + return `record unverifiable scope=${event.scope}${event.index === undefined ? "" : ` index=${event.index}`}${event.name === undefined ? "" : ` name=${formatName(event.name)}`}${event.pid === undefined ? "" : ` pid=${event.pid}`}`; + case "durable.reconciled": + return `reconciled scope=${event.scope} stoppedCount=${event.stoppedCount}`; + case "durable.registry-quarantined": + return "registry quarantined"; + case "durable.unavailable": + return `durable unavailable cause=${event.cause}`; default: return assertNeverEvent(event); } @@ -75,6 +87,18 @@ function formatFileEvent(event) { return `${event.type} ${formatStopContext(event)}`; case "command.stop-failed": return `${event.type} ${formatStopContext(event)} reason=${event.reason}`; + case "durable.record-adopted": + return `${event.type} scope=${event.scope} index=${event.index} name=${formatName(event.name)} pid=${event.pid}`; + case "durable.record-dropped": + return `${event.type} scope=${event.scope}${event.index === undefined ? "" : ` index=${event.index}`}${event.name === undefined ? "" : ` name=${formatName(event.name)}`} pid=${event.pid}`; + case "durable.record-unverifiable": + return `${event.type} scope=${event.scope}${event.index === undefined ? "" : ` index=${event.index}`}${event.name === undefined ? "" : ` name=${formatName(event.name)}`}${event.pid === undefined ? "" : ` pid=${event.pid}`}`; + case "durable.reconciled": + return `${event.type} scope=${event.scope} stoppedCount=${event.stoppedCount}`; + case "durable.registry-quarantined": + return event.type; + case "durable.unavailable": + return `${event.type} cause=${event.cause}`; default: return assertNeverEvent(event); } diff --git a/dist/logger.js.map b/dist/logger.js.map index eae9fb2..ac1abc4 100644 --- a/dist/logger.js.map +++ b/dist/logger.js.map @@ -1 +1 @@ -{"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/process-identity.d.ts b/dist/process-identity.d.ts new file mode 100644 index 0000000..b80167c --- /dev/null +++ b/dist/process-identity.d.ts @@ -0,0 +1,48 @@ +export type ProcessIdentityUnknownReason = "permission-denied" | "facility-unavailable" | "malformed-output" | "timeout" | "unconfirmed"; +export type ProcessDescribeResult = { + status: "described"; + token: string; +} | { + status: "gone"; +} | { + status: "unknown"; + reason: ProcessIdentityUnknownReason; +}; +export type ProcessProbeResult = { + status: "alive"; +} | { + status: "gone"; +} | { + status: "unknown"; + reason: ProcessIdentityUnknownReason; +}; +export interface ProcessIdentityController { + describe(pid: number | undefined): Promise; + probe(pid: number | undefined, expectedToken: string): Promise; +} +export interface ProcessQuerySpawnOptions { + detached: false; + shell: false; + stdio: ["ignore", "pipe", "ignore"]; + windowsHide: true; + env?: NodeJS.ProcessEnv; +} +export interface ProcessQueryChild { + readonly stdout: { + on(event: "data", listener: (chunk: Buffer | string) => void): unknown; + } | null; + once(event: "error", listener: (error: Error) => void): ProcessQueryChild; + once(event: "close", listener: (code: number | null) => void): ProcessQueryChild; + kill(signal?: NodeJS.Signals | number): boolean; + unref(): void; +} +export type ProcessQuerySpawn = (executable: string, args: string[], options: ProcessQuerySpawnOptions) => ProcessQueryChild; +export interface ProcessIdentityControllerOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + readTextFile?: (path: string) => string; + spawn?: ProcessQuerySpawn; + timeoutMs?: number; +} +export declare function createProcessIdentityController(options?: ProcessIdentityControllerOptions): ProcessIdentityController; +//# sourceMappingURL=process-identity.d.ts.map \ No newline at end of file diff --git a/dist/process-identity.d.ts.map b/dist/process-identity.d.ts.map new file mode 100644 index 0000000..0188914 --- /dev/null +++ b/dist/process-identity.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"process-identity.d.ts","sourceRoot":"","sources":["../src/process-identity.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,4BAA4B,GACpC,mBAAmB,GACnB,sBAAsB,GACtB,kBAAkB,GAClB,SAAS,GACT,aAAa,CAAC;AAElB,MAAM,MAAM,qBAAqB,GAC7B;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,4BAA4B,CAAA;CAAE,CAAC;AAEhE,MAAM,MAAM,kBAAkB,GAC1B;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,GACnB;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,4BAA4B,CAAA;CAAE,CAAC;AAEhE,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAClE,KAAK,CACH,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,kBAAkB,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,EAAE,KAAK,CAAC;IAChB,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC;IACpC,WAAW,EAAE,IAAI,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,EAAE;QACf,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC;KACxE,GAAG,IAAI,CAAC;IACT,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,iBAAiB,CAAC;IAC1E,IAAI,CACF,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,GACtC,iBAAiB,CAAC;IACrB,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;IAChD,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,MAAM,iBAAiB,GAAG,CAC9B,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,wBAAwB,KAC9B,iBAAiB,CAAC;AAEvB,MAAM,WAAW,gCAAgC;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACxC,KAAK,CAAC,EAAE,iBAAiB,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAmPD,wBAAgB,+BAA+B,CAC7C,OAAO,GAAE,gCAAqC,GAC7C,yBAAyB,CA8C3B"} \ No newline at end of file diff --git a/dist/process-identity.js b/dist/process-identity.js new file mode 100644 index 0000000..2ab8049 --- /dev/null +++ b/dist/process-identity.js @@ -0,0 +1,228 @@ +import { spawn as spawnProcess } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { win32 } from "node:path"; +import { getErrorCode } from "./error-code.js"; +const LINUX_BOOT_ID_PATH = "/proc/sys/kernel/random/boot_id"; +const DEFAULT_TIMEOUT_MS = 5_000; +function isValidPid(pid) { + return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0; +} +function unknownReasonFor(error) { + const code = getErrorCode(error); + if (code === "EACCES" || code === "EPERM") { + return "permission-denied"; + } + return "unconfirmed"; +} +function tokenTagFor(platform) { + if (platform === "win32") { + return "win1"; + } + if (platform === "darwin") { + return "mac1"; + } + return "linux1"; +} +function parseStartTicks(statContent) { + const tailStart = statContent.lastIndexOf(")"); + if (tailStart < 0) { + return undefined; + } + const fields = statContent + .slice(tailStart + 1) + .trim() + .split(/\s+/); + const startTicks = fields[19]; + return startTicks !== undefined && /^\d+$/.test(startTicks) + ? startTicks + : undefined; +} +function describeLinux(pid, readTextFile) { + let bootId; + try { + bootId = readTextFile(LINUX_BOOT_ID_PATH).trim(); + } + catch (error) { + return { status: "unknown", reason: unknownReasonFor(error) }; + } + if (!/^[0-9a-f-]{4,64}$/.test(bootId)) { + return { status: "unknown", reason: "malformed-output" }; + } + let statContent; + try { + statContent = readTextFile(`/proc/${pid}/stat`); + } + catch (error) { + return getErrorCode(error) === "ENOENT" + ? { status: "gone" } + : { status: "unknown", reason: unknownReasonFor(error) }; + } + const startTicks = parseStartTicks(statContent); + return startTicks === undefined + ? { status: "unknown", reason: "malformed-output" } + : { status: "described", token: `linux1:${bootId}:${startTicks}` }; +} +const MAX_QUERY_OUTPUT_BYTES = 4096; +function runQuery(spawn, executable, args, timeoutMs, env) { + let child; + try { + child = spawn(executable, args, { + detached: false, + shell: false, + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + ...(env ? { env } : {}), + }); + } + catch (error) { + return Promise.resolve({ + status: "failed", + reason: getErrorCode(error) === "ENOENT" + ? "facility-unavailable" + : unknownReasonFor(error), + }); + } + return new Promise((resolve) => { + let settled = false; + let output = ""; + let timer; + const settle = (outcome) => { + if (settled) { + return; + } + settled = true; + if (timer !== undefined) { + clearTimeout(timer); + } + resolve(outcome); + }; + try { + child.stdout?.on("data", (chunk) => { + if (output.length < MAX_QUERY_OUTPUT_BYTES) { + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + output += text.slice(0, MAX_QUERY_OUTPUT_BYTES - output.length); + } + }); + child.once("error", (error) => { + settle({ + status: "failed", + reason: getErrorCode(error) === "ENOENT" + ? "facility-unavailable" + : unknownReasonFor(error), + }); + }); + child.once("close", (exitCode) => { + settle({ status: "completed", output, exitCode }); + }); + } + catch (error) { + settle({ status: "failed", reason: unknownReasonFor(error) }); + } + if (!settled) { + timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + try { + child.kill("SIGKILL"); + } + catch { } + try { + child.unref(); + } + catch { } + resolve({ status: "failed", reason: "timeout" }); + }, timeoutMs); + } + }); +} +function resolvePowerShellPath(env) { + const configuredRoot = env.SystemRoot; + if (typeof configuredRoot !== "string") { + return undefined; + } + const root = configuredRoot.trim(); + if (!root || !win32.isAbsolute(root)) { + return undefined; + } + return win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); +} +async function describeWindows(pid, env, spawn, timeoutMs) { + const powerShellPath = resolvePowerShellPath(env); + if (!powerShellPath) { + return { status: "unknown", reason: "facility-unavailable" }; + } + const outcome = await runQuery(spawn, powerShellPath, [ + "-NoProfile", + "-NonInteractive", + "-Command", + `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($null -eq $p) { 'absent' } else { try { $p.StartTime.ToUniversalTime().Ticks } catch { 'denied' } }`, + ], timeoutMs); + if (outcome.status === "failed") { + return { status: "unknown", reason: outcome.reason }; + } + const value = outcome.output.trim(); + if (value === "absent") { + return { status: "gone" }; + } + if (value === "denied") { + return { status: "unknown", reason: "permission-denied" }; + } + return /^\d+$/.test(value) + ? { status: "described", token: `win1:${value}` } + : { status: "unknown", reason: "malformed-output" }; +} +async function describeDarwin(pid, env, spawn, timeoutMs) { + const outcome = await runQuery(spawn, "/bin/ps", ["-p", String(pid), "-o", "lstart="], timeoutMs, { ...env, TZ: "UTC", LC_ALL: "C" }); + if (outcome.status === "failed") { + return { status: "unknown", reason: outcome.reason }; + } + const value = outcome.output.trim().replace(/\s+/g, " "); + if (!value) { + return { status: "gone" }; + } + return { status: "described", token: `mac1:${value}` }; +} +export function createProcessIdentityController(options = {}) { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const readTextFile = options.readTextFile ?? ((path) => readFileSync(path, "utf8")); + const spawn = options.spawn ?? + ((executable, args, spawnOptions) => spawnProcess(executable, args, spawnOptions)); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const controller = { + async describe(pid) { + if (!isValidPid(pid)) { + return { status: "gone" }; + } + if (platform === "linux") { + return describeLinux(pid, readTextFile); + } + if (platform === "win32") { + return describeWindows(pid, env, spawn, timeoutMs); + } + if (platform === "darwin") { + return describeDarwin(pid, env, spawn, timeoutMs); + } + return { status: "unknown", reason: "facility-unavailable" }; + }, + async probe(pid, expectedToken) { + if (!expectedToken.startsWith(`${tokenTagFor(platform)}:`)) { + return { status: "gone" }; + } + const described = await controller.describe(pid); + if (described.status === "unknown") { + return described; + } + if (described.status === "gone") { + return { status: "gone" }; + } + return described.token === expectedToken + ? { status: "alive" } + : { status: "gone" }; + }, + }; + return controller; +} +//# sourceMappingURL=process-identity.js.map \ No newline at end of file diff --git a/dist/process-identity.js.map b/dist/process-identity.js.map new file mode 100644 index 0000000..eb2faf1 --- /dev/null +++ b/dist/process-identity.js.map @@ -0,0 +1 @@ +{"version":3,"file":"process-identity.js","sourceRoot":"","sources":["../src/process-identity.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/process-tree.d.ts.map b/dist/process-tree.d.ts.map index 5aa4203..5deea91 100644 --- a/dist/process-tree.d.ts.map +++ b/dist/process-tree.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"process-tree.d.ts","sourceRoot":"","sources":["../src/process-tree.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,wBAAwB,GAChC,aAAa,GACb,mBAAmB,GACnB,sBAAsB,GACtB,aAAa,CAAC;AAElB,MAAM,MAAM,yBAAyB,GAAG,MAAM,GAAG,MAAM,CAAC;AAExD,MAAM,MAAM,qBAAqB,GAC7B;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GACrB;IACE,MAAM,EAAE,QAAQ,CAAC;IACjB,MAAM,EAAE,wBAAwB,CAAC;IACjC,cAAc,EAAE,yBAAyB,CAAC;CAC3C,CAAC;AAEN,MAAM,WAAW,sBAAsB;IACrC,YAAY,IAAI,OAAO,CAAC;IACxB,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,CACF,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,qBAAqB,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,mBAAmB,CAAC;IAC5E,IAAI,CACF,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,KAAK,IAAI,GACrE,mBAAmB,CAAC;IACvB,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;IAChD,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,EAAE,KAAK,CAAC;IAChB,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,EAAE,QAAQ,CAAC;IAChB,WAAW,EAAE,IAAI,CAAC;CACnB;AAED,MAAM,MAAM,mBAAmB,GAAG,CAChC,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,0BAA0B,KAChC,mBAAmB,CAAC;AAEzB,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,OAAO,GAAG,CAAC,KAAK,OAAO,CAAC;IAC7D,KAAK,CAAC,EAAE,mBAAmB,CAAC;IAC5B,KAAK,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAgZD,wBAAgB,2BAA2B,CACzC,OAAO,CAAC,EAAE,4BAA4B,GACrC,qBAAqB,CAsDvB"} \ No newline at end of file +{"version":3,"file":"process-tree.d.ts","sourceRoot":"","sources":["../src/process-tree.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,wBAAwB,GAChC,aAAa,GACb,mBAAmB,GACnB,sBAAsB,GACtB,aAAa,CAAC;AAElB,MAAM,MAAM,yBAAyB,GAAG,MAAM,GAAG,MAAM,CAAC;AAExD,MAAM,MAAM,qBAAqB,GAC7B;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GACrB;IACE,MAAM,EAAE,QAAQ,CAAC;IACjB,MAAM,EAAE,wBAAwB,CAAC;IACjC,cAAc,EAAE,yBAAyB,CAAC;CAC3C,CAAC;AAEN,MAAM,WAAW,sBAAsB;IACrC,YAAY,IAAI,OAAO,CAAC;IACxB,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,CACF,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,qBAAqB,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,mBAAmB,CAAC;IAC5E,IAAI,CACF,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,KAAK,IAAI,GACrE,mBAAmB,CAAC;IACvB,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;IAChD,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,EAAE,KAAK,CAAC;IAChB,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,EAAE,QAAQ,CAAC;IAChB,WAAW,EAAE,IAAI,CAAC;CACnB;AAED,MAAM,MAAM,mBAAmB,GAAG,CAChC,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,0BAA0B,KAChC,mBAAmB,CAAC;AAEzB,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,OAAO,GAAG,CAAC,KAAK,OAAO,CAAC;IAC7D,KAAK,CAAC,EAAE,mBAAmB,CAAC;IAC5B,KAAK,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAuYD,wBAAgB,2BAA2B,CACzC,OAAO,CAAC,EAAE,4BAA4B,GACrC,qBAAqB,CAsDvB"} \ No newline at end of file diff --git a/dist/process-tree.js b/dist/process-tree.js index 4ebb5fd..dfdfaa5 100644 --- a/dist/process-tree.js +++ b/dist/process-tree.js @@ -1,15 +1,9 @@ import { spawn as spawnProcess } from "node:child_process"; import { win32 } from "node:path"; +import { getErrorCode } from "./error-code.js"; const DEFAULT_GRACE_PERIOD_MS = 5_000; const DEFAULT_POLL_INTERVAL_MS = 50; const DEFAULT_UTILITY_TIMEOUT_MS = 5_000; -function getErrorCode(error) { - if (typeof error !== "object" || error === null || !("code" in error)) { - return undefined; - } - const code = error.code; - return typeof code === "string" ? code : undefined; -} function signalProcess(kill, pid, signal) { try { return kill(pid, signal) diff --git a/dist/process-tree.js.map b/dist/process-tree.js.map index 5a85369..eadf5bf 100644 --- a/dist/process-tree.js.map +++ b/dist/process-tree.js.map @@ -1 +1 @@ -{"version":3,"file":"process-tree.js","sourceRoot":"","sources":["../src/process-tree.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"process-tree.js","sourceRoot":"","sources":["../src/process-tree.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/server-internal.d.ts b/dist/server-internal.d.ts index 0a16f1c..ad79666 100644 --- a/dist/server-internal.d.ts +++ b/dist/server-internal.d.ts @@ -1,10 +1,15 @@ import type { PluginModule } from "@opencode-ai/plugin"; import type { loadConfigFile, resolveGlobalConfigPath, resolveProjectConfigPath } from "./config.js"; import { type StartupDependencies } from "./core.js"; +export interface StartupOwnerIdentity { + readonly pid: number; + readonly startToken: string; +} export interface StartupCommandsServerDependencies extends StartupDependencies { loadConfigFile: typeof loadConfigFile; resolveGlobalConfigPath: typeof resolveGlobalConfigPath; resolveProjectConfigPath: typeof resolveProjectConfigPath; + resolveOwnerIdentity?(): Promise; } export declare function createStartupCommandsServer(dependencies: StartupCommandsServerDependencies): PluginModule; //# sourceMappingURL=server-internal.d.ts.map \ No newline at end of file diff --git a/dist/server-internal.d.ts.map b/dist/server-internal.d.ts.map index d988d22..2346192 100644 --- a/dist/server-internal.d.ts.map +++ b/dist/server-internal.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"server-internal.d.ts","sourceRoot":"","sources":["../src/server-internal.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAEV,cAAc,EACd,uBAAuB,EACvB,wBAAwB,EACzB,MAAM,aAAa,CAAC;AACrB,OAAO,EAEL,KAAK,mBAAmB,EACzB,MAAM,WAAW,CAAC;AAGnB,MAAM,WAAW,iCACf,SAAQ,mBAAmB;IAC3B,cAAc,EAAE,OAAO,cAAc,CAAC;IACtC,uBAAuB,EAAE,OAAO,uBAAuB,CAAC;IACxD,wBAAwB,EAAE,OAAO,wBAAwB,CAAC;CAC3D;AA2BD,wBAAgB,2BAA2B,CACzC,YAAY,EAAE,iCAAiC,GAC9C,YAAY,CA+Bd"} \ No newline at end of file +{"version":3,"file":"server-internal.d.ts","sourceRoot":"","sources":["../src/server-internal.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAEV,cAAc,EACd,uBAAuB,EACvB,wBAAwB,EACzB,MAAM,aAAa,CAAC;AACrB,OAAO,EAGL,KAAK,mBAAmB,EAEzB,MAAM,WAAW,CAAC;AAGnB,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,iCACf,SAAQ,mBAAmB;IAC3B,cAAc,EAAE,OAAO,cAAc,CAAC;IACtC,uBAAuB,EAAE,OAAO,uBAAuB,CAAC;IACxD,wBAAwB,EAAE,OAAO,wBAAwB,CAAC;IAC1D,oBAAoB,CAAC,IAAI,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAAC;CACpE;AA4DD,wBAAgB,2BAA2B,CACzC,YAAY,EAAE,iCAAiC,GAC9C,YAAY,CAuCd"} \ No newline at end of file diff --git a/dist/server-internal.js b/dist/server-internal.js index bb3572d..be0f1db 100644 --- a/dist/server-internal.js +++ b/dist/server-internal.js @@ -1,4 +1,27 @@ -import { runStartupCommands, } from "./core.js"; +import { normalizeProjectRoot, runStartupCommands, } from "./core.js"; +async function resolveOwner(dependencies) { + if (!dependencies.resolveOwnerIdentity) { + return undefined; + } + try { + return await dependencies.resolveOwnerIdentity(); + } + catch { + return undefined; + } +} +function buildRunOptions(dependencies, worktree, authoritative) { + const { registry, identity } = dependencies; + if (!registry || !identity) { + return undefined; + } + const projectRootHash = registry.hashProjectRoot(normalizeProjectRoot(worktree)); + return { + authoritative, + projectRootHash, + resolveOwner: () => resolveOwner(dependencies), + }; +} function writeConfigDiagnostic(diagnostic, logger) { try { if (diagnostic.reason === "invalid-command") { @@ -17,7 +40,6 @@ function writeConfigDiagnostic(diagnostic, logger) { }); } catch { - // Logging must not prevent valid commands from reaching core execution. } } export function createStartupCommandsServer(dependencies) { @@ -32,7 +54,9 @@ export function createStartupCommandsServer(dependencies) { ]) { writeConfigDiagnostic(diagnostic, dependencies.logger); } - const activation = await runStartupCommands([...globalConfig.commands, ...projectConfig.commands], dependencies); + const runOptions = buildRunOptions(dependencies, input.worktree, globalConfig.diagnostics.length === 0 && + projectConfig.diagnostics.length === 0); + const activation = await runStartupCommands([...globalConfig.commands, ...projectConfig.commands], dependencies, runOptions); return { dispose: () => activation.dispose(), }; diff --git a/dist/server-internal.js.map b/dist/server-internal.js.map index 923d22d..80f32a1 100644 --- a/dist/server-internal.js.map +++ b/dist/server-internal.js.map @@ -1 +1 @@ -{"version":3,"file":"server-internal.js","sourceRoot":"","sources":["../src/server-internal.ts"],"names":[],"mappings":"AAOA,OAAO,EACL,kBAAkB,GAEnB,MAAM,WAAW,CAAC;AAUnB,SAAS,qBAAqB,CAC5B,UAA4B,EAC5B,MAAc;IAEd,IAAI,CAAC;QACH,IAAI,UAAU,CAAC,MAAM,KAAK,iBAAiB,EAAE,CAAC;YAC5C,MAAM,CAAC,KAAK,CAAC;gBACX,IAAI,EAAE,iBAAiB;gBACvB,KAAK,EAAE,UAAU,CAAC,KAAK;gBACvB,KAAK,EAAE,UAAU,CAAC,KAAK;gBACvB,IAAI,EAAE,UAAU,CAAC,IAAI;aACtB,CAAC,CAAC;YACH,OAAO;QACT,CAAC;QAED,MAAM,CAAC,KAAK,CAAC;YACX,IAAI,EAAE,uBAAuB;YAC7B,KAAK,EAAE,UAAU,CAAC,KAAK;YACvB,MAAM,EAAE,UAAU,CAAC,MAAM;SAC1B,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;QACP,wEAAwE;IAC1E,CAAC;AACH,CAAC;AAED,MAAM,UAAU,2BAA2B,CACzC,YAA+C;IAE/C,OAAO;QACL,EAAE,EAAE,2BAA2B;QAC/B,KAAK,CAAC,MAAM,CAAC,KAAK;YAChB,MAAM,YAAY,GAAG,YAAY,CAAC,cAAc,CAC9C,QAAQ,EACR,YAAY,CAAC,uBAAuB,EAAE,CACvC,CAAC;YACF,MAAM,aAAa,GAAG,YAAY,CAAC,cAAc,CAC/C,SAAS,EACT,YAAY,CAAC,wBAAwB,CAAC,KAAK,CAAC,QAAQ,CAAC,EACrD,KAAK,CAAC,QAAQ,CACf,CAAC;YAEF,KAAK,MAAM,UAAU,IAAI;gBACvB,GAAG,YAAY,CAAC,WAAW;gBAC3B,GAAG,aAAa,CAAC,WAAW;aAC7B,EAAE,CAAC;gBACF,qBAAqB,CAAC,UAAU,EAAE,YAAY,CAAC,MAAM,CAAC,CAAC;YACzD,CAAC;YAED,MAAM,UAAU,GAAG,MAAM,kBAAkB,CACzC,CAAC,GAAG,YAAY,CAAC,QAAQ,EAAE,GAAG,aAAa,CAAC,QAAQ,CAAC,EACrD,YAAY,CACb,CAAC;YAEF,OAAO;gBACL,OAAO,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE;aACpC,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC"} \ No newline at end of file +{"version":3,"file":"server-internal.js","sourceRoot":"","sources":["../src/server-internal.ts"],"names":[],"mappings":"AAOA,OAAO,EACL,oBAAoB,EACpB,kBAAkB,GAGnB,MAAM,WAAW,CAAC;AAgBnB,KAAK,UAAU,YAAY,CACzB,YAA+C;IAE/C,IAAI,CAAC,YAAY,CAAC,oBAAoB,EAAE,CAAC;QACvC,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,IAAI,CAAC;QACH,OAAO,MAAM,YAAY,CAAC,oBAAoB,EAAE,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,SAAS,eAAe,CACtB,YAA+C,EAC/C,QAAgB,EAChB,aAAsB;IAEtB,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,YAAY,CAAC;IAC5C,IAAI,CAAC,QAAQ,IAAI,CAAC,QAAQ,EAAE,CAAC;QAC3B,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,eAAe,GAAG,QAAQ,CAAC,eAAe,CAC9C,oBAAoB,CAAC,QAAQ,CAAC,CAC/B,CAAC;IAEF,OAAO;QACL,aAAa;QACb,eAAe;QACf,YAAY,EAAE,GAAG,EAAE,CAAC,YAAY,CAAC,YAAY,CAAC;KAC/C,CAAC;AACJ,CAAC;AAED,SAAS,qBAAqB,CAC5B,UAA4B,EAC5B,MAAc;IAEd,IAAI,CAAC;QACH,IAAI,UAAU,CAAC,MAAM,KAAK,iBAAiB,EAAE,CAAC;YAC5C,MAAM,CAAC,KAAK,CAAC;gBACX,IAAI,EAAE,iBAAiB;gBACvB,KAAK,EAAE,UAAU,CAAC,KAAK;gBACvB,KAAK,EAAE,UAAU,CAAC,KAAK;gBACvB,IAAI,EAAE,UAAU,CAAC,IAAI;aACtB,CAAC,CAAC;YACH,OAAO;QACT,CAAC;QAED,MAAM,CAAC,KAAK,CAAC;YACX,IAAI,EAAE,uBAAuB;YAC7B,KAAK,EAAE,UAAU,CAAC,KAAK;YACvB,MAAM,EAAE,UAAU,CAAC,MAAM;SAC1B,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;IACT,CAAC;AACH,CAAC;AAED,MAAM,UAAU,2BAA2B,CACzC,YAA+C;IAE/C,OAAO;QACL,EAAE,EAAE,2BAA2B;QAC/B,KAAK,CAAC,MAAM,CAAC,KAAK;YAChB,MAAM,YAAY,GAAG,YAAY,CAAC,cAAc,CAC9C,QAAQ,EACR,YAAY,CAAC,uBAAuB,EAAE,CACvC,CAAC;YACF,MAAM,aAAa,GAAG,YAAY,CAAC,cAAc,CAC/C,SAAS,EACT,YAAY,CAAC,wBAAwB,CAAC,KAAK,CAAC,QAAQ,CAAC,EACrD,KAAK,CAAC,QAAQ,CACf,CAAC;YAEF,KAAK,MAAM,UAAU,IAAI;gBACvB,GAAG,YAAY,CAAC,WAAW;gBAC3B,GAAG,aAAa,CAAC,WAAW;aAC7B,EAAE,CAAC;gBACF,qBAAqB,CAAC,UAAU,EAAE,YAAY,CAAC,MAAM,CAAC,CAAC;YACzD,CAAC;YAED,MAAM,UAAU,GAAG,eAAe,CAChC,YAAY,EACZ,KAAK,CAAC,QAAQ,EACd,YAAY,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC;gBACnC,aAAa,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC,CACzC,CAAC;YAEF,MAAM,UAAU,GAAG,MAAM,kBAAkB,CACzC,CAAC,GAAG,YAAY,CAAC,QAAQ,EAAE,GAAG,aAAa,CAAC,QAAQ,CAAC,EACrD,YAAY,EACZ,UAAU,CACX,CAAC;YAEF,OAAO;gBACL,OAAO,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE;aACpC,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/dist/server.d.ts.map b/dist/server.d.ts.map index 70d33cb..bb887cf 100644 --- a/dist/server.d.ts.map +++ b/dist/server.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAWA,QAAA,MAAM,qBAAqB,4CAQzB,CAAC;AAEH,eAAe,qBAAqB,CAAC"} \ No newline at end of file +{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAkBA,QAAA,MAAM,qBAAqB,4CAmBzB,CAAC;AAEH,eAAe,qBAAqB,CAAC"} \ No newline at end of file diff --git a/dist/server.js b/dist/server.js index cfbbd04..5fde4b2 100644 --- a/dist/server.js +++ b/dist/server.js @@ -1,9 +1,12 @@ import { spawn } from "node:child_process"; import { loadConfigFile, resolveGlobalConfigPath, resolveProjectConfigPath, } from "./config.js"; import { processState } from "./core.js"; +import { createDurableRegistry, resolveDurableStateRoot, } from "./durable-registry.js"; import { createLogger } from "./logger.js"; +import { createProcessIdentityController } from "./process-identity.js"; import { createProcessTreeController } from "./process-tree.js"; import { createStartupCommandsServer } from "./server-internal.js"; +const identity = createProcessIdentityController(); const startupCommandsServer = createStartupCommandsServer({ loadConfigFile, resolveGlobalConfigPath, @@ -12,6 +15,17 @@ const startupCommandsServer = createStartupCommandsServer({ state: processState, processTree: createProcessTreeController(), logger: createLogger(), + identity, + registry: createDurableRegistry({ + root: resolveDurableStateRoot(), + identity, + }), + async resolveOwnerIdentity() { + const described = await identity.describe(process.pid); + return described.status === "described" + ? { pid: process.pid, startToken: described.token } + : undefined; + }, }); export default startupCommandsServer; //# sourceMappingURL=server.js.map \ No newline at end of file diff --git a/dist/server.js.map b/dist/server.js.map index e1f5c8b..5da35d2 100644 --- a/dist/server.js.map +++ b/dist/server.js.map @@ -1 +1 @@ -{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EACL,cAAc,EACd,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,2BAA2B,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,EAAE,2BAA2B,EAAE,MAAM,sBAAsB,CAAC;AAEnE,MAAM,qBAAqB,GAAG,2BAA2B,CAAC;IACxD,cAAc;IACd,uBAAuB;IACvB,wBAAwB;IACxB,KAAK;IACL,KAAK,EAAE,YAAY;IACnB,WAAW,EAAE,2BAA2B,EAAE;IAC1C,MAAM,EAAE,YAAY,EAAE;CACvB,CAAC,CAAC;AAEH,eAAe,qBAAqB,CAAC"} \ No newline at end of file +{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EACL,cAAc,EACd,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EACL,qBAAqB,EACrB,uBAAuB,GACxB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,+BAA+B,EAAE,MAAM,uBAAuB,CAAC;AACxE,OAAO,EAAE,2BAA2B,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,EAAE,2BAA2B,EAAE,MAAM,sBAAsB,CAAC;AAEnE,MAAM,QAAQ,GAAG,+BAA+B,EAAE,CAAC;AAEnD,MAAM,qBAAqB,GAAG,2BAA2B,CAAC;IACxD,cAAc;IACd,uBAAuB;IACvB,wBAAwB;IACxB,KAAK;IACL,KAAK,EAAE,YAAY;IACnB,WAAW,EAAE,2BAA2B,EAAE;IAC1C,MAAM,EAAE,YAAY,EAAE;IACtB,QAAQ;IACR,QAAQ,EAAE,qBAAqB,CAAC;QAC9B,IAAI,EAAE,uBAAuB,EAAE;QAC/B,QAAQ;KACT,CAAC;IACF,KAAK,CAAC,oBAAoB;QACxB,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACvD,OAAO,SAAS,CAAC,MAAM,KAAK,WAAW;YACrC,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,UAAU,EAAE,SAAS,CAAC,KAAK,EAAE;YACnD,CAAC,CAAC,SAAS,CAAC;IAChB,CAAC;CACF,CAAC,CAAC;AAEH,eAAe,qBAAqB,CAAC"} \ No newline at end of file diff --git a/package.json b/package.json index 07d6c20..a4b2fea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-startup-commands", - "version": "1.1.0", + "version": "1.2.0", "description": "Launch trusted background commands when OpenCode initializes a project or directory.", "license": "MIT", "author": { @@ -8,7 +8,7 @@ "url": "https://github.com/PixelWinner" }, "repository": "git+https://github.com/PixelWinner/opencode-startup-commands.git", - "homepage": "https://github.com/PixelWinner/opencode-startup-commands#readme", + "homepage": "https://github.com/PixelWinner/opencode-startup-commands", "bugs": { "url": "https://github.com/PixelWinner/opencode-startup-commands/issues" }, diff --git a/scripts/release-package.mjs b/scripts/release-package.mjs index 413db2c..820bbe1 100644 --- a/scripts/release-package.mjs +++ b/scripts/release-package.mjs @@ -18,7 +18,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { runChecked } from "./process.mjs"; const PACKAGE_NAME = "opencode-startup-commands"; -const PACKAGE_VERSION = "1.1.0"; +const PACKAGE_VERSION = "1.2.0"; const PACKAGE_FILENAME = `${PACKAGE_NAME}-${PACKAGE_VERSION}.tgz`; const MAX_COMPRESSED_SIZE = 2 * 1024 * 1024; const MAX_UNPACKED_SIZE = 5 * 1024 * 1024; @@ -29,8 +29,16 @@ const REQUIRED_PACKAGE_PATHS = [ "dist/config.d.ts", "dist/core.js", "dist/core.d.ts", + "dist/durable-lock.js", + "dist/durable-lock.d.ts", + "dist/durable-registry.js", + "dist/durable-registry.d.ts", + "dist/error-code.js", + "dist/error-code.d.ts", "dist/logger.js", "dist/logger.d.ts", + "dist/process-identity.js", + "dist/process-identity.d.ts", "dist/process-tree.d.ts", "dist/process-tree.js", "dist/server.js", diff --git a/src/core.ts b/src/core.ts index 3877d30..3c75241 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,10 +1,24 @@ import { normalize, resolve } from "node:path"; import type { ConfiguredCommand } from "./config.js"; +import { getErrorCode as getRawErrorCode } from "./error-code.js"; +import type { + DurableLockHandle, + DurableLockHolder, + DurableRecord, + DurableRegistry, + DurableUnavailableReason, +} from "./durable-registry.js"; import type { CommandStopTrigger, + DurableUnavailableCause, LogEvent, Logger, } from "./logger.js"; +import type { + ProcessDescribeResult, + ProcessIdentityController, + ProcessProbeResult, +} from "./process-identity.js"; import type { ProcessTreeController, ProcessTreeStopResult, @@ -38,8 +52,7 @@ interface ManagedCommandContext { name: string; } -interface ProcessRecord { - child: SpawnedChild; +interface ProcessRecordBase { pid?: number; context: ManagedCommandContext; creationOrder: number; @@ -50,6 +63,10 @@ interface ProcessRecord { rootExited: boolean; } +type ProcessRecord = + | (ProcessRecordBase & { origin: "spawned"; child: SpawnedChild }) + | (ProcessRecordBase & { origin: "adopted"; startToken: string }); + interface IdentityEntry { processKey: string; records: ProcessRecord[]; @@ -82,12 +99,20 @@ export interface StartupDependencies { state: StartupState; processTree: ProcessTreeController; logger: Logger; + identity?: ProcessIdentityController; + registry?: DurableRegistry; } export interface StartupActivation { dispose(): Promise; } +export interface StartupRunOptions { + authoritative: boolean; + projectRootHash: string; + resolveOwner?: () => Promise; +} + interface IndexedCommand { command: ConfiguredCommand; order: number; @@ -177,8 +202,7 @@ function isProcessRecord(value: unknown): value is ProcessRecord { } try { - return ( - isSpawnedChild(value.child) && + const sharedFieldsValid = isOptionalPid(value.pid) && isManagedCommandContext(value.context) && isNonnegativeSafeInteger(value.creationOrder) && @@ -186,8 +210,21 @@ function isProcessRecord(value: unknown): value is ProcessRecord { typeof value.stopOnExit === "boolean" && (value.status === "active" || value.status === "stopping") && (value.stopPromise === undefined || isPromiseLike(value.stopPromise)) && - typeof value.rootExited === "boolean" - ); + typeof value.rootExited === "boolean"; + + if (!sharedFieldsValid) { + return false; + } + + if (value.origin === "adopted") { + return typeof value.startToken === "string" && value.startToken !== ""; + } + + if (value.origin === undefined || value.origin === "spawned") { + return isSpawnedChild(value.child); + } + + return false; } catch { return false; } @@ -334,6 +371,8 @@ function getNormalizedProjectRoot(projectRoot: string): string { : normalizedRoot; } +export { getNormalizedProjectRoot as normalizeProjectRoot }; + function getProcessKey( command: ConfiguredCommand, signature: string, @@ -346,14 +385,9 @@ function getProcessKey( } function getErrorCode(error: unknown): LogErrorCode | undefined { + const code = getRawErrorCode(error); return ( - typeof error === "object" && - error !== null && - "code" in error && - typeof error.code === "string" && - ALLOWED_ERROR_CODES.has(error.code) - ? error.code - : undefined + code !== undefined && ALLOWED_ERROR_CODES.has(code) ? code : undefined ) as LogErrorCode | undefined; } @@ -361,7 +395,6 @@ function writeLog(logger: Logger, event: LogEvent): void { try { logger.write(event); } catch { - // Logging must not affect command execution or child lifecycle callbacks. } } @@ -456,6 +489,7 @@ function spawnRecord( const pid = getValidatedPid(child); const record: ProcessRecord = { + origin: "spawned", child, pid, context: { @@ -506,7 +540,6 @@ function spawnRecord( } }); } catch { - // A spawned child remains successful even if lifecycle hooks are unavailable. } try { @@ -547,13 +580,11 @@ function spawnRecord( ).catch(() => undefined); }); } catch { - // A spawned child remains successful even if lifecycle hooks are unavailable. } try { child.unref(); } catch { - // The child already started, so an unref failure is not a spawn failure. } return record; @@ -576,6 +607,50 @@ function requestRecordStop( trigger, }); + if (record.origin === "spawned") { + record.stopPromise = stopThroughTree(record, trigger, dependencies); + return record.stopPromise; + } + + record.stopPromise = resolveRootExited(record, dependencies) + .then((rootExitedAtEntry) => { + record.rootExited = record.rootExited || rootExitedAtEntry; + return stopThroughTree(record, trigger, dependencies); + }) + .catch( + (): ProcessTreeStopResult => ({ + status: "failed", + reason: "unconfirmed", + addressability: record.rootExited ? "lost" : "safe", + }), + ); + + return record.stopPromise; +} + +async function resolveRootExited( + record: Extract, + dependencies: StartupDependencies, +): Promise { + if (!dependencies.identity) { + return true; + } + try { + const probed = await dependencies.identity.probe( + record.pid, + record.startToken, + ); + return probed.status !== "alive"; + } catch { + return true; + } +} + +function stopThroughTree( + record: ProcessRecord, + trigger: CommandStopTrigger, + dependencies: StartupDependencies, +): Promise { let stopPromise: Promise; try { stopPromise = Promise.resolve( @@ -599,7 +674,7 @@ function requestRecordStop( }); } - record.stopPromise = stopPromise + return stopPromise .catch( (): ProcessTreeStopResult => ({ status: "failed", @@ -619,7 +694,6 @@ function requestRecordStop( } return result; }); - return record.stopPromise; } function applyRecordStopResult( @@ -643,14 +717,548 @@ function applyRecordStopResult( record.stopPromise = undefined; } -export async function runStartupCommands( +type DurableScopeInfo = + | { scope: "global"; identityHash: string } + | { scope: "project"; identityHash: string; projectRootHash: string }; + +type DurablePlan = + | { + enabled: true; + registry: DurableRegistry; + identity: ProcessIdentityController; + handle: DurableLockHandle; + foreignRecords: DurableRecord[]; + carriedRecords: DurableRecord[]; + fallbackTokens: Map; + blocked: Set; + } + | { enabled: false; blocked: Set }; + +function durableKeyFor(part: { + scope: "global" | "project"; + identityHash: string; + projectRootHash?: string; +}): string { + return part.scope === "global" + ? `identity:${part.identityHash}` + : `identity:${part.identityHash}:root:${part.projectRootHash}`; +} + +function trackedPidKey(processKey: string, pid: number): string { + return `${processKey}:${pid}`; +} + +function adoptedRecordFrom( + stored: DurableRecord, + context: ManagedCommandContext, +): ProcessRecord { + return { + origin: "adopted", + pid: stored.pid, + startToken: stored.startToken, + context, + creationOrder: stored.creationOrder, + owners: new Set(), + stopOnExit: false, + status: "active", + rootExited: false, + }; +} + +function toDurableRecord( + scopeInfo: DurableScopeInfo, + pid: number, + startToken: string, + creationOrder: number, + recordedAt: string, +): DurableRecord { + return scopeInfo.scope === "global" + ? { + scope: "global", + identityHash: scopeInfo.identityHash, + pid, + startToken, + stopOnExit: false, + creationOrder, + recordedAt, + } + : { + scope: "project", + identityHash: scopeInfo.identityHash, + projectRootHash: scopeInfo.projectRootHash, + pid, + startToken, + stopOnExit: false, + creationOrder, + recordedAt, + }; +} + +const DURABLE_CAUSE_BY_REASON: Record< + DurableUnavailableReason, + DurableUnavailableCause +> = { + unreadable: "registry-unreadable", + malformed: "registry-malformed", + "unsupported-schema": "registry-unsupported-schema", +}; + +function durableCauseFor( + reason: DurableUnavailableReason, +): DurableUnavailableCause { + return DURABLE_CAUSE_BY_REASON[reason]; +} + +function finalizePersistedRecords( + records: readonly DurableRecord[], +): DurableRecord[] { + const deduped = new Map(); + for (const stored of records) { + deduped.set(`${durableKeyFor(stored)}:${stored.pid}`, stored); + } + + const groups = new Map(); + const groupOrder: string[] = []; + for (const stored of deduped.values()) { + const key = durableKeyFor(stored); + let group = groups.get(key); + if (!group) { + group = []; + groups.set(key, group); + groupOrder.push(key); + } + group.push(stored); + } + + const sorted: DurableRecord[] = []; + for (const key of groupOrder) { + const group = groups.get(key)!; + group.sort((left, right) => left.creationOrder - right.creationOrder); + sorted.push(...group); + } + return sorted; +} + +async function safeDescribe( + pid: number | undefined, + identity: ProcessIdentityController, +): Promise { + try { + return await identity.describe(pid); + } catch { + return { status: "unknown", reason: "unconfirmed" }; + } +} + +async function safeProbe( + pid: number, + startToken: string, + identity: ProcessIdentityController, +): Promise { + try { + return await identity.probe(pid, startToken); + } catch { + return { status: "unknown", reason: "unconfirmed" }; + } +} + +async function describeToken( + pid: number, + identity: ProcessIdentityController, +): Promise { + const described = await safeDescribe(pid, identity); + return described.status === "described" ? described.token : undefined; +} + +type LockHolderResolution = + | { status: "resolved"; holder: DurableLockHolder } + | { status: "unresolved" }; + +async function resolveLockHolder( + resolveOwner: StartupRunOptions["resolveOwner"], + identity: ProcessIdentityController, +): Promise { + let owner: DurableLockHolder | undefined; + try { + owner = await resolveOwner?.(); + } catch { + owner = undefined; + } + if (owner) { + return { status: "resolved", holder: owner }; + } + const resolved = await describeToken(process.pid, identity); + return resolved === undefined + ? { status: "unresolved" } + : { + status: "resolved", + holder: { pid: process.pid, startToken: resolved }, + }; +} + +type DurableCommandIndex = { + durableScopes: Map; + durableProcessKeys: Set; + commandByDurableKey: Map< + string, + { processKey: string; context: ManagedCommandContext } + >; +}; + +function indexDurableCommands( + uniqueCommands: readonly IndexedCommand[], + registry: DurableRegistry, + projectRootHash: string | undefined, +): DurableCommandIndex { + const durableScopes = new Map(); + const durableProcessKeys = new Set(); + const commandByDurableKey = new Map< + string, + { processKey: string; context: ManagedCommandContext } + >(); + + for (const { command, signature } of uniqueCommands) { + const processKey = getProcessKey(command, signature); + const identityHash = registry.hashIdentity(signature); + + if (!command.stopOnExit) { + durableProcessKeys.add(processKey); + } + + let scopeInfo: DurableScopeInfo; + if (command.scope === "global") { + scopeInfo = { scope: "global", identityHash }; + } else { + if (projectRootHash === undefined) { + continue; + } + scopeInfo = { scope: "project", identityHash, projectRootHash }; + } + + durableScopes.set(processKey, scopeInfo); + + if (!command.stopOnExit) { + commandByDurableKey.set(durableKeyFor(scopeInfo), { + processKey, + context: { + scope: command.scope, + index: command.index, + name: command.name, + }, + }); + } + } + + return { durableScopes, durableProcessKeys, commandByDurableKey }; +} + +function partitionByProjectRoot( + records: readonly DurableRecord[], + projectRootHash: string | undefined, +): { foreign: DurableRecord[]; current: DurableRecord[] } { + const foreign: DurableRecord[] = []; + const current: DurableRecord[] = []; + for (const stored of records) { + if ( + stored.scope === "global" || + stored.projectRootHash === projectRootHash + ) { + current.push(stored); + } else { + foreign.push(stored); + } + } + return { foreign, current }; +} + +async function buildDurablePlan( + uniqueCommands: readonly IndexedCommand[], + dependencies: StartupDependencies, + options: StartupRunOptions | undefined, + registry: DurableRegistry, + identity: ProcessIdentityController, +): Promise<{ + plan: DurablePlan; + durableScopes: Map; +}> { + const { durableScopes, durableProcessKeys, commandByDurableKey } = + indexDurableCommands(uniqueCommands, registry, options?.projectRootHash); + const reconciledCounts: Record<"global" | "project", { stopped: number }> = { + global: { stopped: 0 }, + project: { stopped: 0 }, + }; + + function unavailable(cause: DurableUnavailableCause): { + plan: DurablePlan; + durableScopes: Map; + } { + writeLog(dependencies.logger, { + type: "durable.unavailable", + cause, + }); + return { + plan: { enabled: false, blocked: durableProcessKeys }, + durableScopes, + }; + } + + const holderResolution = await resolveLockHolder( + options?.resolveOwner, + identity, + ); + if (holderResolution.status === "unresolved") { + return unavailable("identity-unavailable"); + } + + const lockResult = await registry.acquireLock(holderResolution.holder); + if (lockResult.status === "unavailable") { + return unavailable("lock-unavailable"); + } + const handle = lockResult.handle; + + try { + const readResult = registry.read(); + if (readResult.status === "unavailable") { + const cause = durableCauseFor(readResult.reason); + const result = unavailable(cause); + handle.release(); + return result; + } + + if (readResult.quarantined) { + writeLog(dependencies.logger, { + type: "durable.registry-quarantined", + }); + } + + const { foreign: foreignRecords, current: currentScopeRecords } = + partitionByProjectRoot(readResult.records, options?.projectRootHash); + + const nextCreationOrderByKey = new Map(); + for (const stored of currentScopeRecords) { + const key = durableKeyFor(stored); + nextCreationOrderByKey.set( + key, + Math.max( + nextCreationOrderByKey.get(key) ?? 0, + stored.creationOrder + 1, + ), + ); + } + + const carriedRecords: DurableRecord[] = []; + const fallbackTokens = new Map(); + const seededEntries = new Map(); + + function seededEntryFor( + processKey: string, + nextOrderFloor: number, + ): IdentityEntry { + let entry = seededEntries.get(processKey); + if (!entry) { + entry = + dependencies.state.get(processKey) ?? + createIdentityEntry(processKey); + dependencies.state.set(processKey, entry); + seededEntries.set(processKey, entry); + } + entry.nextCreationOrder = Math.max( + entry.nextCreationOrder, + nextOrderFloor, + ); + return entry; + } + + for (const stored of currentScopeRecords) { + const key = durableKeyFor(stored); + const lookup = commandByDurableKey.get(key); + const nextOrderFloor = + nextCreationOrderByKey.get(key) ?? stored.creationOrder + 1; + const owningCommand = lookup + ? { + processKey: lookup.processKey, + entry: seededEntryFor(lookup.processKey, nextOrderFloor), + context: lookup.context, + } + : undefined; + const probeResult = await safeProbe( + stored.pid, + stored.startToken, + identity, + ); + + if (probeResult.status === "alive") { + if (!owningCommand) { + if (options?.authoritative !== true) { + carriedRecords.push(stored); + continue; + } + + const orphanProcessRecord = adoptedRecordFrom(stored, { + scope: stored.scope, + index: -1, + name: "(orphaned)", + }); + + const stopResult = await requestRecordStop( + orphanProcessRecord, + "restart", + dependencies, + ); + if (stopResult.status !== "stopped") { + carriedRecords.push(stored); + continue; + } + + reconciledCounts[stored.scope].stopped += 1; + continue; + } + + const alreadyTracked = owningCommand.entry.records.some( + (existing) => existing.pid === stored.pid, + ); + if (alreadyTracked) { + fallbackTokens.set( + trackedPidKey(owningCommand.processKey, stored.pid), + stored.startToken, + ); + continue; + } + + owningCommand.entry.records.push( + adoptedRecordFrom(stored, owningCommand.context), + ); + writeLog(dependencies.logger, { + type: "durable.record-adopted", + scope: owningCommand.context.scope, + index: owningCommand.context.index, + name: owningCommand.context.name, + pid: stored.pid, + }); + continue; + } + + if (probeResult.status === "gone") { + writeLog(dependencies.logger, { + type: "durable.record-dropped", + scope: stored.scope, + index: owningCommand?.context.index, + name: owningCommand?.context.name, + pid: stored.pid, + }); + continue; + } + + if (owningCommand) { + owningCommand.entry.cleanupUnconfirmed = true; + owningCommand.entry.status = "degraded"; + } + carriedRecords.push(stored); + writeLog(dependencies.logger, { + type: "durable.record-unverifiable", + scope: stored.scope, + index: owningCommand?.context.index, + name: owningCommand?.context.name, + pid: stored.pid, + }); + } + + for (const scope of ["global", "project"] as const) { + const counts = reconciledCounts[scope]; + if (counts.stopped === 0) { + continue; + } + writeLog(dependencies.logger, { + type: "durable.reconciled", + scope, + stoppedCount: counts.stopped, + }); + } + + return { + plan: { + enabled: true, + registry, + identity, + handle, + foreignRecords, + carriedRecords, + fallbackTokens, + blocked: new Set(), + }, + durableScopes, + }; + } catch (error) { + handle.release(); + throw error; + } +} + +function dedupeCommands( commands: readonly ConfiguredCommand[], + logger: Logger, +): IndexedCommand[] { + const globalCommands = new Map(); + const projectCommands = new Map(); + const duplicateCommands: IndexedCommand[] = []; + + commands.forEach((command, order) => { + const signature = getCommandSignature(command); + const indexedCommand = { command, order, signature }; + + if (command.scope === "global") { + if (globalCommands.has(signature)) { + duplicateCommands.push(indexedCommand); + } else { + globalCommands.set(signature, indexedCommand); + } + return; + } + + const projectKey = getProcessKey(command, signature); + if (projectCommands.has(projectKey)) { + duplicateCommands.push(indexedCommand); + } else { + projectCommands.set(projectKey, indexedCommand); + } + }); + + const uniqueProjectCommands = [...projectCommands.values()].filter( + (indexedCommand) => { + if (globalCommands.has(indexedCommand.signature)) { + duplicateCommands.push(indexedCommand); + return false; + } + return true; + }, + ); + const uniqueCommands = [ + ...globalCommands.values(), + ...uniqueProjectCommands, + ]; + + duplicateCommands + .sort((left, right) => left.order - right.order) + .forEach(({ command }) => { + writeLog(logger, { + type: "command.skipped", + scope: command.scope, + index: command.index, + name: command.name, + reason: "duplicate", + }); + }); + + return uniqueCommands; +} + +function createActivation( + claimedKeys: ReadonlySet, + owner: OwnerToken, dependencies: StartupDependencies, -): Promise { - const owner = Symbol("startup-command-owner"); - const claimedKeys = new Set(); +): StartupActivation { let disposePromise: Promise | undefined; - const activation: StartupActivation = { + return { dispose(): Promise { disposePromise ??= Promise.all( [...claimedKeys].map((processKey) => @@ -694,225 +1302,305 @@ export async function runStartupCommands( return disposePromise; }, }; +} - writeLog(dependencies.logger, { - type: "plugin.initialized", - commandCount: commands.length, - }); - - if (commands.length === 0) { - writeLog(dependencies.logger, { - type: "batch.skipped", - reason: "no-valid-commands", - }); - return activation; +async function restartIdentity( + entry: IdentityEntry, + command: ConfiguredCommand, + processKey: string, + owner: OwnerToken, + claimedKeys: Set, + dependencies: StartupDependencies, +): Promise { + entry.status = "restarting"; + const snapshot = entry.records.map((record) => ({ + record, + owners: new Set(record.owners), + })); + const ownerUnion = new Set([owner]); + for (const { owners } of snapshot) { + for (const recordOwner of owners) { + ownerUnion.add(recordOwner); + } } + const results = await Promise.all( + snapshot.map(({ record }) => + requestRecordStop(record, "restart", dependencies), + ), + ); - const globalCommands = new Map(); - const projectCommands = new Map(); - const duplicateCommands: IndexedCommand[] = []; - - commands.forEach((command, order) => { - const signature = getCommandSignature(command); - const indexedCommand = { command, order, signature }; + const safeSurvivors: ProcessRecord[] = []; + const transferredOwners = new Set([owner]); + let stopFailed = false; + results.forEach((result, index) => { + const { record, owners } = snapshot[index]!; + if (result.status === "stopped") { + for (const recordOwner of owners) { + transferredOwners.add(recordOwner); + } + return; + } - if (command.scope === "global") { - if (globalCommands.has(signature)) { - duplicateCommands.push(indexedCommand); - } else { - globalCommands.set(signature, indexedCommand); + stopFailed = true; + if (record.rootExited || result.addressability === "lost") { + entry.cleanupUnconfirmed = true; + for (const recordOwner of owners) { + transferredOwners.add(recordOwner); } return; } - const projectKey = getProcessKey(command, signature); - if (projectCommands.has(projectKey)) { - duplicateCommands.push(indexedCommand); - } else { - projectCommands.set(projectKey, indexedCommand); + record.owners.clear(); + for (const recordOwner of owners) { + record.owners.add(recordOwner); } + record.status = "active"; + record.stopPromise = undefined; + safeSurvivors.push(record); }); + entry.records = safeSurvivors; + + if (!stopFailed) { + const replacement = spawnRecord( + command, + owner, + entry, + dependencies, + ); + if (replacement) { + for (const recordOwner of ownerUnion) { + replacement.owners.add(recordOwner); + } + entry.retryTombstone = undefined; + claimedKeys.add(processKey); + } + entry.status = "stable"; + return true; + } - const uniqueProjectCommands = [...projectCommands.values()].filter( - (indexedCommand) => { - if (globalCommands.has(indexedCommand.signature)) { - duplicateCommands.push(indexedCommand); - return false; + const oldestSurvivor = safeSurvivors[0]; + if (oldestSurvivor) { + for (const recordOwner of transferredOwners) { + oldestSurvivor.owners.add(recordOwner); + } + claimedKeys.add(processKey); + } + entry.status = "degraded"; + return true; +} + +async function persistDurableRecords( + plan: Extract, + durableScopes: ReadonlyMap, + dependencies: StartupDependencies, +): Promise { + const persisted: DurableRecord[] = [...plan.carriedRecords]; + + for (const [processKey, entry] of dependencies.state) { + const scopeInfo = durableScopes.get(processKey); + if (!scopeInfo) { + continue; + } + + for (const record of entry.records) { + if (record.stopOnExit) { + continue; } - return true; - }, - ); - const uniqueCommands = [ - ...globalCommands.values(), - ...uniqueProjectCommands, + if (record.pid === undefined) { + writeLog(dependencies.logger, { + type: "durable.record-unverifiable", + ...record.context, + pid: undefined, + }); + continue; + } + + const describedToken = + record.origin === "adopted" + ? record.startToken + : await describeToken(record.pid, plan.identity); + const startToken = + describedToken ?? + plan.fallbackTokens.get(trackedPidKey(processKey, record.pid)); + if (startToken === undefined) { + writeLog(dependencies.logger, { + type: "durable.record-unverifiable", + ...record.context, + pid: record.pid, + }); + continue; + } + + const recordedAt = new Date().toISOString(); + persisted.push( + toDurableRecord( + scopeInfo, + record.pid, + startToken, + record.creationOrder, + recordedAt, + ), + ); + } + } + + const finalRecords = [ + ...plan.foreignRecords, + ...finalizePersistedRecords(persisted), ]; - let commandAttempted = false; - duplicateCommands - .sort((left, right) => left.order - right.order) - .forEach(({ command }) => { - writeLog(dependencies.logger, { - type: "command.skipped", - scope: command.scope, - index: command.index, - name: command.name, - reason: "duplicate", - }); + const persistedSuccessfully = plan.registry.write(finalRecords); + if (!persistedSuccessfully) { + writeLog(dependencies.logger, { + type: "durable.unavailable", + cause: "registry-unwritable", }); + } +} - for (const { command, signature } of uniqueCommands) { - const processKey = getProcessKey(command, signature); - const attempted = await withIdentityTransition( - dependencies.state, - processKey, - async (entry) => { - if (entry.records.length === 0) { - if ( - entry.retryTombstone !== undefined || - entry.cleanupUnconfirmed - ) { - writeLog(dependencies.logger, { - type: "command.skipped", - scope: command.scope, - index: command.index, - name: command.name, - reason: "already-started", - }); - return false; - } +export async function runStartupCommands( + commands: readonly ConfiguredCommand[], + dependencies: StartupDependencies, + options?: StartupRunOptions, +): Promise { + const owner = Symbol("startup-command-owner"); + const claimedKeys = new Set(); + const activation = createActivation(claimedKeys, owner, dependencies); - const record = spawnRecord(command, owner, entry, dependencies); - if (record) { - claimedKeys.add(processKey); - } - return true; - } + writeLog(dependencies.logger, { + type: "plugin.initialized", + commandCount: commands.length, + }); - if (command.onExistingProcess === "start") { - const record = spawnRecord(command, owner, entry, dependencies); - if (record) { - claimedKeys.add(processKey); - } - return true; - } + const uniqueCommands = dedupeCommands(commands, dependencies.logger); + let commandAttempted = false; - if (command.onExistingProcess === "skip") { - const activeRecord = entry.records.find( - (record) => record.status === "active", - ); - if (activeRecord) { - activeRecord.owners.add(owner); - claimedKeys.add(processKey); - } - writeLog(dependencies.logger, { - type: "command.skipped", - scope: command.scope, - index: command.index, - name: command.name, - reason: "already-started", - }); - return false; - } + let durablePlan: DurablePlan; + let durableScopes: Map; + const registry = dependencies.registry; + if ( + registry && + dependencies.identity && + (uniqueCommands.some(({ command }) => !command.stopOnExit) || + registry.exists()) + ) { + const built = await buildDurablePlan( + uniqueCommands, + dependencies, + options, + registry, + dependencies.identity, + ); + durablePlan = built.plan; + durableScopes = built.durableScopes; + } else { + durablePlan = { enabled: false, blocked: new Set() }; + durableScopes = new Map(); + } - if (entry.cleanupUnconfirmed) { - entry.status = "degraded"; - writeLog(dependencies.logger, { - type: "command.stop-failed", - scope: command.scope, - index: command.index, - name: command.name, - pid: undefined, - trigger: "restart", - reason: "unconfirmed", - }); - return true; - } + function logSkipped( + command: ConfiguredCommand, + reason: "already-started" | "durable-unavailable", + ): void { + writeLog(dependencies.logger, { + type: "command.skipped", + scope: command.scope, + index: command.index, + name: command.name, + reason, + }); + } + + try { + for (const { command, signature } of uniqueCommands) { + const processKey = getProcessKey(command, signature); + + if (durablePlan.blocked.has(processKey)) { + logSkipped(command, "durable-unavailable"); + continue; + } - entry.status = "restarting"; - const snapshot = entry.records.map((record) => ({ - record, - owners: new Set(record.owners), - })); - const ownerUnion = new Set([owner]); - for (const { owners } of snapshot) { - for (const recordOwner of owners) { - ownerUnion.add(recordOwner); + const attempted = await withIdentityTransition( + dependencies.state, + processKey, + async (entry) => { + if (entry.records.length === 0) { + if ( + entry.retryTombstone !== undefined || + entry.cleanupUnconfirmed + ) { + logSkipped(command, "already-started"); + return false; + } + + const record = spawnRecord(command, owner, entry, dependencies); + if (record) { + claimedKeys.add(processKey); + } + return true; } - } - const results = await Promise.all( - snapshot.map(({ record }) => - requestRecordStop(record, "restart", dependencies), - ), - ); - const safeSurvivors: ProcessRecord[] = []; - const transferredOwners = new Set([owner]); - let stopFailed = false; - results.forEach((result, index) => { - const { record, owners } = snapshot[index]!; - if (result.status === "stopped") { - for (const recordOwner of owners) { - transferredOwners.add(recordOwner); + if (command.onExistingProcess === "start") { + const record = spawnRecord(command, owner, entry, dependencies); + if (record) { + claimedKeys.add(processKey); } - return; + return true; } - stopFailed = true; - if (record.rootExited || result.addressability === "lost") { - entry.cleanupUnconfirmed = true; - for (const recordOwner of owners) { - transferredOwners.add(recordOwner); + if (command.onExistingProcess === "skip") { + const activeRecord = entry.records.find( + (record) => record.status === "active", + ); + if (activeRecord) { + activeRecord.owners.add(owner); + claimedKeys.add(processKey); } - return; + logSkipped(command, "already-started"); + return false; } - record.owners.clear(); - for (const recordOwner of owners) { - record.owners.add(recordOwner); + if (entry.cleanupUnconfirmed) { + entry.status = "degraded"; + writeLog(dependencies.logger, { + type: "command.stop-failed", + scope: command.scope, + index: command.index, + name: command.name, + pid: undefined, + trigger: "restart", + reason: "unconfirmed", + }); + return true; } - record.status = "active"; - record.stopPromise = undefined; - safeSurvivors.push(record); - }); - entry.records = safeSurvivors; - if (!stopFailed) { - const replacement = spawnRecord( + return await restartIdentity( + entry, command, + processKey, owner, - entry, + claimedKeys, dependencies, ); - if (replacement) { - for (const recordOwner of ownerUnion) { - replacement.owners.add(recordOwner); - } - entry.retryTombstone = undefined; - claimedKeys.add(processKey); - } - entry.status = "stable"; - return true; - } + }, + ); + commandAttempted ||= attempted; + } - const oldestSurvivor = safeSurvivors[0]; - if (oldestSurvivor) { - for (const recordOwner of transferredOwners) { - oldestSurvivor.owners.add(recordOwner); - } - claimedKeys.add(processKey); - } - entry.status = "degraded"; - return true; - }, - ); - commandAttempted ||= attempted; - } + if (!commandAttempted) { + writeLog(dependencies.logger, { + type: "batch.skipped", + reason: commands.length === 0 ? "no-valid-commands" : "already-started", + }); + } - if (!commandAttempted) { - writeLog(dependencies.logger, { - type: "batch.skipped", - reason: "already-started", - }); + if (durablePlan.enabled) { + await persistDurableRecords(durablePlan, durableScopes, dependencies); + } + } finally { + if (durablePlan.enabled) { + durablePlan.handle.release(); + } } return activation; diff --git a/src/durable-lock.ts b/src/durable-lock.ts new file mode 100644 index 0000000..9ab779f --- /dev/null +++ b/src/durable-lock.ts @@ -0,0 +1,327 @@ +import { + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { randomBytes } from "node:crypto"; +import type { + ProcessIdentityController, + ProcessProbeResult, +} from "./process-identity.js"; +import { getErrorCode } from "./error-code.js"; + +export interface DurableLockHolder { + pid: number; + startToken: string; +} + +export interface DurableLockHandle { + release(): void; +} + +export type DurableLockResult = + | { status: "acquired"; handle: DurableLockHandle } + | { status: "unavailable" }; + +export interface DurableLock { + acquireLock(holder: DurableLockHolder): Promise; +} + +export interface DurableLockOptions { + lockDirectory: string; + lockHolderPath: string; + identity?: ProcessIdentityController; + delay?: (milliseconds: number) => Promise; + lockTimeoutMs?: number; + lockPollIntervalMs?: number; + nowMs?: () => number; + random?: () => string; + mkdir?: (path: string) => void; + writeFile?: (path: string, content: string) => void; + readFile?: (path: string) => string; + rm?: (path: string) => void; + stat?: (path: string) => { mtimeMs: number }; +} + +const DEFAULT_LOCK_TIMEOUT_MS = 15_000; +const DEFAULT_LOCK_POLL_INTERVAL_MS = 50; +const GUARD_STALE_AFTER_MS = 1_000; +const RECLAIM_GUARD_SUFFIX = ".reclaim"; +const DIRECTORY_MODE = 0o700; +const FILE_MODE = 0o600; + +type HolderClassification = + | { kind: "valid"; generation: string; pid: number; startToken: string } + | { kind: "missing" } + | { kind: "malformed"; content: string | undefined }; + +export function createDurableLock(options: DurableLockOptions): DurableLock { + const { lockDirectory, lockHolderPath } = options; + const identity = options.identity; + const delay = + options.delay ?? + ((milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds))); + const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS; + const lockPollIntervalMs = + options.lockPollIntervalMs ?? DEFAULT_LOCK_POLL_INTERVAL_MS; + const nowMs = options.nowMs ?? Date.now; + const random = options.random ?? (() => randomBytes(8).toString("hex")); + const mkdir = + options.mkdir ?? + ((path: string) => mkdirSync(path, { mode: DIRECTORY_MODE })); + const writeFile = + options.writeFile ?? + ((path: string, content: string) => + writeFileSync(path, content, { encoding: "utf8", mode: FILE_MODE })); + const readFile = + options.readFile ?? ((path: string) => readFileSync(path, "utf8")); + const rm = + options.rm ?? + ((path: string) => rmSync(path, { recursive: true, force: true })); + const stat = options.stat ?? ((path: string) => statSync(path)); + + const reclaimGuardDirectory = `${lockDirectory}${RECLAIM_GUARD_SUFFIX}`; + + function removeLock(): boolean { + try { + rm(lockDirectory); + return true; + } catch { + return false; + } + } + + function isLockDirectoryEmpty(): boolean { + try { + readFile(lockHolderPath); + return false; + } catch (error) { + return getErrorCode(error) === "ENOENT"; + } + } + + function classifyHolderFile(): HolderClassification { + let content: string; + try { + content = readFile(lockHolderPath); + } catch (error) { + return getErrorCode(error) === "ENOENT" + ? { kind: "missing" } + : { kind: "malformed", content: undefined }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return { kind: "malformed", content }; + } + + if (typeof parsed !== "object" || parsed === null) { + return { kind: "malformed", content }; + } + const candidate = parsed as { + generation?: unknown; + pid?: unknown; + startToken?: unknown; + }; + const generation = candidate.generation; + const pid = candidate.pid; + const startToken = candidate.startToken; + if ( + typeof generation !== "string" || + !generation || + typeof pid !== "number" || + !Number.isSafeInteger(pid) || + pid <= 0 || + typeof startToken !== "string" || + !startToken + ) { + return { kind: "malformed", content }; + } + + return { kind: "valid", generation, pid, startToken }; + } + + function isGuardStale(): boolean { + let mtimeMs: number; + try { + mtimeMs = stat(reclaimGuardDirectory).mtimeMs; + } catch { + return false; + } + return nowMs() - mtimeMs > GUARD_STALE_AFTER_MS; + } + + function tryAcquireGuard(): boolean { + try { + mkdir(reclaimGuardDirectory); + return true; + } catch (error) { + if (getErrorCode(error) !== "EEXIST") { + return false; + } + if (!isGuardStale()) { + return false; + } + try { + rm(reclaimGuardDirectory); + mkdir(reclaimGuardDirectory); + return true; + } catch { + return false; + } + } + } + + function releaseGuard(): void { + try { + rm(reclaimGuardDirectory); + } catch {} + } + + type ReclaimExpectation = + | { kind: "valid"; generation: string } + | { kind: "malformed"; content: string | undefined } + | { kind: "missing" }; + + async function attemptGuardedRemoval( + expectation: ReclaimExpectation, + ): Promise { + if (!tryAcquireGuard()) { + return false; + } + try { + const fresh = classifyHolderFile(); + if (expectation.kind === "valid") { + return fresh.kind === "valid" && + fresh.generation === expectation.generation + ? removeLock() + : false; + } + if (expectation.kind === "malformed") { + return fresh.kind === "malformed" && + fresh.content === expectation.content + ? removeLock() + : false; + } + return fresh.kind === "missing" ? removeLock() : false; + } finally { + releaseGuard(); + } + } + + function createHandle(generation: string): DurableLockHandle { + return { + release(): void { + try { + const parsed = JSON.parse(readFile(lockHolderPath)) as { + generation?: unknown; + }; + if (parsed.generation !== generation) { + return; + } + } catch { + return; + } + removeLock(); + }, + }; + } + + return { + async acquireLock(holder: DurableLockHolder): Promise { + const generation = random(); + const deadline = nowMs() + lockTimeoutMs; + let missingHolderStreak = 0; + + async function tryReclaim(): Promise { + const classification = classifyHolderFile(); + + if (classification.kind === "missing") { + missingHolderStreak += 1; + if (missingHolderStreak < 2) { + return false; + } + } else { + missingHolderStreak = 0; + } + + let reclaimed: boolean; + if (classification.kind === "valid") { + if (!identity) { + return false; + } + let probed: ProcessProbeResult; + try { + probed = await identity.probe( + classification.pid, + classification.startToken, + ); + } catch { + return false; + } + if (probed.status !== "gone") { + return false; + } + reclaimed = await attemptGuardedRemoval({ + kind: "valid", + generation: classification.generation, + }); + } else if (classification.kind === "malformed") { + reclaimed = await attemptGuardedRemoval({ + kind: "malformed", + content: classification.content, + }); + } else { + reclaimed = await attemptGuardedRemoval({ kind: "missing" }); + } + + if (reclaimed) { + missingHolderStreak = 0; + } + + return reclaimed; + } + + for (;;) { + let createdLockDirectory = false; + try { + mkdir(lockDirectory); + createdLockDirectory = true; + writeFile( + lockHolderPath, + JSON.stringify({ + generation, + pid: holder.pid, + startToken: holder.startToken, + }), + ); + return { status: "acquired", handle: createHandle(generation) }; + } catch (error) { + if (getErrorCode(error) !== "EEXIST") { + if (createdLockDirectory && isLockDirectoryEmpty()) { + removeLock(); + } + return { status: "unavailable" }; + } + } + + if (nowMs() >= deadline) { + return { status: "unavailable" }; + } + + if (await tryReclaim()) { + continue; + } + + await delay( + lockPollIntervalMs + Math.floor(Math.random() * lockPollIntervalMs), + ); + } + }, + }; +} diff --git a/src/durable-registry.ts b/src/durable-registry.ts new file mode 100644 index 0000000..793816f --- /dev/null +++ b/src/durable-registry.ts @@ -0,0 +1,299 @@ +import { createHash, randomBytes } from "node:crypto"; +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir, hostname } from "node:os"; +import { posix, win32 } from "node:path"; +import { + createDurableLock, + type DurableLockHolder, + type DurableLockResult, +} from "./durable-lock.js"; +import type { ProcessIdentityController } from "./process-identity.js"; +import { getErrorCode } from "./error-code.js"; + +export type { + DurableLockHandle, + DurableLockHolder, + DurableLockResult, +} from "./durable-lock.js"; + +interface DurableRecordFields { + identityHash: string; + pid: number; + startToken: string; + stopOnExit: false; + creationOrder: number; + recordedAt: string; +} + +export type DurableRecord = + | (DurableRecordFields & { scope: "global"; projectRootHash?: never }) + | (DurableRecordFields & { scope: "project"; projectRootHash: string }); + +export type DurableUnavailableReason = + | "unreadable" + | "malformed" + | "unsupported-schema"; + +export type DurableReadResult = + | { status: "loaded"; records: DurableRecord[]; quarantined?: true } + | { status: "unavailable"; reason: DurableUnavailableReason }; + +export interface DurableRegistry { + hashIdentity(signature: string): string; + hashProjectRoot(normalizedRoot: string): string; + exists(): boolean; + read(): DurableReadResult; + write(records: readonly DurableRecord[]): boolean; + acquireLock(holder: DurableLockHolder): Promise; +} + +export interface DurableRegistryOptions { + root?: string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + home?: string; + hostname?: string; + random?: () => string; + identity?: ProcessIdentityController; + delay?: (milliseconds: number) => Promise; + lockTimeoutMs?: number; + lockPollIntervalMs?: number; + clock?: { + nowMs: () => number; + stat: (path: string) => { mtimeMs: number }; + }; +} + +const SCHEMA_VERSION = 1; +const REGISTRY_FILE_NAME = "registry.json"; +const LOCK_DIRECTORY_NAME = "registry.lock"; +const LOCK_HOLDER_FILE_NAME = "holder.json"; +const DIRECTORY_MODE = 0o700; +const FILE_MODE = 0o600; +const IDENTITY_HASH_PREFIX = "identity:"; +const PROJECT_ROOT_HASH_PREFIX = "project-root:"; +const HOST_SEGMENT_MAX_LENGTH = 64; +const UNKNOWN_HOST_SEGMENT = "unknown-host"; + +function sanitizeHostSegment(rawHostname: string): string { + const segment = rawHostname + .toLowerCase() + .replace(/[^a-z0-9.-]/g, "-") + .slice(0, HOST_SEGMENT_MAX_LENGTH) + .replace(/^[.-]+|[.-]+$/g, ""); + return segment || UNKNOWN_HOST_SEGMENT; +} + +export function resolveDurableStateRoot( + options: Pick< + DurableRegistryOptions, + "platform" | "env" | "home" | "hostname" + > = {}, +): string { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const home = options.home ?? homedir(); + const host = sanitizeHostSegment(options.hostname ?? hostname()); + + if (platform === "win32") { + const configuredRoot = env.LOCALAPPDATA?.trim(); + const root = + configuredRoot && win32.isAbsolute(configuredRoot) + ? configuredRoot + : win32.join(home, "AppData", "Local"); + return win32.join(root, "opencode", "startup-commands", host); + } + + if (platform === "darwin") { + return posix.join( + home, + "Library", + "Application Support", + "OpenCode", + "startup-commands", + host, + ); + } + + const configuredRoot = env.XDG_STATE_HOME?.trim(); + const root = + configuredRoot && posix.isAbsolute(configuredRoot) + ? configuredRoot + : posix.join(home, ".local", "state"); + return posix.join(root, "opencode", "startup-commands", host); +} + +function isHexDigest(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); +} + +function isValidRecord(value: unknown): value is DurableRecord { + if (typeof value !== "object" || value === null) { + return false; + } + const candidate = value as Record; + + if ( + !isHexDigest(candidate.identityHash) || + typeof candidate.pid !== "number" || + !Number.isSafeInteger(candidate.pid) || + candidate.pid <= 0 || + typeof candidate.startToken !== "string" || + !candidate.startToken || + candidate.stopOnExit !== false || + typeof candidate.creationOrder !== "number" || + !Number.isSafeInteger(candidate.creationOrder) || + candidate.creationOrder < 0 || + typeof candidate.recordedAt !== "string" + ) { + return false; + } + + if (candidate.scope === "global") { + return candidate.projectRootHash === undefined; + } + if (candidate.scope === "project") { + return isHexDigest(candidate.projectRootHash); + } + return false; +} + +export function createDurableRegistry( + options: DurableRegistryOptions = {}, +): DurableRegistry { + const root = options.root ?? resolveDurableStateRoot(options); + const random = options.random ?? (() => randomBytes(8).toString("hex")); + const nowMs = options.clock?.nowMs ?? Date.now; + + const platform = options.platform ?? process.platform; + const joinPath = platform === "win32" ? win32.join : posix.join; + const registryPath = joinPath(root, REGISTRY_FILE_NAME); + const lockDirectory = joinPath(root, LOCK_DIRECTORY_NAME); + const lockHolderPath = joinPath(lockDirectory, LOCK_HOLDER_FILE_NAME); + + const lock = createDurableLock({ + lockDirectory, + lockHolderPath, + identity: options.identity, + delay: options.delay, + lockTimeoutMs: options.lockTimeoutMs, + lockPollIntervalMs: options.lockPollIntervalMs, + nowMs: options.clock?.nowMs, + stat: options.clock?.stat, + random, + }); + + function quarantineCorruptRegistry(): DurableReadResult { + try { + renameSync(registryPath, `${registryPath}.corrupt-${nowMs()}`); + } catch { + return { status: "unavailable", reason: "malformed" }; + } + return { status: "loaded", records: [], quarantined: true }; + } + + return { + hashIdentity(signature: string): string { + return createHash("sha256") + .update(IDENTITY_HASH_PREFIX + signature, "utf8") + .digest("hex"); + }, + + hashProjectRoot(normalizedRoot: string): string { + return createHash("sha256") + .update(PROJECT_ROOT_HASH_PREFIX + normalizedRoot, "utf8") + .digest("hex"); + }, + + exists(): boolean { + try { + statSync(registryPath); + return true; + } catch { + return false; + } + }, + + read(): DurableReadResult { + let content: string; + try { + content = readFileSync(registryPath, "utf8"); + } catch (error) { + return getErrorCode(error) === "ENOENT" + ? { status: "loaded", records: [] } + : { status: "unavailable", reason: "unreadable" }; + } + + let document: unknown; + try { + document = JSON.parse(content) as unknown; + } catch { + return quarantineCorruptRegistry(); + } + + if (typeof document !== "object" || document === null) { + return quarantineCorruptRegistry(); + } + const shape = document as Record; + + if (shape.schemaVersion !== SCHEMA_VERSION) { + return typeof shape.schemaVersion === "number" + ? { status: "unavailable", reason: "unsupported-schema" } + : quarantineCorruptRegistry(); + } + if ( + !Array.isArray(shape.records) || + !shape.records.every(isValidRecord) + ) { + return quarantineCorruptRegistry(); + } + + return { status: "loaded", records: [...shape.records] }; + }, + + write(records: readonly DurableRecord[]): boolean { + if (!records.every(isValidRecord)) { + return false; + } + + const temporaryPath = `${registryPath}.${random()}.tmp`; + + try { + mkdirSync(root, { recursive: true, mode: DIRECTORY_MODE }); + writeFileSync( + temporaryPath, + `${JSON.stringify( + { schemaVersion: SCHEMA_VERSION, records }, + undefined, + 2, + )}\n`, + { encoding: "utf8", mode: FILE_MODE }, + ); + renameSync(temporaryPath, registryPath); + return true; + } catch { + return false; + } finally { + try { + rmSync(temporaryPath, { force: true }); + } catch {} + } + }, + + async acquireLock(holder: DurableLockHolder): Promise { + try { + mkdirSync(root, { recursive: true, mode: DIRECTORY_MODE }); + } catch { + return { status: "unavailable" }; + } + return lock.acquireLock(holder); + }, + }; +} diff --git a/src/error-code.ts b/src/error-code.ts new file mode 100644 index 0000000..6ddf141 --- /dev/null +++ b/src/error-code.ts @@ -0,0 +1,7 @@ +export function getErrorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} diff --git a/src/logger.ts b/src/logger.ts index 5d0fef8..1d1bd93 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -17,6 +17,14 @@ export type CommandStopTrigger = | "root-exited" | "restart"; +export type DurableUnavailableCause = + | "registry-unreadable" + | "registry-malformed" + | "registry-unsupported-schema" + | "lock-unavailable" + | "identity-unavailable" + | "registry-unwritable"; + interface CommandStopContext { scope: LogScope; index: number; @@ -51,7 +59,7 @@ export type LogEvent = scope: LogScope; index: number; name: string; - reason: "duplicate" | "already-started"; + reason: "duplicate" | "already-started" | "durable-unavailable"; } | { type: "command.spawned"; @@ -87,7 +95,35 @@ export type LogEvent = | ({ type: "command.stop-failed"; reason: ProcessTreeFailureReason; - } & CommandStopContext); + } & CommandStopContext) + | { + type: "durable.record-adopted"; + scope: LogScope; + index: number; + name: string; + pid: number; + } + | { + type: "durable.record-dropped"; + scope: LogScope; + index?: number; + name?: string; + pid: number; + } + | { + type: "durable.record-unverifiable"; + scope: LogScope; + index?: number; + name?: string; + pid?: number; + } + | { + type: "durable.reconciled"; + scope: LogScope; + stoppedCount: number; + } + | { type: "durable.registry-quarantined" } + | { type: "durable.unavailable"; cause: DurableUnavailableCause }; export interface Logger { write(event: LogEvent): void; @@ -166,6 +202,26 @@ function formatConsoleEvent(event: LogEvent): string { return `stop forced ${formatStopContext(event)}`; case "command.stop-failed": return `stop failed ${formatStopContext(event)} reason=${event.reason}`; + case "durable.record-adopted": + return `record adopted scope=${event.scope} index=${event.index} name=${formatName(event.name)} pid=${event.pid}`; + case "durable.record-dropped": + return `record dropped scope=${event.scope}${ + event.index === undefined ? "" : ` index=${event.index}` + }${ + event.name === undefined ? "" : ` name=${formatName(event.name)}` + } pid=${event.pid}`; + case "durable.record-unverifiable": + return `record unverifiable scope=${event.scope}${ + event.index === undefined ? "" : ` index=${event.index}` + }${event.name === undefined ? "" : ` name=${formatName(event.name)}`}${ + event.pid === undefined ? "" : ` pid=${event.pid}` + }`; + case "durable.reconciled": + return `reconciled scope=${event.scope} stoppedCount=${event.stoppedCount}`; + case "durable.registry-quarantined": + return "registry quarantined"; + case "durable.unavailable": + return `durable unavailable cause=${event.cause}`; default: return assertNeverEvent(event); } @@ -201,6 +257,26 @@ function formatFileEvent(event: LogEvent): string { return `${event.type} ${formatStopContext(event)}`; case "command.stop-failed": return `${event.type} ${formatStopContext(event)} reason=${event.reason}`; + case "durable.record-adopted": + return `${event.type} scope=${event.scope} index=${event.index} name=${formatName(event.name)} pid=${event.pid}`; + case "durable.record-dropped": + return `${event.type} scope=${event.scope}${ + event.index === undefined ? "" : ` index=${event.index}` + }${ + event.name === undefined ? "" : ` name=${formatName(event.name)}` + } pid=${event.pid}`; + case "durable.record-unverifiable": + return `${event.type} scope=${event.scope}${ + event.index === undefined ? "" : ` index=${event.index}` + }${event.name === undefined ? "" : ` name=${formatName(event.name)}`}${ + event.pid === undefined ? "" : ` pid=${event.pid}` + }`; + case "durable.reconciled": + return `${event.type} scope=${event.scope} stoppedCount=${event.stoppedCount}`; + case "durable.registry-quarantined": + return event.type; + case "durable.unavailable": + return `${event.type} cause=${event.cause}`; default: return assertNeverEvent(event); } diff --git a/src/process-identity.ts b/src/process-identity.ts new file mode 100644 index 0000000..98c9b82 --- /dev/null +++ b/src/process-identity.ts @@ -0,0 +1,355 @@ +import { spawn as spawnProcess } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { win32 } from "node:path"; +import { getErrorCode } from "./error-code.js"; + +export type ProcessIdentityUnknownReason = + | "permission-denied" + | "facility-unavailable" + | "malformed-output" + | "timeout" + | "unconfirmed"; + +export type ProcessDescribeResult = + | { status: "described"; token: string } + | { status: "gone" } + | { status: "unknown"; reason: ProcessIdentityUnknownReason }; + +export type ProcessProbeResult = + | { status: "alive" } + | { status: "gone" } + | { status: "unknown"; reason: ProcessIdentityUnknownReason }; + +export interface ProcessIdentityController { + describe(pid: number | undefined): Promise; + probe( + pid: number | undefined, + expectedToken: string, + ): Promise; +} + +export interface ProcessQuerySpawnOptions { + detached: false; + shell: false; + stdio: ["ignore", "pipe", "ignore"]; + windowsHide: true; + env?: NodeJS.ProcessEnv; +} + +export interface ProcessQueryChild { + readonly stdout: { + on(event: "data", listener: (chunk: Buffer | string) => void): unknown; + } | null; + once(event: "error", listener: (error: Error) => void): ProcessQueryChild; + once( + event: "close", + listener: (code: number | null) => void, + ): ProcessQueryChild; + kill(signal?: NodeJS.Signals | number): boolean; + unref(): void; +} + +export type ProcessQuerySpawn = ( + executable: string, + args: string[], + options: ProcessQuerySpawnOptions, +) => ProcessQueryChild; + +export interface ProcessIdentityControllerOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + readTextFile?: (path: string) => string; + spawn?: ProcessQuerySpawn; + timeoutMs?: number; +} + +const LINUX_BOOT_ID_PATH = "/proc/sys/kernel/random/boot_id"; +const DEFAULT_TIMEOUT_MS = 5_000; + +function isValidPid(pid: number | undefined): pid is number { + return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0; +} + +function unknownReasonFor(error: unknown): ProcessIdentityUnknownReason { + const code = getErrorCode(error); + if (code === "EACCES" || code === "EPERM") { + return "permission-denied"; + } + return "unconfirmed"; +} + +function tokenTagFor(platform: NodeJS.Platform): string { + if (platform === "win32") { + return "win1"; + } + if (platform === "darwin") { + return "mac1"; + } + return "linux1"; +} + +function parseStartTicks(statContent: string): string | undefined { + const tailStart = statContent.lastIndexOf(")"); + if (tailStart < 0) { + return undefined; + } + const fields = statContent + .slice(tailStart + 1) + .trim() + .split(/\s+/); + const startTicks = fields[19]; + return startTicks !== undefined && /^\d+$/.test(startTicks) + ? startTicks + : undefined; +} + +function describeLinux( + pid: number, + readTextFile: (path: string) => string, +): ProcessDescribeResult { + let bootId: string; + try { + bootId = readTextFile(LINUX_BOOT_ID_PATH).trim(); + } catch (error) { + return { status: "unknown", reason: unknownReasonFor(error) }; + } + if (!/^[0-9a-f-]{4,64}$/.test(bootId)) { + return { status: "unknown", reason: "malformed-output" }; + } + + let statContent: string; + try { + statContent = readTextFile(`/proc/${pid}/stat`); + } catch (error) { + return getErrorCode(error) === "ENOENT" + ? { status: "gone" } + : { status: "unknown", reason: unknownReasonFor(error) }; + } + + const startTicks = parseStartTicks(statContent); + return startTicks === undefined + ? { status: "unknown", reason: "malformed-output" } + : { status: "described", token: `linux1:${bootId}:${startTicks}` }; +} + +type QueryOutcome = + | { status: "completed"; output: string; exitCode: number | null } + | { status: "failed"; reason: ProcessIdentityUnknownReason }; + +const MAX_QUERY_OUTPUT_BYTES = 4096; + +function runQuery( + spawn: ProcessQuerySpawn, + executable: string, + args: string[], + timeoutMs: number, + env?: NodeJS.ProcessEnv, +): Promise { + let child: ProcessQueryChild; + try { + child = spawn(executable, args, { + detached: false, + shell: false, + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + ...(env ? { env } : {}), + }); + } catch (error) { + return Promise.resolve({ + status: "failed", + reason: + getErrorCode(error) === "ENOENT" + ? "facility-unavailable" + : unknownReasonFor(error), + }); + } + + return new Promise((resolve) => { + let settled = false; + let output = ""; + let timer: ReturnType | undefined; + + const settle = (outcome: QueryOutcome): void => { + if (settled) { + return; + } + settled = true; + if (timer !== undefined) { + clearTimeout(timer); + } + resolve(outcome); + }; + + try { + child.stdout?.on("data", (chunk) => { + if (output.length < MAX_QUERY_OUTPUT_BYTES) { + const text = + typeof chunk === "string" ? chunk : chunk.toString("utf8"); + output += text.slice(0, MAX_QUERY_OUTPUT_BYTES - output.length); + } + }); + child.once("error", (error) => { + settle({ + status: "failed", + reason: + getErrorCode(error) === "ENOENT" + ? "facility-unavailable" + : unknownReasonFor(error), + }); + }); + child.once("close", (exitCode) => { + settle({ status: "completed", output, exitCode }); + }); + } catch (error) { + settle({ status: "failed", reason: unknownReasonFor(error) }); + } + + if (!settled) { + timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + try { + child.kill("SIGKILL"); + } catch {} + try { + child.unref(); + } catch {} + resolve({ status: "failed", reason: "timeout" }); + }, timeoutMs); + } + }); +} + +function resolvePowerShellPath(env: NodeJS.ProcessEnv): string | undefined { + const configuredRoot = env.SystemRoot; + if (typeof configuredRoot !== "string") { + return undefined; + } + const root = configuredRoot.trim(); + if (!root || !win32.isAbsolute(root)) { + return undefined; + } + return win32.join( + root, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + +async function describeWindows( + pid: number, + env: NodeJS.ProcessEnv, + spawn: ProcessQuerySpawn, + timeoutMs: number, +): Promise { + const powerShellPath = resolvePowerShellPath(env); + if (!powerShellPath) { + return { status: "unknown", reason: "facility-unavailable" }; + } + + const outcome = await runQuery( + spawn, + powerShellPath, + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($null -eq $p) { 'absent' } else { try { $p.StartTime.ToUniversalTime().Ticks } catch { 'denied' } }`, + ], + timeoutMs, + ); + + if (outcome.status === "failed") { + return { status: "unknown", reason: outcome.reason }; + } + + const value = outcome.output.trim(); + if (value === "absent") { + return { status: "gone" }; + } + if (value === "denied") { + return { status: "unknown", reason: "permission-denied" }; + } + return /^\d+$/.test(value) + ? { status: "described", token: `win1:${value}` } + : { status: "unknown", reason: "malformed-output" }; +} + +async function describeDarwin( + pid: number, + env: NodeJS.ProcessEnv, + spawn: ProcessQuerySpawn, + timeoutMs: number, +): Promise { + const outcome = await runQuery( + spawn, + "/bin/ps", + ["-p", String(pid), "-o", "lstart="], + timeoutMs, + { ...env, TZ: "UTC", LC_ALL: "C" }, + ); + + if (outcome.status === "failed") { + return { status: "unknown", reason: outcome.reason }; + } + + const value = outcome.output.trim().replace(/\s+/g, " "); + if (!value) { + return { status: "gone" }; + } + return { status: "described", token: `mac1:${value}` }; +} + +export function createProcessIdentityController( + options: ProcessIdentityControllerOptions = {}, +): ProcessIdentityController { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const readTextFile = + options.readTextFile ?? ((path: string) => readFileSync(path, "utf8")); + const spawn: ProcessQuerySpawn = + options.spawn ?? + ((executable, args, spawnOptions) => + spawnProcess(executable, args, spawnOptions)); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + const controller: ProcessIdentityController = { + async describe(pid): Promise { + if (!isValidPid(pid)) { + return { status: "gone" }; + } + if (platform === "linux") { + return describeLinux(pid, readTextFile); + } + if (platform === "win32") { + return describeWindows(pid, env, spawn, timeoutMs); + } + if (platform === "darwin") { + return describeDarwin(pid, env, spawn, timeoutMs); + } + return { status: "unknown", reason: "facility-unavailable" }; + }, + + async probe(pid, expectedToken): Promise { + if (!expectedToken.startsWith(`${tokenTagFor(platform)}:`)) { + return { status: "gone" }; + } + const described = await controller.describe(pid); + if (described.status === "unknown") { + return described; + } + if (described.status === "gone") { + return { status: "gone" }; + } + return described.token === expectedToken + ? { status: "alive" } + : { status: "gone" }; + }, + }; + + return controller; +} diff --git a/src/process-tree.ts b/src/process-tree.ts index 22a8833..2fbf63f 100644 --- a/src/process-tree.ts +++ b/src/process-tree.ts @@ -1,5 +1,6 @@ import { spawn as spawnProcess } from "node:child_process"; import { win32 } from "node:path"; +import { getErrorCode } from "./error-code.js"; export type ProcessTreeFailureReason = | "missing-pid" @@ -85,15 +86,6 @@ type RootProbeResult = | { status: "lost" } | { status: "failed"; reason: ProcessTreeFailureReason }; -function getErrorCode(error: unknown): string | undefined { - if (typeof error !== "object" || error === null || !("code" in error)) { - return undefined; - } - - const code = (error as { code?: unknown }).code; - return typeof code === "string" ? code : undefined; -} - function signalProcess( kill: NonNullable, pid: number, diff --git a/src/server-internal.ts b/src/server-internal.ts index 08d16db..50aff0e 100644 --- a/src/server-internal.ts +++ b/src/server-internal.ts @@ -6,16 +6,58 @@ import type { resolveProjectConfigPath, } from "./config.js"; import { + normalizeProjectRoot, runStartupCommands, type StartupDependencies, + type StartupRunOptions, } from "./core.js"; import type { Logger } from "./logger.js"; +export interface StartupOwnerIdentity { + readonly pid: number; + readonly startToken: string; +} + export interface StartupCommandsServerDependencies extends StartupDependencies { loadConfigFile: typeof loadConfigFile; resolveGlobalConfigPath: typeof resolveGlobalConfigPath; resolveProjectConfigPath: typeof resolveProjectConfigPath; + resolveOwnerIdentity?(): Promise; +} + +async function resolveOwner( + dependencies: StartupCommandsServerDependencies, +): Promise { + if (!dependencies.resolveOwnerIdentity) { + return undefined; + } + try { + return await dependencies.resolveOwnerIdentity(); + } catch { + return undefined; + } +} + +function buildRunOptions( + dependencies: StartupCommandsServerDependencies, + worktree: string, + authoritative: boolean, +): StartupRunOptions | undefined { + const { registry, identity } = dependencies; + if (!registry || !identity) { + return undefined; + } + + const projectRootHash = registry.hashProjectRoot( + normalizeProjectRoot(worktree), + ); + + return { + authoritative, + projectRootHash, + resolveOwner: () => resolveOwner(dependencies), + }; } function writeConfigDiagnostic( @@ -39,7 +81,6 @@ function writeConfigDiagnostic( reason: diagnostic.reason, }); } catch { - // Logging must not prevent valid commands from reaching core execution. } } @@ -66,9 +107,17 @@ export function createStartupCommandsServer( writeConfigDiagnostic(diagnostic, dependencies.logger); } + const runOptions = buildRunOptions( + dependencies, + input.worktree, + globalConfig.diagnostics.length === 0 && + projectConfig.diagnostics.length === 0, + ); + const activation = await runStartupCommands( [...globalConfig.commands, ...projectConfig.commands], dependencies, + runOptions, ); return { diff --git a/src/server.ts b/src/server.ts index ed9d5d4..4a626bf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,10 +5,17 @@ import { resolveProjectConfigPath, } from "./config.js"; import { processState } from "./core.js"; +import { + createDurableRegistry, + resolveDurableStateRoot, +} from "./durable-registry.js"; import { createLogger } from "./logger.js"; +import { createProcessIdentityController } from "./process-identity.js"; import { createProcessTreeController } from "./process-tree.js"; import { createStartupCommandsServer } from "./server-internal.js"; +const identity = createProcessIdentityController(); + const startupCommandsServer = createStartupCommandsServer({ loadConfigFile, resolveGlobalConfigPath, @@ -17,6 +24,17 @@ const startupCommandsServer = createStartupCommandsServer({ state: processState, processTree: createProcessTreeController(), logger: createLogger(), + identity, + registry: createDurableRegistry({ + root: resolveDurableStateRoot(), + identity, + }), + async resolveOwnerIdentity() { + const described = await identity.describe(process.pid); + return described.status === "described" + ? { pid: process.pid, startToken: described.token } + : undefined; + }, }); export default startupCommandsServer; diff --git a/tests/community-files.test.ts b/tests/community-files.test.ts index 71b203c..960f861 100644 --- a/tests/community-files.test.ts +++ b/tests/community-files.test.ts @@ -45,7 +45,7 @@ const expectedBugIssueForm: Record = { id: "plugin-version", attributes: { label: "Plugin version", - placeholder: "1.1.0", + placeholder: "1.2.0", }, validations: { required: true }, }, diff --git a/tests/core-durable.test.ts b/tests/core-durable.test.ts new file mode 100644 index 0000000..d93b944 --- /dev/null +++ b/tests/core-durable.test.ts @@ -0,0 +1,1856 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, normalize, resolve } from "node:path"; +import { + createStartupState, + getOrCreateProcessState, + runStartupCommands, + type SpawnFunction, +} from "../src/core.js"; +import { createDurableRegistry } from "../src/durable-registry.js"; +import type { + DurableLockResult, + DurableReadResult, + DurableRecord, + DurableRegistry, +} from "../src/durable-registry.js"; +import { + aliveIdentity, + createLogger, + FakeChild, + FakeProcessTree, + FakeRegistry, + globalCommand, + orphanRecord, + persistedRecord, + projectCommand, + RoundTrippingRegistry, + seedAdopted, +} from "./helpers/core-fixtures.js"; + +const DURABLE_SKIP = { + onExistingProcess: "skip", + stopOnExit: false, +} as const; + +describe("adopted records", () => { + const helperProcessKey = `global:${JSON.stringify(["helper", []])}`; + + test("an adopted record is accepted by the state validator", () => { + const registry: Record = {}; + const state = createStartupState(); + seedAdopted(state, helperProcessKey, 4242, "linux1:boot-a:11"); + + registry[Symbol.for("opencode.startup-commands.state")] = state; + + expect(getOrCreateProcessState(registry)).toBe(state); + }); + + test("stopping an adopted record probes liveness instead of trusting a field", async () => { + const probes: Array<[number | undefined, string]> = []; + const processTree = new FakeProcessTree([{ status: "stopped" }]); + const state = createStartupState(); + + seedAdopted(state, helperProcessKey, 4242, "linux1:boot-a:11"); + + const activation = await runStartupCommands( + [ + globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "restart", + stopOnExit: false, + }), + ], + { + spawn: () => new FakeChild(5555), + state, + processTree, + logger: createLogger(), + identity: { + async describe() { + return { status: "described", token: "linux1:boot-a:11" }; + }, + async probe(pid, expectedToken) { + probes.push([pid, expectedToken]); + return { status: "alive" }; + }, + }, + }, + ); + await activation.dispose(); + + expect(probes[0]).toEqual([4242, "linux1:boot-a:11"]); + expect(processTree.calls[0]?.options.isRootExited()).toBe(false); + }); + + test("an unverifiable adopted record reports rootExited so it is never signalled", async () => { + const processTree = new FakeProcessTree([{ status: "stopped" }]); + const state = createStartupState(); + seedAdopted(state, helperProcessKey, 4242, "linux1:boot-a:11"); + + const activation = await runStartupCommands( + [ + globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "restart", + stopOnExit: false, + }), + ], + { + spawn: () => new FakeChild(5555), + state, + processTree, + logger: createLogger(), + identity: { + async describe() { + return { status: "unknown", reason: "permission-denied" }; + }, + async probe() { + return { status: "unknown", reason: "permission-denied" }; + }, + }, + }, + ); + await activation.dispose(); + + expect(processTree.calls[0]?.options.isRootExited()).toBe(true); + }); + + test("without an identity controller, an adopted record can never be verified", async () => { + const processTree = new FakeProcessTree([{ status: "stopped" }]); + const state = createStartupState(); + seedAdopted(state, helperProcessKey, 4242, "linux1:boot-a:11"); + + const activation = await runStartupCommands( + [ + globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "restart", + stopOnExit: false, + }), + ], + { + spawn: () => new FakeChild(5555), + state, + processTree, + logger: createLogger(), + }, + ); + await activation.dispose(); + + expect(processTree.calls[0]?.options.isRootExited()).toBe(true); + }); +}); + +describe("durable seeding", () => { + test("skip adopts a live persisted record instead of spawning", async () => { + const registry = new FakeRegistry({ + status: "loaded", + records: [persistedRecord()], + }); + const logger = createLogger(); + let spawnCalls = 0; + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => { + spawnCalls += 1; + return new FakeChild(9999); + }, + state: createStartupState(), + processTree: new FakeProcessTree(), + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(spawnCalls).toBe(0); + expect( + logger.events.some((event) => event.type === "durable.record-adopted"), + ).toBe(true); + expect( + logger.events.some( + (event) => + event.type === "command.skipped" && + event.reason === "already-started", + ), + ).toBe(true); + expect(registry.writes.at(-1)).toHaveLength(1); + expect(registry.releases).toBe(1); + }); + + test("start keeps the adopted record, appends one more, and persists both", async () => { + const registry = new FakeRegistry({ + status: "loaded", + records: [persistedRecord()], + }); + const processTree = new FakeProcessTree(); + const logger = createLogger(); + let spawnCalls = 0; + + const activation = await runStartupCommands( + [ + globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: false, + }), + ], + { + spawn: () => { + spawnCalls += 1; + return new FakeChild(9999); + }, + state: createStartupState(), + processTree, + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(spawnCalls).toBe(1); + expect(processTree.calls).toHaveLength(0); + expect( + logger.events.some((event) => event.type === "durable.record-adopted"), + ).toBe(true); + expect( + (registry.writes.at(-1) ?? []) + .map((record) => record.pid) + .sort((left, right) => left - right), + ).toEqual([4242, 9999]); + }); + + test("a gone record is dropped and the command spawns fresh", async () => { + const registry = new FakeRegistry({ + status: "loaded", + records: [persistedRecord()], + }); + const logger = createLogger(); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger, + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:boot-a:77" }; + }, + async probe() { + return { status: "gone" }; + }, + }, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect( + logger.events.some((event) => event.type === "durable.record-dropped"), + ).toBe(true); + expect(logger.events.some((event) => event.type === "command.spawned")).toBe( + true, + ); + expect(registry.writes.at(-1)?.[0]?.pid).toBe(9999); + expect(registry.writes.at(-1)?.[0]?.startToken).toBe("linux1:boot-a:77"); + }); + + test("an unverifiable record fails closed without spawning or stopping", async () => { + const processTree = new FakeProcessTree(); + const registry = new FakeRegistry({ + status: "loaded", + records: [persistedRecord()], + }); + const logger = createLogger(); + let spawnCalls = 0; + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => { + spawnCalls += 1; + return new FakeChild(9999); + }, + state: createStartupState(), + processTree, + logger, + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:e2e:1" }; + }, + async probe() { + return { status: "unknown", reason: "permission-denied" }; + }, + }, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(spawnCalls).toBe(0); + expect(processTree.calls).toHaveLength(0); + expect( + logger.events.some( + (event) => event.type === "durable.record-unverifiable", + ), + ).toBe(true); + expect(registry.writes.at(-1)).toHaveLength(1); + }); + + test("this process's own identity cannot be described blocks durable commands without ever acquiring the lock", async () => { + const registry = new FakeRegistry(); + const logger = createLogger(); + const processTree = new FakeProcessTree(); + let spawnCalls = 0; + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => { + spawnCalls += 1; + return new FakeChild(9999); + }, + state: createStartupState(), + processTree, + logger, + registry, + identity: { + async describe() { + return { status: "unknown", reason: "permission-denied" }; + }, + async probe() { + return { status: "alive" }; + }, + }, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(spawnCalls).toBe(0); + expect(processTree.calls).toHaveLength(0); + expect(registry.writes).toHaveLength(0); + expect(registry.acquireCalls).toBe(0); + expect( + logger.events.some( + (event) => + event.type === "durable.unavailable" && + event.cause === "identity-unavailable", + ), + ).toBe(true); + }); + + test("a stopOnExit true command is never persisted", async () => { + const registry = new FakeRegistry(); + + const activation = await runStartupCommands( + [ + globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "skip", + stopOnExit: true, + }), + ], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(registry.writes.at(-1)).toEqual([]); + }); + + test("an unavailable lock blocks durable commands and keeps direct ones", async () => { + const registry = new FakeRegistry({ status: "loaded", records: [] }, false); + const logger = createLogger(); + const spawned: string[] = []; + + const activation = await runStartupCommands( + [ + globalCommand("durable", [], "Durable", 0, DURABLE_SKIP), + globalCommand("direct", [], "Direct", 1, { + onExistingProcess: "skip", + stopOnExit: true, + }), + ], + { + spawn: (command) => { + spawned.push(command); + return new FakeChild(9999); + }, + state: createStartupState(), + processTree: new FakeProcessTree(), + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(spawned).toEqual(["direct"]); + expect( + logger.events.some( + (event) => + event.type === "durable.unavailable" && + event.cause === "lock-unavailable", + ), + ).toBe(true); + expect(registry.writes).toHaveLength(0); + }); + + test("a blocked durable command is skipped as durable-unavailable, never as already-started", async () => { + const registry = new FakeRegistry({ status: "loaded", records: [] }, false); + const logger = createLogger(); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect( + logger.events + .filter((event) => event.type === "command.skipped") + .map((event) => event.reason), + ).toEqual(["durable-unavailable"]); + }); + + test("a malformed registry blocks durable commands and writes nothing", async () => { + const registry = new FakeRegistry({ + status: "unavailable", + reason: "malformed", + }); + const logger = createLogger(); + let spawnCalls = 0; + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => { + spawnCalls += 1; + return new FakeChild(9999); + }, + state: createStartupState(), + processTree: new FakeProcessTree(), + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(spawnCalls).toBe(0); + expect(registry.writes).toHaveLength(0); + expect( + logger.events.some( + (event) => + event.type === "durable.unavailable" && + event.cause === "registry-malformed", + ), + ).toBe(true); + }); + + test("a quarantined registry is reported once and durable commands keep running", async () => { + const registry = new FakeRegistry({ + status: "loaded", + records: [], + quarantined: true, + }); + const logger = createLogger(); + const spawned: string[] = []; + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: (command) => { + spawned.push(command); + return new FakeChild(9999); + }, + state: createStartupState(), + processTree: new FakeProcessTree(), + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(spawned).toEqual(["helper"]); + expect( + logger.events.filter( + (event) => event.type === "durable.registry-quarantined", + ), + ).toHaveLength(1); + expect( + logger.events.some((event) => event.type === "durable.unavailable"), + ).toBe(false); + expect(registry.writes.at(-1)).toHaveLength(1); + }); + + test("records for another project root survive the rewrite untouched", async () => { + const foreign: DurableRecord = { + scope: "project", + identityHash: "f".repeat(64), + projectRootHash: "e".repeat(64), + pid: 1111, + startToken: "linux1:boot-a:1", + stopOnExit: false, + creationOrder: 0, + recordedAt: "2026-09-03T01:00:00.000Z", + }; + const registry = new FakeRegistry({ + status: "loaded", + records: [foreign], + }); + let probeCalls = 0; + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:boot-a:11" }; + }, + async probe() { + probeCalls += 1; + return { status: "alive" }; + }, + }, + }, + { authoritative: true, projectRootHash: "d".repeat(64) }, + ); + await activation.dispose(); + + expect(probeCalls).toBe(0); + expect(registry.writes.at(-1)).toContainEqual(foreign); + }); + + test("multiple foreign records for one foreign identity are concatenated verbatim, not reordered", async () => { + const foreignHigh: DurableRecord = { + scope: "project", + identityHash: "f".repeat(64), + projectRootHash: "e".repeat(64), + pid: 1111, + startToken: "linux1:boot-a:1", + stopOnExit: false, + creationOrder: 5, + recordedAt: "2026-09-03T01:00:00.000Z", + }; + const foreignLow: DurableRecord = { + scope: "project", + identityHash: "f".repeat(64), + projectRootHash: "e".repeat(64), + pid: 2222, + startToken: "linux1:boot-a:2", + stopOnExit: false, + creationOrder: 2, + recordedAt: "2026-09-03T02:00:00.000Z", + }; + const registry = new FakeRegistry({ + status: "loaded", + records: [foreignHigh, foreignLow], + }); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "d".repeat(64) }, + ); + await activation.dispose(); + + const written = registry.writes.at(-1) ?? []; + const foreignPids = written + .filter((record) => record.pid === 1111 || record.pid === 2222) + .map((record) => record.pid); + expect(foreignPids).toEqual([1111, 2222]); + }); + + test("without a registry the behavior is unchanged", async () => { + const logger = createLogger(); + let spawnCalls = 0; + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => { + spawnCalls += 1; + return new FakeChild(9999); + }, + state: createStartupState(), + processTree: new FakeProcessTree(), + logger, + }, + ); + await activation.dispose(); + + expect(spawnCalls).toBe(1); + expect(logger.events.some((event) => event.type.startsWith("durable."))).toBe( + false, + ); + }); + + test("a dropped record's creationOrder ordinal is never reused", async () => { + const registry = new FakeRegistry({ + status: "loaded", + records: [persistedRecord({ creationOrder: 5 })], + }); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:boot-a:77" }; + }, + async probe() { + return { status: "gone" }; + }, + }, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + const written = registry.writes.at(-1)?.[0]; + expect(written?.pid).toBe(9999); + expect(written?.creationOrder).toBe(6); + }); + + test("a write failure logs registry-unwritable instead of failing silently", async () => { + class UnwritableRegistry implements DurableRegistry { + public releases = 0; + public hashIdentity(signature: string): string { + return `identity-${signature}`; + } + public hashProjectRoot(normalizedRoot: string): string { + return `root-${normalizedRoot}`; + } + public exists(): boolean { + return true; + } + public read(): DurableReadResult { + return { status: "loaded", records: [] }; + } + public write(): boolean { + return false; + } + public async acquireLock(): Promise { + return { + status: "acquired", + handle: { + release: () => { + this.releases += 1; + }, + }, + }; + } + } + const registry = new UnwritableRegistry(); + const logger = createLogger(); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(registry.releases).toBe(1); + expect( + logger.events.some( + (event) => + event.type === "durable.unavailable" && + event.cause === "registry-unwritable", + ), + ).toBe(true); + }); + + test("a throwing registry read still releases the lock instead of leaking it", async () => { + class ThrowingReadRegistry implements DurableRegistry { + public releases = 0; + public hashIdentity(signature: string): string { + return `identity-${signature}`; + } + public hashProjectRoot(normalizedRoot: string): string { + return `root-${normalizedRoot}`; + } + public exists(): boolean { + return true; + } + public read(): DurableReadResult { + throw new Error("simulated read failure"); + } + public write(): boolean { + return true; + } + public async acquireLock(): Promise { + return { + status: "acquired", + handle: { + release: () => { + this.releases += 1; + }, + }, + }; + } + } + const registry = new ThrowingReadRegistry(); + + await expect( + runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ), + ).rejects.toThrow("simulated read failure"); + + expect(registry.releases).toBe(1); + }); + + test("a record whose pid could not be validated is logged, not dropped silently", async () => { + const registry = new FakeRegistry(); + const logger = createLogger(); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(undefined), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect( + logger.events.some( + (event) => + event.type === "durable.record-unverifiable" && + event.pid === undefined, + ), + ).toBe(true); + expect(registry.writes.at(-1)).toEqual([]); + }); +}); + +describe("durable seeding across multiple activations in one process", () => { + test("a second activation against the same registry and state map does not duplicate the adopted record", async () => { + const registry = new RoundTrippingRegistry(); + const state = createStartupState(); + const identity = aliveIdentity(); + let spawnCalls = 0; + const spawn: SpawnFunction = () => { + spawnCalls += 1; + return new FakeChild(5001); + }; + const command = globalCommand("helper", [], "Helper", 0, DURABLE_SKIP); + + const activation1 = await runStartupCommands( + [command], + { + spawn, + state, + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation1.dispose(); + + const activation2 = await runStartupCommands( + [command], + { + spawn, + state, + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation2.dispose(); + + expect(spawnCalls).toBe(1); + + const finalRecords = registry.writes.at(-1) ?? []; + expect(finalRecords).toHaveLength(1); + expect(finalRecords[0]?.pid).toBe(5001); + expect(finalRecords[0]?.creationOrder).toBe(0); + + const entry = state.get(`global:${JSON.stringify(["helper", []])}`); + expect(entry?.records).toHaveLength(1); + expect(entry?.nextCreationOrder).toBe(1); + }); + + test("four activations stay flat rather than growing 2^n", async () => { + const registry = new RoundTrippingRegistry(); + const state = createStartupState(); + const identity = aliveIdentity(); + let spawnCalls = 0; + const spawn: SpawnFunction = () => { + spawnCalls += 1; + return new FakeChild(5001); + }; + const command = globalCommand("helper", [], "Helper", 0, DURABLE_SKIP); + + for (let i = 0; i < 4; i += 1) { + const activation = await runStartupCommands( + [command], + { + spawn, + state, + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + } + + expect(spawnCalls).toBe(1); + const finalRecords = registry.writes.at(-1) ?? []; + expect(finalRecords).toHaveLength(1); + expect(finalRecords[0]?.creationOrder).toBe(0); + }); +}); + +describe("an already-tracked record survives a failed re-describe", () => { + test("the file record fills the gap when the state walk's re-describe fails", async () => { + const helperProcessKey = `global:${JSON.stringify(["helper", []])}`; + const state = createStartupState(); + const trackedPid = process.pid + 1; + state.set(helperProcessKey, { + processKey: helperProcessKey, + records: [ + { + origin: "spawned", + child: new FakeChild(trackedPid), + pid: trackedPid, + context: { scope: "global", index: 0, name: "Helper" }, + creationOrder: 0, + owners: new Set(), + stopOnExit: false, + status: "active", + rootExited: false, + }, + ], + cleanupUnconfirmed: false, + status: "stable", + transitionTail: Promise.resolve(), + pendingTransitions: 0, + nextCreationOrder: 1, + }); + + const registry = new FakeRegistry({ + status: "loaded", + records: [ + persistedRecord({ pid: trackedPid, startToken: "linux1:boot-a:11" }), + ], + }); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state, + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: { + async describe(pid) { + if (pid === trackedPid) { + return { status: "unknown", reason: "permission-denied" }; + } + return { status: "described", token: "linux1:owner:1" }; + }, + async probe() { + return { status: "alive" }; + }, + }, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + const written = registry.writes.at(-1) ?? []; + expect(written.some((record) => record.pid === trackedPid)).toBe(true); + }); +}); + +describe("restart + stopOnExit:false on the durable path", () => { + test("a successful restart drops the stopped pid's record instead of writing it alongside the replacement", async () => { + const registry = new RoundTrippingRegistry(); + const state = createStartupState(); + let spawnCount = 0; + const spawn: SpawnFunction = () => { + spawnCount += 1; + return new FakeChild(spawnCount === 1 ? 7001 : 7002); + }; + const command = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "restart", + stopOnExit: false, + }); + + const activation1 = await runStartupCommands( + [command], + { + spawn, + state, + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation1.dispose(); + expect(registry.writes.at(-1)).toHaveLength(1); + expect(registry.writes.at(-1)?.[0]?.pid).toBe(7001); + + const activation2 = await runStartupCommands( + [command], + { + spawn, + state, + processTree: new FakeProcessTree([{ status: "stopped" }]), + logger: createLogger(), + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation2.dispose(); + + expect(spawnCount).toBe(2); + + const written = registry.writes.at(-1) ?? []; + expect(written).toHaveLength(1); + expect(written[0]?.pid).toBe(7002); + + const entry = state.get(`global:${JSON.stringify(["helper", []])}`); + expect(entry?.status).not.toBe("degraded"); + expect(entry?.cleanupUnconfirmed).toBe(false); + }); +}); + +describe("epilogue dedupe and creationOrder ordering, discriminated separately", () => { + test("an unknown probe on an already-tracked record does not grow the file by one record per activation", async () => { + const registry = new RoundTrippingRegistry(); + const state = createStartupState(); + let spawnCalls = 0; + const spawn: SpawnFunction = () => { + spawnCalls += 1; + return new FakeChild(5002); + }; + const command = globalCommand("helper", [], "Helper", 0, DURABLE_SKIP); + + const activation1 = await runStartupCommands( + [command], + { + spawn, + state, + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation1.dispose(); + expect(registry.writes.at(-1)).toHaveLength(1); + + const activation2 = await runStartupCommands( + [command], + { + spawn, + state, + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:boot-a:11" }; + }, + async probe() { + return { status: "unknown", reason: "permission-denied" }; + }, + }, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation2.dispose(); + + expect(spawnCalls).toBe(1); + expect(registry.writes.at(-1)).toHaveLength(1); + }); + + test("records for one identity are written in ascending creationOrder even when assembled descending", async () => { + const helperProcessKey = `global:${JSON.stringify(["helper", []])}`; + const state = createStartupState(); + state.set(helperProcessKey, { + processKey: helperProcessKey, + records: [ + { + origin: "adopted", + pid: 9002, + startToken: "linux1:boot-a:22", + context: { scope: "global", index: 0, name: "Helper" }, + creationOrder: 2, + owners: new Set(), + stopOnExit: false, + status: "active", + rootExited: false, + }, + ], + cleanupUnconfirmed: false, + status: "stable", + transitionTail: Promise.resolve(), + pendingTransitions: 0, + nextCreationOrder: 3, + }); + + const registry = new FakeRegistry({ + status: "loaded", + records: [ + persistedRecord({ + pid: 9001, + creationOrder: 5, + startToken: "linux1:boot-a:11", + }), + ], + }); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state, + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:boot-a:11" }; + }, + async probe() { + return { status: "unknown", reason: "permission-denied" }; + }, + }, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + const written = registry.writes.at(-1) ?? []; + expect(written).toHaveLength(2); + expect(written.map((record) => record.creationOrder)).toEqual([2, 5]); + }); +}); + +describe("a project command's durable scope uses options.projectRootHash", () => { + test("a supplied hash unrelated to command.projectRoot still round-trips: adopted, not respawned", async () => { + const registry = new RoundTrippingRegistry(); + const state = createStartupState(); + const identity = aliveIdentity(); + let spawnCalls = 0; + const spawn: SpawnFunction = () => { + spawnCalls += 1; + return new FakeChild(6001); + }; + const suppliedProjectRootHash = "caller-supplied-hash-unrelated-to-path"; + const command = projectCommand( + "/some/worktree/project", + "helper", + [], + "Helper", + 0, + DURABLE_SKIP, + ); + + const activation1 = await runStartupCommands( + [command], + { + spawn, + state, + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity, + }, + { authoritative: true, projectRootHash: suppliedProjectRootHash }, + ); + await activation1.dispose(); + + const firstWrite = registry.writes.at(-1) ?? []; + expect(firstWrite).toHaveLength(1); + const persisted = firstWrite[0]; + expect(persisted?.scope).toBe("project"); + if (persisted?.scope === "project") { + expect(persisted.projectRootHash).toBe(suppliedProjectRootHash); + } + + const activation2 = await runStartupCommands( + [command], + { + spawn, + state, + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity, + }, + { authoritative: true, projectRootHash: suppliedProjectRootHash }, + ); + await activation2.dispose(); + + expect(spawnCalls).toBe(1); + expect(registry.writes.at(-1)).toHaveLength(1); + }); + + test("a project command is excluded from the durable plan when options itself is absent, rather than guessing a hash", async () => { + const registry = new FakeRegistry(); + const command = projectCommand( + "/some/worktree/project", + "helper", + [], + "Helper", + 0, + DURABLE_SKIP, + ); + + const activation = await runStartupCommands([command], { + spawn: () => new FakeChild(6002), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: aliveIdentity(), + }); + await activation.dispose(); + + expect(registry.writes.at(-1)).toEqual([]); + }); + + test("a project durable command with no scope info still blocks when the plan is disabled for an unrelated reason", async () => { + const registry = new FakeRegistry({ status: "loaded", records: [] }, false); + const logger = createLogger(); + const command = projectCommand( + "/some/worktree/project", + "helper", + [], + "Helper", + 0, + DURABLE_SKIP, + ); + let spawnCalls = 0; + + const activation = await runStartupCommands([command], { + spawn: () => { + spawnCalls += 1; + return new FakeChild(9999); + }, + state: createStartupState(), + processTree: new FakeProcessTree(), + logger, + registry, + identity: aliveIdentity(), + }); + await activation.dispose(); + + expect(spawnCalls).toBe(0); + expect( + logger.events.some( + (event) => + event.type === "command.skipped" && + event.reason === "durable-unavailable", + ), + ).toBe(true); + }); +}); + +describe("orphaned records (no matching command)", () => { + test("gone: dropped from the rewritten registry, and nothing is stopped", async () => { + const registry = new FakeRegistry({ + status: "loaded", + records: [orphanRecord()], + }); + const processTree = new FakeProcessTree(); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree, + logger: createLogger(), + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:e2e:1" }; + }, + async probe() { + return { status: "gone" }; + }, + }, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(processTree.calls).toHaveLength(0); + const written = registry.writes.at(-1) ?? []; + expect(written.some((record) => record.pid === 7777)).toBe(false); + }); + + test("alive: preserved verbatim in the rewritten registry, and nothing is stopped", async () => { + const orphan = orphanRecord(); + const registry = new FakeRegistry({ + status: "loaded", + records: [orphan], + }); + const processTree = new FakeProcessTree(); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree, + logger: createLogger(), + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:e2e:1" }; + }, + async probe() { + return { status: "alive" }; + }, + }, + }, + { authoritative: false, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(processTree.calls).toHaveLength(0); + expect(registry.writes.at(-1)).toContainEqual(orphan); + }); + + test("unknown: preserved verbatim in the rewritten registry, and nothing is stopped", async () => { + const orphan = orphanRecord(); + const registry = new FakeRegistry({ + status: "loaded", + records: [orphan], + }); + const processTree = new FakeProcessTree(); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree, + logger: createLogger(), + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:e2e:1" }; + }, + async probe() { + return { status: "unknown", reason: "permission-denied" }; + }, + }, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(processTree.calls).toHaveLength(0); + expect(registry.writes.at(-1)).toContainEqual(orphan); + }); +}); + +describe("the durable transaction is skipped when nothing needs it", () => { + const DIRECT_SKIP = { + onExistingProcess: "skip", + stopOnExit: true, + } as const; + + function temporaryStateRoot(): { parent: string; root: string } { + const parent = mkdtempSync(join(tmpdir(), "durable-fast-path-")); + return { parent, root: join(parent, "state") }; + } + + test("no durable command and no registry file: no owner resolution and no state directory", async () => { + const { parent, root } = temporaryStateRoot(); + let resolveOwnerCalls = 0; + let describeCalls = 0; + + try { + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DIRECT_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger: createLogger(), + registry: createDurableRegistry({ root }), + identity: { + async describe() { + describeCalls += 1; + return { status: "described", token: "linux1:boot-a:11" }; + }, + async probe() { + return { status: "alive" }; + }, + }, + }, + { + authoritative: true, + projectRootHash: "unused-project-root-hash", + resolveOwner: async () => { + resolveOwnerCalls += 1; + return { pid: 4242, startToken: "linux1:boot-a:11" }; + }, + }, + ); + await activation.dispose(); + + expect(resolveOwnerCalls).toBe(0); + expect(describeCalls).toBe(0); + expect(existsSync(root)).toBe(false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("no durable command but a registry file exists: the transaction still reconciles", async () => { + const { parent, root } = temporaryStateRoot(); + + try { + const registry = createDurableRegistry({ root }); + const stale: DurableRecord = { + scope: "global", + identityHash: registry.hashIdentity("retired-helper"), + pid: 7777, + startToken: "linux1:boot-a:99", + stopOnExit: false, + creationOrder: 0, + recordedAt: "2026-09-03T00:00:00.000Z", + }; + expect(registry.write([stale])).toBe(true); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DIRECT_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:boot-a:11" }; + }, + async probe() { + return { status: "gone" }; + }, + }, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(registry.read()).toEqual({ status: "loaded", records: [] }); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("one durable command and no registry file: the transaction runs and writes one", async () => { + const { parent, root } = temporaryStateRoot(); + let resolveOwnerCalls = 0; + + try { + const registry = createDurableRegistry({ root }); + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: aliveIdentity(), + }, + { + authoritative: true, + projectRootHash: "unused-project-root-hash", + resolveOwner: async () => { + resolveOwnerCalls += 1; + return { pid: 4242, startToken: "linux1:boot-a:11" }; + }, + }, + ); + await activation.dispose(); + + expect(resolveOwnerCalls).toBe(1); + expect(existsSync(join(root, "registry.json"))).toBe(true); + const loaded = registry.read(); + expect(loaded.status === "loaded" && loaded.records).toHaveLength(1); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("the owner thunk supplies the lock holder instead of this process's own identity", async () => { + const acquired: Array<{ pid: number; startToken: string }> = []; + const registry: DurableRegistry = { + hashIdentity: (signature) => `identity-${signature}`, + hashProjectRoot: (normalizedRoot) => `root-${normalizedRoot}`, + exists: () => false, + read: () => ({ status: "loaded", records: [] }), + write: () => true, + async acquireLock(holder) { + acquired.push(holder); + return { status: "unavailable" }; + }, + }; + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: { + describe() { + throw new Error("the owner thunk already supplied an identity"); + }, + async probe() { + return { status: "alive" }; + }, + }, + }, + { + authoritative: true, + projectRootHash: "unused-project-root-hash", + resolveOwner: async () => ({ + pid: 4242, + startToken: "linux1:boot-a:11", + }), + }, + ); + await activation.dispose(); + + expect(acquired).toEqual([{ pid: 4242, startToken: "linux1:boot-a:11" }]); + }); +}); + +describe("durable registry privacy end-to-end", () => { + test("a real activation persists only hashes, never the executable, args, or project root", async () => { + const stateRoot = mkdtempSync(join(tmpdir(), "durable-registry-e2e-")); + + try { + const registry = createDurableRegistry({ root: stateRoot }); + const rawExecutable = "/usr/bin/very-secret-watcher"; + const rawArgs = ["--token", "TOP_SECRET_ARGUMENT_VALUE"]; + const rawProjectRoot = join(stateRoot, "workspace", "secret-project-name"); + const command = projectCommand( + rawProjectRoot, + rawExecutable, + rawArgs, + "Secret helper", + 0, + { onExistingProcess: "skip", stopOnExit: false }, + ); + + const normalizedRoot = + process.platform === "win32" + ? normalize(resolve(rawProjectRoot)).toLowerCase() + : normalize(resolve(rawProjectRoot)); + const expectedIdentityHash = registry.hashIdentity( + JSON.stringify([rawExecutable, rawArgs]), + ); + const expectedProjectRootHash = registry.hashProjectRoot(normalizedRoot); + + const activation = await runStartupCommands( + [command], + { + spawn: () => new FakeChild(4321), + state: createStartupState(), + processTree: new FakeProcessTree(), + logger: createLogger(), + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:e2e-boot:1" }; + }, + async probe() { + return { status: "alive" }; + }, + }, + }, + { authoritative: true, projectRootHash: expectedProjectRootHash }, + ); + await activation.dispose(); + + const bytes = readFileSync(join(stateRoot, "registry.json"), "utf8"); + + expect(bytes).toContain(expectedIdentityHash); + expect(bytes).toContain(expectedProjectRootHash); + expect(bytes).not.toContain(rawExecutable); + expect(bytes).not.toContain("TOP_SECRET_ARGUMENT_VALUE"); + expect(bytes).not.toContain("secret-project-name"); + expect(bytes).not.toContain(rawProjectRoot); + } finally { + rmSync(stateRoot, { recursive: true, force: true }); + } + }); +}); + +describe("reconciliation and the authoritativeness gate", () => { + test("an orphan (command removed) probing alive is stopped and dropped when authoritative", async () => { + const orphan = orphanRecord(); + const registry = new FakeRegistry({ + status: "loaded", + records: [orphan], + }); + const processTree = new FakeProcessTree([{ status: "stopped" }]); + const logger = createLogger(); + + const activation = await runStartupCommands( + [], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree, + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(processTree.calls).toHaveLength(1); + expect(processTree.calls[0]?.pid).toBe(7777); + expect(registry.writes.at(-1)).toEqual([]); + expect( + logger.events.some( + (event) => + event.type === "durable.reconciled" && + event.scope === "global" && + event.stoppedCount === 1, + ), + ).toBe(true); + }); + + test("an orphan whose stop loses addressability is preserved, not dropped", async () => { + const orphan = orphanRecord(); + const registry = new FakeRegistry({ + status: "loaded", + records: [orphan], + }); + const processTree = new FakeProcessTree([ + { status: "failed", reason: "unconfirmed", addressability: "lost" }, + ]); + const logger = createLogger(); + + const activation = await runStartupCommands( + [], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree, + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(processTree.calls).toHaveLength(1); + expect(registry.writes.at(-1)).toEqual([orphan]); + expect( + logger.events.some((event) => event.type === "durable.reconciled"), + ).toBe(false); + }); + + test("an orphan whose stop fails while still addressable is preserved", async () => { + const orphan = orphanRecord(); + const registry = new FakeRegistry({ + status: "loaded", + records: [orphan], + }); + const processTree = new FakeProcessTree([ + { status: "failed", reason: "permission-denied", addressability: "safe" }, + ]); + + const activation = await runStartupCommands( + [], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree, + logger: createLogger(), + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(processTree.calls).toHaveLength(1); + expect(registry.writes.at(-1)).toEqual([orphan]); + }); + + test("the same orphan is preserved and never stopped when not authoritative", async () => { + const orphan = orphanRecord(); + const registry = new FakeRegistry({ + status: "loaded", + records: [orphan], + }); + const processTree = new FakeProcessTree(); + const logger = createLogger(); + + const activation = await runStartupCommands( + [], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree, + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: false, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(processTree.calls).toHaveLength(0); + expect(registry.writes.at(-1)).toEqual([orphan]); + expect( + logger.events.some((event) => event.type === "durable.reconciled"), + ).toBe(false); + }); + + test("a matched record is still adopted when not authoritative", async () => { + const registry = new FakeRegistry({ + status: "loaded", + records: [persistedRecord()], + }); + const processTree = new FakeProcessTree(); + const logger = createLogger(); + let spawnCalls = 0; + + const activation = await runStartupCommands( + [globalCommand("helper", [], "Helper", 0, DURABLE_SKIP)], + { + spawn: () => { + spawnCalls += 1; + return new FakeChild(9999); + }, + state: createStartupState(), + processTree, + logger, + registry, + identity: aliveIdentity(), + }, + { authoritative: false, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(spawnCalls).toBe(0); + expect(processTree.calls).toHaveLength(0); + expect( + logger.events.some((event) => event.type === "durable.record-adopted"), + ).toBe(true); + expect(registry.writes.at(-1)).toHaveLength(1); + }); + + test("stopOnExit flipped to true stops the old durable process and spawns fresh", async () => { + const registry = new FakeRegistry({ + status: "loaded", + records: [persistedRecord()], + }); + const processTree = new FakeProcessTree([ + { status: "stopped" }, + { status: "stopped" }, + ]); + const spawnedPids: Array = []; + + const activation = await runStartupCommands( + [ + globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "skip", + stopOnExit: true, + }), + ], + { + spawn: () => { + const child = new FakeChild(9999); + spawnedPids.push(child.pid); + return child; + }, + state: createStartupState(), + processTree, + logger: createLogger(), + registry, + identity: aliveIdentity(), + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + + expect(processTree.calls[0]?.pid).toBe(4242); + expect(spawnedPids).toEqual([9999]); + expect(registry.writes.at(-1)).toEqual([]); + + await activation.dispose(); + expect(processTree.calls.map((call) => call.pid)).toEqual([4242, 9999]); + }); + + test("an orphan probing unknown is never stopped, matched or not", async () => { + const orphan = orphanRecord(); + const registry = new FakeRegistry({ + status: "loaded", + records: [orphan], + }); + const processTree = new FakeProcessTree(); + + const activation = await runStartupCommands( + [], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree, + logger: createLogger(), + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:e2e:1" }; + }, + async probe() { + return { status: "unknown", reason: "permission-denied" }; + }, + }, + }, + { authoritative: true, projectRootHash: "unused-project-root-hash" }, + ); + await activation.dispose(); + + expect(processTree.calls).toHaveLength(0); + expect(registry.writes.at(-1)).toEqual([orphan]); + }); + + test("a foreign project's orphan is never probed and never stopped", async () => { + const foreign: DurableRecord = { + scope: "project", + identityHash: `identity-${JSON.stringify(["orphan-tool", []])}`, + projectRootHash: "someone-elses-root-hash", + pid: 1111, + startToken: "linux1:boot-a:1", + stopOnExit: false, + creationOrder: 0, + recordedAt: "2026-09-03T01:00:00.000Z", + }; + const registry = new FakeRegistry({ + status: "loaded", + records: [foreign], + }); + const processTree = new FakeProcessTree(); + let probeCalls = 0; + + const activation = await runStartupCommands( + [], + { + spawn: () => new FakeChild(9999), + state: createStartupState(), + processTree, + logger: createLogger(), + registry, + identity: { + async describe() { + return { status: "described", token: "linux1:e2e:1" }; + }, + async probe() { + probeCalls += 1; + return { status: "alive" }; + }, + }, + }, + { authoritative: true, projectRootHash: "our-project-root-hash" }, + ); + await activation.dispose(); + + expect(probeCalls).toBe(0); + expect(processTree.calls).toHaveLength(0); + expect(registry.writes.at(-1)).toEqual([foreign]); + }); +}); diff --git a/tests/core.test.ts b/tests/core.test.ts index 2f6b739..5bdfbb2 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; -import type { ConfiguredCommand } from "../src/config.js"; import { createStartupState, getOrCreateProcessState, @@ -15,250 +14,18 @@ import type { ProcessTreeStopOptions, ProcessTreeStopResult, } from "../src/process-tree.js"; - -class FakeChild implements SpawnedChild { - public readonly onceCalls: Array<"error" | "exit"> = []; - public unrefCalls = 0; - private readonly errorListeners: Array<(error: Error) => void> = []; - private readonly exitListeners: Array< - (code: number | null, signal: NodeJS.Signals | null) => void - > = []; - - public constructor( - public readonly pid?: number, - private readonly options: { - throwOnOnce?: boolean; - throwOnUnref?: boolean; - } = {}, - ) {} - - public once(event: "error", listener: (error: Error) => void): this; - public once( - event: "exit", - listener: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void, - ): this; - public once( - event: "error" | "exit", - listener: - | ((error: Error) => void) - | ((code: number | null, signal: NodeJS.Signals | null) => void), - ): this { - this.onceCalls.push(event); - - if (this.options.throwOnOnce) { - throw new Error("Test child once failure"); - } - - if (event === "error") { - this.errorListeners.push(listener as (error: Error) => void); - } else { - this.exitListeners.push( - listener as ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void, - ); - } - - return this; - } - - public unref(): void { - this.unrefCalls += 1; - - if (this.options.throwOnUnref) { - throw new Error("Test child unref failure"); - } - } - - public emitError(error: Error): void { - const listeners = this.errorListeners.splice(0); - - for (const listener of listeners) { - listener(error); - } - } - - public emitExit( - code: number | null, - signal: NodeJS.Signals | null, - ): void { - const listeners = this.exitListeners.splice(0); - - for (const listener of listeners) { - listener(code, signal); - } - } -} - -class FakeProcessTree implements ProcessTreeController { - public readonly calls: Array<{ - pid: number | undefined; - options: ProcessTreeStopOptions; - }> = []; - - public constructor( - private readonly results: ProcessTreeStopResult[] = [ - { status: "stopped" }, - ], - ) {} - - public async stop( - pid: number | undefined, - options: ProcessTreeStopOptions, - ): Promise { - this.calls.push({ pid, options }); - return this.results.shift() ?? { status: "stopped" }; - } -} - -function deferred(): { - promise: Promise; - resolve: (value: T) => void; - reject: (reason?: unknown) => void; -} { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((done, fail) => { - resolve = done; - reject = fail; - }); - return { promise, resolve, reject }; -} - -class DeferredProcessTree implements ProcessTreeController { - public readonly calls: Array<{ - pid: number | undefined; - options: ProcessTreeStopOptions; - }> = []; - public readonly stops = new Map< - number | undefined, - ReturnType> - >(); - - public stop( - pid: number | undefined, - options: ProcessTreeStopOptions, - ): Promise { - this.calls.push({ pid, options }); - const pending = deferred(); - this.stops.set(pid, pending); - return pending.promise; - } -} - -type CommandPolicies = Pick< - ConfiguredCommand, - "onExistingProcess" | "stopOnExit" ->; - -const DEFAULT_POLICIES: CommandPolicies = { - onExistingProcess: "skip", - stopOnExit: true, -}; - -function globalCommand( - executable: string, - args: string[] = [], - name = "Global command", - index = 0, - policies: CommandPolicies = DEFAULT_POLICIES, -): ConfiguredCommand { - return { name, executable, args, ...policies, scope: "global", index }; -} - -function projectCommand( - projectRoot: string, - executable: string, - args: string[] = [], - name = "Project command", - index = 0, - policies: CommandPolicies = DEFAULT_POLICIES, -): ConfiguredCommand { - return { - name, - executable, - args, - ...policies, - scope: "project", - projectRoot, - index, - }; -} - -function createLogger(): Logger & { events: LogEvent[] } { - const events: LogEvent[] = []; - - return { - events, - write(event): void { - events.push(event); - }, - }; -} - -function createSpawn( - children: SpawnedChild[], - calls: unknown[][], -): SpawnFunction { - let childIndex = 0; - - return (executable, args, options) => { - calls.push([executable, args, options]); - const child = children[childIndex]; - childIndex += 1; - - if (!child) { - throw new Error("Test did not provide a fake child"); - } - - return child; - }; -} - -function createDependencies( - children: SpawnedChild[] = [], - processTree: ProcessTreeController = new FakeProcessTree(), -): { - calls: unknown[][]; - logger: Logger & { events: LogEvent[] }; - processTree: ProcessTreeController; - state: StartupState; - spawn: SpawnFunction; -} { - const calls: unknown[][] = []; - - return { - calls, - logger: createLogger(), - processTree, - state: createStartupState(), - spawn: createSpawn(children, calls), - }; -} - -function createGatedIdentityState( - processKey: string, - transitionTail: Promise, -): StartupState { - return new Map([ - [ - processKey, - { - processKey, - records: [], - cleanupUnconfirmed: false, - status: "stable", - transitionTail, - pendingTransitions: 0, - nextCreationOrder: 0, - }, - ], - ]) as StartupState; -} +import { + createDependencies, + createGatedIdentityState, + createLogger, + createSpawn, + deferred, + DeferredProcessTree, + FakeChild, + FakeProcessTree, + globalCommand, + projectCommand, +} from "./helpers/core-fixtures.js"; describe("getOrCreateProcessState", () => { const stateKey = Symbol.for("opencode.startup-commands.state"); diff --git a/tests/durable-integration.test.ts b/tests/durable-integration.test.ts new file mode 100644 index 0000000..3f32910 --- /dev/null +++ b/tests/durable-integration.test.ts @@ -0,0 +1,544 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { spawn as spawnChildProcess, type ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ConfiguredCommand, OnExistingProcessPolicy } from "../src/config.js"; +import { + createStartupState, + runStartupCommands, + type SpawnFunction, +} from "../src/core.js"; +import { + createDurableRegistry, + type DurableRecord, + type DurableRegistry, +} from "../src/durable-registry.js"; +import type { LogEvent, Logger } from "../src/logger.js"; +import { + createProcessIdentityController, + type ProcessDescribeResult, +} from "../src/process-identity.js"; +import { createProcessTreeController } from "../src/process-tree.js"; + +const fixturePath = fileURLToPath( + new URL("./fixtures/durable-child.mjs", import.meta.url), +); + +const identity = createProcessIdentityController(); +const processTree = createProcessTreeController({ + gracePeriodMs: 100, + pollIntervalMs: 10, +}); + +function isValidFixturePid(pid: unknown): pid is number { + return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0; +} + +function getErrorCodeForTest(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !(getErrorCodeForTest(error) === "ESRCH"); + } +} + +function delayForTest(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function waitForProcessesAbsent( + pids: number[], + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (pids.every((pid) => !processExists(pid))) { + return true; + } + await delayForTest(10); + } + + return pids.every((pid) => !processExists(pid)); +} + +async function settleWithin( + promise: Promise, + milliseconds: number, +): Promise { + let timer: ReturnType | undefined; + + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error("Controlled operation did not settle")), + milliseconds, + ); + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +const everySpawnedPid = new Set(); + +function trackPid(child: ChildProcess): void { + if (isValidFixturePid(child.pid)) { + everySpawnedPid.add(child.pid); + } +} + +async function terminateFixture(child: ChildProcess): Promise { + const pid = child.pid; + if (!isValidFixturePid(pid) || !processExists(pid)) { + return; + } + + const exited = new Promise((resolve) => { + child.once("exit", () => resolve()); + }); + + try { + child.stdin?.end(); + } catch {} + try { + child.kill("SIGTERM"); + } catch {} + + await settleWithin(exited, 5_000).catch(() => {}); + + if (processExists(pid)) { + try { + child.kill("SIGKILL"); + } catch {} + await waitForProcessesAbsent([pid], 3_000); + } +} + +async function terminateAll(children: readonly ChildProcess[]): Promise { + await Promise.all(children.map((child) => terminateFixture(child))); +} + +afterAll(async () => { + const pids = [...everySpawnedPid]; + await waitForProcessesAbsent(pids, 2_000); + const survivors = pids.filter(processExists); + expect(survivors).toEqual([]); +}); + +function spawnDirectFixture(): ChildProcess { + const child = spawnChildProcess( + process.execPath, + [fixturePath, "reap-on-stdin-close"], + { + detached: true, + shell: false, + stdio: ["pipe", "ignore", "ignore"], + windowsHide: true, + }, + ); + child.unref(); + trackPid(child); + return child; +} + +async function waitForDescribed( + pid: number, + timeoutMs: number, +): Promise<{ status: "described"; token: string }> { + const deadline = Date.now() + timeoutMs; + let last: ProcessDescribeResult = { status: "gone" }; + + while (Date.now() < deadline) { + last = await identity.describe(pid); + if (last.status === "described") { + return last; + } + await delayForTest(20); + } + + throw new Error( + `pid ${pid} never became describable within ${timeoutMs}ms: ${JSON.stringify(last)}`, + ); +} + +function expectedTokenPattern(): RegExp { + if (process.platform === "win32") { + return /^win1:\d+$/; + } + if (process.platform === "darwin") { + return /^mac1:.+$/; + } + return /^linux1:[0-9a-f-]{4,64}:\d+$/; +} + +describe("process identity against a real OS process", () => { + test( + "describe() and probe() agree a freshly spawned real child is alive", + async () => { + const child = spawnDirectFixture(); + + try { + const pid = child.pid; + if (!isValidFixturePid(pid)) { + throw new Error("Fixture child did not receive a PID"); + } + + const described = await waitForDescribed(pid, 8_000); + expect(described.token).toMatch(expectedTokenPattern()); + + await expect(identity.probe(pid, described.token)).resolves.toEqual({ + status: "alive", + }); + } finally { + await terminateFixture(child); + } + }, + 15_000, + ); + + test( + "probe() reports gone once the child has actually exited", + async () => { + const child = spawnDirectFixture(); + + try { + const pid = child.pid; + if (!isValidFixturePid(pid)) { + throw new Error("Fixture child did not receive a PID"); + } + + const described = await waitForDescribed(pid, 8_000); + await terminateFixture(child); + expect(processExists(pid)).toBe(false); + + await expect(identity.probe(pid, described.token)).resolves.toEqual({ + status: "gone", + }); + } finally { + await terminateFixture(child); + } + }, + 15_000, + ); + + test( + "a dead process's (pid, startToken) pair is never mistaken for alive again -- the actual property recycled-PID rejection rests on", + async () => { + const child = spawnDirectFixture(); + + try { + const pid = child.pid; + if (!isValidFixturePid(pid)) { + throw new Error("Fixture child did not receive a PID"); + } + + const described = await waitForDescribed(pid, 8_000); + await expect(identity.probe(pid, described.token)).resolves.toEqual({ + status: "alive", + }); + + await terminateFixture(child); + expect(processExists(pid)).toBe(false); + + await expect(identity.probe(pid, described.token)).resolves.toEqual({ + status: "gone", + }); + } finally { + await terminateFixture(child); + } + }, + 15_000, + ); +}); + +function createTestLogger(): Logger & { events: LogEvent[] } { + const events: LogEvent[] = []; + return { + events, + write(event): void { + events.push(event); + }, + }; +} + +function fixtureCommand( + onExistingProcess: OnExistingProcessPolicy, +): ConfiguredCommand { + return { + name: "Durable fixture", + executable: process.execPath, + args: [fixturePath], + onExistingProcess, + stopOnExit: false, + index: 0, + scope: "global", + }; +} + +function createTrackingSpawn(collected: ChildProcess[]): SpawnFunction { + return (executable, args, options) => { + const child = spawnChildProcess(executable, args, options); + collected.push(child); + trackPid(child); + return child; + }; +} + +function temporaryRegistryRoot(): string { + return mkdtempSync(join(tmpdir(), "durable-integration-")); +} + +function loadedRecords(registry: DurableRegistry): DurableRecord[] { + const result = registry.read(); + if (result.status !== "loaded") { + throw new Error(`Expected the registry to load; got: ${result.status}`); + } + return result.records; +} + +const UNUSED_PROJECT_ROOT_HASH = "unused-project-root-hash"; + +describe("durable registry round trip through a real OS process", () => { + test( + "a fresh process adopts a durable child and skip spawns nothing new", + async () => { + const root = temporaryRegistryRoot(); + const spawnedChildren: ChildProcess[] = []; + const logger = createTestLogger(); + const registry = createDurableRegistry({ root, identity }); + const command = fixtureCommand("skip"); + + try { + const firstActivation = await runStartupCommands( + [command], + { + spawn: createTrackingSpawn(spawnedChildren), + state: createStartupState(), + processTree, + logger, + registry, + identity, + }, + { authoritative: true, projectRootHash: UNUSED_PROJECT_ROOT_HASH }, + ); + await firstActivation.dispose(); + + expect(existsSync(join(root, "registry.json"))).toBe(true); + expect(spawnedChildren).toHaveLength(1); + const firstPid = spawnedChildren[0]?.pid; + if (!isValidFixturePid(firstPid)) { + throw new Error("Fixture child did not receive a PID"); + } + + const afterFirstRun = loadedRecords(registry); + expect(afterFirstRun).toHaveLength(1); + expect(afterFirstRun[0]?.pid).toBe(firstPid); + + const secondActivation = await runStartupCommands( + [command], + { + spawn: createTrackingSpawn(spawnedChildren), + state: createStartupState(), + processTree, + logger, + registry, + identity, + }, + { authoritative: true, projectRootHash: UNUSED_PROJECT_ROOT_HASH }, + ); + await secondActivation.dispose(); + + expect(spawnedChildren).toHaveLength(1); + expect(processExists(firstPid)).toBe(true); + + const afterSecondRun = loadedRecords(registry); + expect(afterSecondRun).toHaveLength(1); + expect(afterSecondRun[0]?.pid).toBe(firstPid); + expect( + logger.events.some( + (event) => + event.type === "command.skipped" && + event.reason === "already-started", + ), + ).toBe(true); + } finally { + await terminateAll(spawnedChildren); + rmSync(root, { recursive: true, force: true }); + } + }, + 20_000, + ); + + test( + "restart stops the durable child and registers exactly one replacement", + async () => { + const root = temporaryRegistryRoot(); + const spawnedChildren: ChildProcess[] = []; + const logger = createTestLogger(); + const registry = createDurableRegistry({ root, identity }); + const command = fixtureCommand("restart"); + + try { + const firstActivation = await settleWithin( + runStartupCommands( + [command], + { + spawn: createTrackingSpawn(spawnedChildren), + state: createStartupState(), + processTree, + logger, + registry, + identity, + }, + { authoritative: true, projectRootHash: UNUSED_PROJECT_ROOT_HASH }, + ), + 15_000, + ); + await firstActivation.dispose(); + + expect(spawnedChildren).toHaveLength(1); + const firstPid = spawnedChildren[0]?.pid; + if (!isValidFixturePid(firstPid)) { + throw new Error("Fixture child did not receive a PID"); + } + expect(processExists(firstPid)).toBe(true); + + const secondActivation = await settleWithin( + runStartupCommands( + [command], + { + spawn: createTrackingSpawn(spawnedChildren), + state: createStartupState(), + processTree, + logger, + registry, + identity, + }, + { authoritative: true, projectRootHash: UNUSED_PROJECT_ROOT_HASH }, + ), + 15_000, + ); + await secondActivation.dispose(); + + expect(spawnedChildren).toHaveLength(2); + const secondPid = spawnedChildren[1]?.pid; + if (!isValidFixturePid(secondPid)) { + throw new Error("Replacement fixture child did not receive a PID"); + } + expect(secondPid).not.toBe(firstPid); + + expect(await waitForProcessesAbsent([firstPid], 5_000)).toBe(true); + expect(processExists(secondPid)).toBe(true); + + const afterRestart = loadedRecords(registry); + expect(afterRestart).toHaveLength(1); + expect(afterRestart[0]?.pid).toBe(secondPid); + } finally { + await terminateAll(spawnedChildren); + rmSync(root, { recursive: true, force: true }); + } + }, + 30_000, + ); + + test( + "a mutated start token (simulated reboot) drops the stale record without touching the still-live process, and spawns a fresh one", + async () => { + const root = temporaryRegistryRoot(); + const spawnedChildren: ChildProcess[] = []; + const logger = createTestLogger(); + const registry = createDurableRegistry({ root, identity }); + const command = fixtureCommand("skip"); + + try { + const firstActivation = await runStartupCommands( + [command], + { + spawn: createTrackingSpawn(spawnedChildren), + state: createStartupState(), + processTree, + logger, + registry, + identity, + }, + { authoritative: true, projectRootHash: UNUSED_PROJECT_ROOT_HASH }, + ); + await firstActivation.dispose(); + + expect(spawnedChildren).toHaveLength(1); + const firstPid = spawnedChildren[0]?.pid; + if (!isValidFixturePid(firstPid)) { + throw new Error("Fixture child did not receive a PID"); + } + + const [original] = loadedRecords(registry); + if (!original) { + throw new Error("Expected a persisted durable record after the first run"); + } + expect(original.pid).toBe(firstPid); + + const mutatedToken = `${original.startToken}-mutated-for-test`; + const mutatedRecord: DurableRecord = { + ...original, + startToken: mutatedToken, + }; + expect(registry.write([mutatedRecord])).toBe(true); + + const secondActivation = await runStartupCommands( + [command], + { + spawn: createTrackingSpawn(spawnedChildren), + state: createStartupState(), + processTree, + logger, + registry, + identity, + }, + { authoritative: true, projectRootHash: UNUSED_PROJECT_ROOT_HASH }, + ); + await secondActivation.dispose(); + + expect(spawnedChildren).toHaveLength(2); + const secondPid = spawnedChildren[1]?.pid; + if (!isValidFixturePid(secondPid)) { + throw new Error("Replacement fixture child did not receive a PID"); + } + expect(secondPid).not.toBe(firstPid); + + expect(processExists(firstPid)).toBe(true); + expect( + logger.events.some( + (event) => + event.type === "durable.record-dropped" && event.pid === firstPid, + ), + ).toBe(true); + + const afterReboot = loadedRecords(registry); + expect(afterReboot).toHaveLength(1); + expect(afterReboot[0]?.pid).toBe(secondPid); + expect(afterReboot[0]?.startToken).not.toBe(mutatedToken); + } finally { + await terminateAll(spawnedChildren); + rmSync(root, { recursive: true, force: true }); + } + }, + 20_000, + ); +}); diff --git a/tests/durable-lock.test.ts b/tests/durable-lock.test.ts new file mode 100644 index 0000000..9c96abc --- /dev/null +++ b/tests/durable-lock.test.ts @@ -0,0 +1,758 @@ +import { describe, expect, test } from "bun:test"; +import { + createDurableLock, + type DurableLockHolder, +} from "../src/durable-lock.js"; +import type { + ProcessIdentityController, + ProcessProbeResult, +} from "../src/process-identity.js"; + +interface FakeFileSystem { + directories: Set; + files: Map; + mtimes: Map; + mkdir(path: string): void; + writeFile(path: string, content: string): void; + readFile(path: string): string; + rm(path: string): void; + stat(path: string): { mtimeMs: number }; +} + +function errnoException(code: string): NodeJS.ErrnoException { + const error = new Error(code) as NodeJS.ErrnoException; + error.code = code; + return error; +} + +function createFakeFileSystem(nowMs: () => number = () => 0): FakeFileSystem { + const directories = new Set(); + const files = new Map(); + const mtimes = new Map(); + + return { + directories, + files, + mtimes, + mkdir(path: string): void { + if (directories.has(path)) { + throw errnoException("EEXIST"); + } + directories.add(path); + mtimes.set(path, nowMs()); + }, + writeFile(path: string, content: string): void { + files.set(path, content); + }, + readFile(path: string): string { + const content = files.get(path); + if (content === undefined) { + throw errnoException("ENOENT"); + } + return content; + }, + rm(path: string): void { + directories.delete(path); + mtimes.delete(path); + files.delete(path); + const prefix = `${path}/`; + for (const filePath of [...files.keys()]) { + if (filePath.startsWith(prefix)) { + files.delete(filePath); + } + } + }, + stat(path: string): { mtimeMs: number } { + const mtimeMs = mtimes.get(path); + if (mtimeMs === undefined) { + throw errnoException("ENOENT"); + } + return { mtimeMs }; + }, + }; +} + +const LOCK_DIRECTORY = "/state/registry.lock"; +const LOCK_HOLDER_PATH = `${LOCK_DIRECTORY}/holder.json`; +const RECLAIM_GUARD_DIRECTORY = `${LOCK_DIRECTORY}.reclaim`; + +function recordingIdentity( + probeResult: ProcessProbeResult, +): ProcessIdentityController & { + probeCalls: Array<{ pid: number | undefined; expectedToken: string }>; +} { + const probeCalls: Array<{ pid: number | undefined; expectedToken: string }> = + []; + return { + probeCalls, + async describe() { + return { status: "described", token: "linux1:boot-a:11" }; + }, + async probe(pid, expectedToken) { + probeCalls.push({ pid, expectedToken }); + return probeResult; + }, + }; +} + +function seedHolder( + fs: FakeFileSystem, + fields: { generation: string; pid: number; startToken: string }, +): void { + fs.directories.add(LOCK_DIRECTORY); + fs.files.set(LOCK_HOLDER_PATH, JSON.stringify(fields)); +} + +const CONTENDER: DurableLockHolder = { pid: 9999, startToken: "linux1:boot-a:99" }; + +describe("durable-lock basic acquire/release", () => { + test("acquires a free lock and writes generation, pid, and token", async () => { + const fs = createFakeFileSystem(); + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock({ + pid: 4242, + startToken: "linux1:boot-a:11", + }); + + expect(result.status).toBe("acquired"); + const raw = JSON.parse(fs.files.get(LOCK_HOLDER_PATH)!); + expect(raw.pid).toBe(4242); + expect(raw.startToken).toBe("linux1:boot-a:11"); + expect(raw.generation).toMatch(/^[0-9a-f]+$/); + }); + + test("release removes the lock only while the generation still matches", async () => { + const fs = createFakeFileSystem(); + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const first = await lock.acquireLock({ + pid: 1, + startToken: "linux1:boot-a:1", + }); + if (first.status !== "acquired") { + throw new Error("expected the first acquisition to succeed"); + } + first.handle.release(); + expect(fs.directories.has(LOCK_DIRECTORY)).toBe(false); + + const second = await lock.acquireLock({ + pid: 2, + startToken: "linux1:boot-a:2", + }); + expect(second.status).toBe("acquired"); + + first.handle.release(); + expect(fs.directories.has(LOCK_DIRECTORY)).toBe(true); + expect(fs.files.get(LOCK_HOLDER_PATH)).toContain('"pid":2'); + }); +}); + +describe("two contenders racing one stale lock never both acquire", () => { + test("only one of two racing contenders ends up holding the lock", async () => { + const fs = createFakeFileSystem(); + seedHolder(fs, { + generation: "H-gen", + pid: 1, + startToken: "linux1:boot-a:1", + }); + + let aHasAcquired = false; + let lockA: ReturnType; + + const identityForA: ProcessIdentityController = { + async describe() { + return { status: "described", token: "linux1:boot-a:1" }; + }, + async probe() { + return { status: "gone" }; + }, + }; + let clockA = 0; + lockA = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + identity: identityForA, + random: () => "A-gen", + nowMs: () => clockA, + lockTimeoutMs: 500, + lockPollIntervalMs: 1, + delay: async (ms) => { + clockA += ms; + }, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const identityForB: ProcessIdentityController = { + async describe() { + return { status: "described", token: "linux1:boot-a:1" }; + }, + async probe(pid) { + if (pid === 1 && !aHasAcquired) { + const resultA = await lockA.acquireLock({ + pid: 2, + startToken: "linux1:boot-a:2", + }); + aHasAcquired = resultA.status === "acquired"; + } + return pid === 1 ? { status: "gone" } : { status: "alive" }; + }, + }; + let clockB = 0; + const lockB = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + identity: identityForB, + random: () => "B-gen", + nowMs: () => clockB, + lockTimeoutMs: 500, + lockPollIntervalMs: 1, + delay: async (ms) => { + clockB += ms; + }, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const resultB = await lockB.acquireLock(CONTENDER); + + expect(aHasAcquired).toBe(true); + if (resultB.status === "acquired") { + throw new Error( + "both A and B report acquired -- the double-hold the generation fence prevents", + ); + } + expect(resultB.status).toBe("unavailable"); + const finalRaw = JSON.parse(fs.files.get(LOCK_HOLDER_PATH) ?? "{}"); + expect(finalRaw.generation).toBe("A-gen"); + }); +}); + +describe("a removal is fenced by the generation that was probed", () => { + test("a removal is refused when the holder file no longer shows the generation that was probed", async () => { + const fs = createFakeFileSystem(); + seedHolder(fs, { + generation: "H-gen", + pid: 1, + startToken: "linux1:boot-a:1", + }); + + const identity: ProcessIdentityController = { + async describe() { + return { status: "described", token: "linux1:boot-a:1" }; + }, + async probe(pid) { + if (pid === 1) { + fs.files.set( + LOCK_HOLDER_PATH, + JSON.stringify({ + generation: "intervening-gen", + pid: 2, + startToken: "linux1:boot-a:2", + }), + ); + return { status: "gone" }; + } + return { status: "alive" }; + }, + }; + + let clock = 0; + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + identity, + nowMs: () => clock, + lockTimeoutMs: 30, + lockPollIntervalMs: 1, + delay: async (ms) => { + clock += ms; + }, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result).toEqual({ status: "unavailable" }); + const finalRaw = JSON.parse(fs.files.get(LOCK_HOLDER_PATH)!); + expect(finalRaw.generation).toBe("intervening-gen"); + }); +}); + +describe("cleanup after a failed holder write is guarded by emptiness", () => { + test("cleans up the directory it created when the write fails and nothing else has claimed it", async () => { + const fs = createFakeFileSystem(); + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + mkdir: fs.mkdir, + writeFile: () => { + throw errnoException("ENOSPC"); + }, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result).toEqual({ status: "unavailable" }); + expect(fs.directories.has(LOCK_DIRECTORY)).toBe(false); + }); + + test("leaves the directory alone when a legitimate successor already wrote its own holder file", async () => { + const fs = createFakeFileSystem(); + const writeFile = (path: string, content: string): void => { + if (path === LOCK_HOLDER_PATH) { + fs.files.set( + LOCK_HOLDER_PATH, + JSON.stringify({ + generation: "successor-gen", + pid: 5, + startToken: "linux1:boot-a:5", + }), + ); + throw errnoException("EACCES"); + } + fs.writeFile(path, content); + }; + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + mkdir: fs.mkdir, + writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result).toEqual({ status: "unavailable" }); + expect(fs.directories.has(LOCK_DIRECTORY)).toBe(true); + const raw = JSON.parse(fs.files.get(LOCK_HOLDER_PATH)!); + expect(raw.generation).toBe("successor-gen"); + }); +}); + +describe("a busy reclaim guard blocks reclaim without touching the live lock", () => { + test("skips reclaim for that poll and eventually times out without touching the live lock", async () => { + const fs = createFakeFileSystem(); + seedHolder(fs, { + generation: "H-gen", + pid: 1, + startToken: "linux1:boot-a:1", + }); + fs.directories.add(RECLAIM_GUARD_DIRECTORY); + fs.mtimes.set(RECLAIM_GUARD_DIRECTORY, 1000); + + const identity = recordingIdentity({ status: "gone" }); + let clock = 1000; + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + identity, + nowMs: () => clock, + lockTimeoutMs: 50, + lockPollIntervalMs: 10, + delay: async (ms) => { + clock += ms; + }, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result).toEqual({ status: "unavailable" }); + expect(fs.files.get(LOCK_HOLDER_PATH)).toContain("H-gen"); + expect(fs.directories.has(RECLAIM_GUARD_DIRECTORY)).toBe(true); + expect(fs.mtimes.get(RECLAIM_GUARD_DIRECTORY)).toBe(1000); + expect(identity.probeCalls.length).toBeGreaterThan(0); + }); +}); + +describe("the reclaim guard's own mtime decides staleness", () => { + test("a guard directory with no file inside it and an old mtime is reclaimed, not treated as fresh forever", async () => { + const fs = createFakeFileSystem(); + seedHolder(fs, { + generation: "H-gen", + pid: 1, + startToken: "linux1:boot-a:1", + }); + fs.directories.add(RECLAIM_GUARD_DIRECTORY); + fs.mtimes.set(RECLAIM_GUARD_DIRECTORY, 0); + expect( + [...fs.files.keys()].some((path) => + path.startsWith(`${RECLAIM_GUARD_DIRECTORY}/`), + ), + ).toBe(false); + + let clock = 20_000; + const identity = recordingIdentity({ status: "gone" }); + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + identity, + nowMs: () => clock, + lockTimeoutMs: 200, + lockPollIntervalMs: 10, + delay: async (ms) => { + clock += ms; + }, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result.status).toBe("acquired"); + const raw = JSON.parse(fs.files.get(LOCK_HOLDER_PATH)!); + expect(raw.pid).toBe(CONTENDER.pid); + expect(identity.probeCalls).toContainEqual({ + pid: 1, + expectedToken: "linux1:boot-a:1", + }); + }); + + test("a guard that is not yet stale is left alone", async () => { + const fs = createFakeFileSystem(); + seedHolder(fs, { + generation: "H-gen", + pid: 1, + startToken: "linux1:boot-a:1", + }); + fs.directories.add(RECLAIM_GUARD_DIRECTORY); + fs.mtimes.set(RECLAIM_GUARD_DIRECTORY, 19_900); + + let clock = 20_000; + const identity = recordingIdentity({ status: "gone" }); + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + identity, + nowMs: () => clock, + lockTimeoutMs: 30, + lockPollIntervalMs: 5, + delay: async (ms) => { + clock += ms; + }, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result).toEqual({ status: "unavailable" }); + expect(fs.mtimes.get(RECLAIM_GUARD_DIRECTORY)).toBe(19_900); + }); + + test("the reclaim guard is never stamped -- nothing is ever written inside it", async () => { + let clock = 0; + const fs = createFakeFileSystem(() => clock); + seedHolder(fs, { + generation: "H-gen", + pid: 1, + startToken: "linux1:boot-a:1", + }); + + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + identity: recordingIdentity({ status: "gone" }), + nowMs: () => clock, + lockTimeoutMs: 200, + lockPollIntervalMs: 10, + delay: async (ms) => { + clock += ms; + }, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result.status).toBe("acquired"); + expect( + [...fs.files.keys()].some((path) => + path.startsWith(`${RECLAIM_GUARD_DIRECTORY}/`), + ), + ).toBe(false); + }); +}); + +describe("a missing holder file gets a two-poll grace covering the mkdir-then-write window", () => { + test("a holder file missing on only the first poll is not reclaimed", async () => { + const fs = createFakeFileSystem(); + fs.directories.add(LOCK_DIRECTORY); + + let clock = 0; + let pollCount = 0; + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + nowMs: () => clock, + lockTimeoutMs: 100, + lockPollIntervalMs: 10, + delay: async (ms) => { + pollCount += 1; + clock += ms; + if (pollCount === 1) { + fs.files.set( + LOCK_HOLDER_PATH, + JSON.stringify({ + generation: "late-writer-gen", + pid: 1, + startToken: "linux1:boot-a:1", + }), + ); + } + }, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result).toEqual({ status: "unavailable" }); + expect(fs.files.get(LOCK_HOLDER_PATH)).toContain("late-writer-gen"); + }); + + test("a holder file still missing on the second consecutive poll is reclaimed", async () => { + const fs = createFakeFileSystem(); + fs.directories.add(LOCK_DIRECTORY); + + let clock = 0; + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + nowMs: () => clock, + lockTimeoutMs: 200, + lockPollIntervalMs: 10, + delay: async (ms) => { + clock += ms; + }, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result.status).toBe("acquired"); + const raw = JSON.parse(fs.files.get(LOCK_HOLDER_PATH)!); + expect(raw.pid).toBe(CONTENDER.pid); + }); +}); + +describe("malformed reclaim is fenced by content, not just kind", () => { + test("refuses to remove when a successor's mkdir-then-write gap is observed on the guarded re-check", async () => { + const fs = createFakeFileSystem(); + fs.directories.add(LOCK_DIRECTORY); + fs.files.set(LOCK_HOLDER_PATH, "{ bad json"); + + const mkdir = (path: string): void => { + fs.mkdir(path); + if (path === RECLAIM_GUARD_DIRECTORY) { + fs.rm(LOCK_DIRECTORY); + fs.mkdir(LOCK_DIRECTORY); + } + }; + + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + lockTimeoutMs: 3, + lockPollIntervalMs: 5, + mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result).toEqual({ status: "unavailable" }); + expect(fs.directories.has(LOCK_DIRECTORY)).toBe(true); + expect(fs.files.has(LOCK_HOLDER_PATH)).toBe(false); + }); +}); + +describe("the default wait budget outlasts the stop budget held under the lock", () => { + test("a contender keeps waiting on a live holder past 15 seconds before giving up", async () => { + const fs = createFakeFileSystem(); + seedHolder(fs, { + generation: "H-gen", + pid: 1, + startToken: "linux1:boot-a:1", + }); + + let clock = 0; + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + identity: recordingIdentity({ status: "alive" }), + nowMs: () => clock, + lockPollIntervalMs: 1_000, + delay: async (ms) => { + clock += ms; + }, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result).toEqual({ status: "unavailable" }); + expect(clock).toBeGreaterThanOrEqual(15_000); + expect(clock).toBeLessThan(17_000); + }); +}); + +describe("a throwing probe never escapes reclaim", () => { + test("a rejecting probe leaves the live lock in place and still resolves", async () => { + const fs = createFakeFileSystem(); + seedHolder(fs, { + generation: "H-gen", + pid: 1, + startToken: "linux1:boot-a:1", + }); + + let probeCalls = 0; + const identity: ProcessIdentityController = { + async describe() { + return { status: "described", token: "linux1:boot-a:1" }; + }, + async probe() { + probeCalls += 1; + throw new Error("probe facility unavailable"); + }, + }; + + let clock = 0; + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + identity, + nowMs: () => clock, + lockTimeoutMs: 30, + lockPollIntervalMs: 5, + delay: async (ms) => { + clock += ms; + }, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result).toEqual({ status: "unavailable" }); + expect(probeCalls).toBeGreaterThan(0); + expect(fs.directories.has(LOCK_DIRECTORY)).toBe(true); + expect(fs.files.get(LOCK_HOLDER_PATH)).toContain("H-gen"); + }); +}); + +describe("the missing-holder grace streak resets after a successful reclaim", () => { + test("a fresh incarnation created by someone else right after a reclaim gets its own two-poll grace", async () => { + const fs = createFakeFileSystem(); + fs.directories.add(LOCK_DIRECTORY); + + let mkdirCallsOnLockDirectory = 0; + const mkdir = (path: string): void => { + if (path === LOCK_DIRECTORY) { + mkdirCallsOnLockDirectory += 1; + if (mkdirCallsOnLockDirectory === 3) { + fs.directories.add(LOCK_DIRECTORY); + throw errnoException("EEXIST"); + } + } + fs.mkdir(path); + }; + + let clock = 0; + let pollCount = 0; + const lock = createDurableLock({ + lockDirectory: LOCK_DIRECTORY, + lockHolderPath: LOCK_HOLDER_PATH, + nowMs: () => clock, + lockTimeoutMs: 100, + lockPollIntervalMs: 10, + delay: async (ms) => { + pollCount += 1; + clock += ms; + if (pollCount === 2) { + fs.files.set( + LOCK_HOLDER_PATH, + JSON.stringify({ + generation: "v2-gen", + pid: 2, + startToken: "linux1:boot-a:2", + }), + ); + } + }, + mkdir, + writeFile: fs.writeFile, + readFile: fs.readFile, + rm: fs.rm, + stat: fs.stat, + }); + + const result = await lock.acquireLock(CONTENDER); + + expect(result).toEqual({ status: "unavailable" }); + expect(fs.files.get(LOCK_HOLDER_PATH)).toContain("v2-gen"); + }); +}); diff --git a/tests/durable-registry.test.ts b/tests/durable-registry.test.ts new file mode 100644 index 0000000..910d2ed --- /dev/null +++ b/tests/durable-registry.test.ts @@ -0,0 +1,739 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { + createDurableRegistry, + resolveDurableStateRoot, + type DurableLockHolder, + type DurableRecord, +} from "../src/durable-registry.js"; +import type { + ProcessIdentityController, + ProcessProbeResult, +} from "../src/process-identity.js"; + +const roots: string[] = []; + +function temporaryRoot(): string { + const root = mkdtempSync(join(tmpdir(), "startup-registry-")); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function record( + overrides: Partial> = {}, +): DurableRecord { + return { + scope: "global", + identityHash: "a".repeat(64), + pid: 4242, + startToken: "linux1:boot-a:11", + stopOnExit: false, + creationOrder: 0, + recordedAt: "2026-09-03T01:46:57.997Z", + ...overrides, + }; +} + +describe("state root resolution", () => { + test("windows uses LOCALAPPDATA and ends at the host segment", () => { + expect( + resolveDurableStateRoot({ + platform: "win32", + env: { LOCALAPPDATA: "C:\\Users\\dev\\AppData\\Local" }, + home: "C:\\Users\\dev", + hostname: "build-box", + }), + ).toBe( + "C:\\Users\\dev\\AppData\\Local\\opencode\\startup-commands\\build-box", + ); + }); + + test("linux prefers XDG_STATE_HOME and ends at the host segment", () => { + expect( + resolveDurableStateRoot({ + platform: "linux", + env: { XDG_STATE_HOME: "/home/dev/.state" }, + home: "/home/dev", + hostname: "build-box", + }), + ).toBe("/home/dev/.state/opencode/startup-commands/build-box"); + }); + + test("linux falls back to ~/.local/state for a relative XDG_STATE_HOME", () => { + expect( + resolveDurableStateRoot({ + platform: "linux", + env: { XDG_STATE_HOME: "relative/path" }, + home: "/home/dev", + hostname: "build-box", + }), + ).toBe("/home/dev/.local/state/opencode/startup-commands/build-box"); + }); + + test("macos uses Application Support and ends at the host segment", () => { + expect( + resolveDurableStateRoot({ + platform: "darwin", + env: {}, + home: "/Users/dev", + hostname: "build-box", + }), + ).toBe( + "/Users/dev/Library/Application Support/OpenCode/startup-commands/build-box", + ); + }); + + test("two hosts sharing one home directory resolve to different roots", () => { + const shared = { platform: "linux" as const, env: {}, home: "/home/dev" }; + + expect(resolveDurableStateRoot({ ...shared, hostname: "host-a" })).not.toBe( + resolveDurableStateRoot({ ...shared, hostname: "host-b" }), + ); + }); +}); + +function hostSegment(hostname: string): string { + return basename( + resolveDurableStateRoot({ + platform: "linux", + env: {}, + home: "/home/dev", + hostname, + }), + ); +} + +describe("host segment sanitization", () => { + test("lowercases a mixed-case hostname", () => { + expect(hostSegment("My-Host")).toBe("my-host"); + }); + + test("replaces unsupported characters and trims the trailing separator", () => { + expect(hostSegment("host name!")).toBe("host-name"); + }); + + test("falls back to unknown-host for an empty hostname", () => { + expect(hostSegment("")).toBe("unknown-host"); + }); + + test("never yields a relative path segment", () => { + expect(hostSegment("..")).toBe("unknown-host"); + }); + + test("truncates an over-long hostname to 64 characters", () => { + expect(hostSegment("a".repeat(65))).toBe("a".repeat(64)); + }); + + test("truncation never leaves a trailing separator for the filesystem to strip", () => { + expect(hostSegment(`${"a".repeat(63)}-${"b".repeat(10)}`)).toBe( + "a".repeat(63), + ); + expect(hostSegment(`${"a".repeat(63)}.${"b".repeat(10)}`)).toBe( + "a".repeat(63), + ); + }); +}); + +describe("identity hashing", () => { + test("produces a stable unkeyed sha256 hex digest", () => { + const registry = createDurableRegistry({ root: temporaryRoot() }); + const signature = JSON.stringify(["/usr/bin/node", ["watch.js"]]); + + const first = registry.hashIdentity(signature); + const second = registry.hashIdentity(signature); + + expect(first).toBe(second); + expect(first).toMatch(/^[0-9a-f]{64}$/); + expect(first).not.toContain("node"); + }); + + test("different signatures produce different digests", () => { + const registry = createDurableRegistry({ root: temporaryRoot() }); + + expect(registry.hashIdentity(JSON.stringify(["a", []]))).not.toBe( + registry.hashIdentity(JSON.stringify(["b", []])), + ); + }); + + test("identity and project-root namespaces cannot collide", () => { + const registry = createDurableRegistry({ root: temporaryRoot() }); + + expect(registry.hashIdentity("same")).not.toBe( + registry.hashProjectRoot("same"), + ); + }); +}); + +describe("read and write", () => { + test("a missing file reads as loaded and empty", () => { + const registry = createDurableRegistry({ root: temporaryRoot() }); + + expect(registry.read()).toEqual({ status: "loaded", records: [] }); + }); + + test("written records round-trip", () => { + const registry = createDurableRegistry({ root: temporaryRoot() }); + + expect(registry.write([record()])).toBe(true); + expect(registry.read()).toEqual({ + status: "loaded", + records: [record()], + }); + }); + + test("the written file contains hashes, never the raw values they were derived from", () => { + const root = temporaryRoot(); + const registry = createDurableRegistry({ root }); + + const identityHash = registry.hashIdentity( + JSON.stringify(["/usr/bin/node", ["watch.js"]]), + ); + const projectRootHash = registry.hashProjectRoot("/home/dev/project"); + const projectRecord: DurableRecord = { + scope: "project", + identityHash, + projectRootHash, + pid: 4242, + startToken: "linux1:boot-a:11", + stopOnExit: false, + creationOrder: 0, + recordedAt: "2026-09-03T01:46:57.997Z", + }; + + registry.write([projectRecord]); + const raw = readFileSync(join(root, "registry.json"), "utf8"); + + expect(raw).toContain(identityHash); + expect(raw).toContain(projectRootHash); + expect(raw).not.toContain("node"); + expect(raw).not.toContain("watch.js"); + expect(raw).not.toContain("/home/dev/project"); + expect(JSON.parse(raw).schemaVersion).toBe(1); + }); + + test("write rejects a global-scoped record that also carries a project root hash", () => { + const registry = createDurableRegistry({ root: temporaryRoot() }); + const invalid = { + ...record(), + projectRootHash: "b".repeat(64), + } as DurableRecord; + + expect(registry.write([invalid])).toBe(false); + }); + + test("write rejects a project-scoped record with no project root hash", () => { + const registry = createDurableRegistry({ root: temporaryRoot() }); + const invalid = { ...record(), scope: "project" } as DurableRecord; + + expect(registry.write([invalid])).toBe(false); + }); + + test("a rejected write leaves an existing registry.json byte-identical", () => { + const root = temporaryRoot(); + const registry = createDurableRegistry({ root }); + expect(registry.write([record()])).toBe(true); + const before = readFileSync(join(root, "registry.json"), "utf8"); + + const invalid = { ...record(), scope: "project" } as DurableRecord; + expect(registry.write([invalid])).toBe(false); + + expect(readFileSync(join(root, "registry.json"), "utf8")).toBe(before); + }); + + test("malformed json is set aside and read continues with an empty registry", () => { + const root = temporaryRoot(); + writeFileSync(join(root, "registry.json"), "{ not json", "utf8"); + const registry = createDurableRegistry({ + root, + clock: { nowMs: () => 1234, stat: () => ({ mtimeMs: 0 }) }, + }); + + expect(registry.read()).toEqual({ + status: "loaded", + records: [], + quarantined: true, + }); + expect(existsSync(join(root, "registry.json"))).toBe(false); + expect( + readFileSync(join(root, "registry.json.corrupt-1234"), "utf8"), + ).toBe("{ not json"); + }); + + test("a registry that cannot be set aside is still reported as malformed", () => { + const root = temporaryRoot(); + writeFileSync(join(root, "registry.json"), "{ not json", "utf8"); + const occupiedTarget = join(root, "registry.json.corrupt-1234"); + mkdirSync(occupiedTarget); + writeFileSync(join(occupiedTarget, "occupied"), "", "utf8"); + const registry = createDurableRegistry({ + root, + clock: { nowMs: () => 1234, stat: () => ({ mtimeMs: 0 }) }, + }); + + expect(registry.read()).toEqual({ + status: "unavailable", + reason: "malformed", + }); + expect(readFileSync(join(root, "registry.json"), "utf8")).toBe( + "{ not json", + ); + }); + + test("an unknown schema version is reported and never set aside", () => { + const root = temporaryRoot(); + const original = JSON.stringify({ schemaVersion: 99, records: [] }); + writeFileSync(join(root, "registry.json"), original, "utf8"); + const registry = createDurableRegistry({ root }); + + expect(registry.read()).toEqual({ + status: "unavailable", + reason: "unsupported-schema", + }); + expect(readFileSync(join(root, "registry.json"), "utf8")).toBe(original); + expect( + readdirSync(root).some((entry) => entry.includes(".corrupt-")), + ).toBe(false); + }); + + test("a registry that cannot be read is reported and never set aside", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "registry.json")); + const registry = createDurableRegistry({ root }); + + expect(registry.read()).toEqual({ + status: "unavailable", + reason: "unreadable", + }); + expect(existsSync(join(root, "registry.json"))).toBe(true); + expect( + readdirSync(root).some((entry) => entry.includes(".corrupt-")), + ).toBe(false); + }); + + test("an invalid record quarantines the whole file and reads empty", () => { + const root = temporaryRoot(); + writeFileSync( + join(root, "registry.json"), + JSON.stringify({ + schemaVersion: 1, + records: [{ ...record(), pid: -1 }], + }), + "utf8", + ); + const registry = createDurableRegistry({ root }); + + expect(registry.read()).toEqual({ + status: "loaded", + records: [], + quarantined: true, + }); + }); + + test("a project record without a root hash quarantines the file and reads empty", () => { + const root = temporaryRoot(); + writeFileSync( + join(root, "registry.json"), + JSON.stringify({ + schemaVersion: 1, + records: [{ ...record(), scope: "project" }], + }), + "utf8", + ); + const registry = createDurableRegistry({ root }); + + expect(registry.read()).toEqual({ + status: "loaded", + records: [], + quarantined: true, + }); + }); + + test("a global-scope record carrying a project root hash quarantines the file and reads empty", () => { + const root = temporaryRoot(); + writeFileSync( + join(root, "registry.json"), + JSON.stringify({ + schemaVersion: 1, + records: [{ ...record(), projectRootHash: "b".repeat(64) }], + }), + "utf8", + ); + const registry = createDurableRegistry({ root }); + + expect(registry.read()).toEqual({ + status: "loaded", + records: [], + quarantined: true, + }); + }); + + test("a non-integer pid quarantines the file and reads empty", () => { + const root = temporaryRoot(); + writeFileSync( + join(root, "registry.json"), + JSON.stringify({ + schemaVersion: 1, + records: [{ ...record(), pid: 1.5 }], + }), + "utf8", + ); + const registry = createDurableRegistry({ root }); + + expect(registry.read()).toEqual({ + status: "loaded", + records: [], + quarantined: true, + }); + }); + + test("stopOnExit: true quarantines the file and reads empty", () => { + const root = temporaryRoot(); + writeFileSync( + join(root, "registry.json"), + JSON.stringify({ + schemaVersion: 1, + records: [{ ...record(), stopOnExit: true }], + }), + "utf8", + ); + const registry = createDurableRegistry({ root }); + + expect(registry.read()).toEqual({ + status: "loaded", + records: [], + quarantined: true, + }); + }); + + test("a negative creationOrder quarantines the file and reads empty", () => { + const root = temporaryRoot(); + writeFileSync( + join(root, "registry.json"), + JSON.stringify({ + schemaVersion: 1, + records: [{ ...record(), creationOrder: -1 }], + }), + "utf8", + ); + const registry = createDurableRegistry({ root }); + + expect(registry.read()).toEqual({ + status: "loaded", + records: [], + quarantined: true, + }); + }); + + test("an unknown scope value quarantines the file and reads empty", () => { + const root = temporaryRoot(); + writeFileSync( + join(root, "registry.json"), + JSON.stringify({ + schemaVersion: 1, + records: [{ ...record(), scope: "bogus" }], + }), + "utf8", + ); + const registry = createDurableRegistry({ root }); + + expect(registry.read()).toEqual({ + status: "loaded", + records: [], + quarantined: true, + }); + }); + + test("no temporary file is left behind after a write", () => { + const root = temporaryRoot(); + const registry = createDurableRegistry({ root, random: () => "fixed" }); + + registry.write([record()]); + + expect(() => + readFileSync(join(root, "registry.json.fixed.tmp"), "utf8"), + ).toThrow(); + }); + + test("the state directory and registry file get restrictive POSIX permissions", () => { + if (process.platform === "win32") { + return; + } + + const root = join(temporaryRoot(), "state"); + const registry = createDurableRegistry({ root }); + registry.write([record()]); + + expect(statSync(root).mode & 0o777).toBe(0o700); + expect(statSync(join(root, "registry.json")).mode & 0o777).toBe(0o600); + }); +}); + +function recordingIdentity( + probeResult: ProcessProbeResult, +): ProcessIdentityController & { + probeCalls: Array<{ pid: number | undefined; expectedToken: string }>; +} { + const probeCalls: Array<{ pid: number | undefined; expectedToken: string }> = + []; + return { + probeCalls, + async describe() { + return { status: "described", token: "linux1:boot-a:11" }; + }, + async probe(pid, expectedToken): Promise { + probeCalls.push({ pid, expectedToken }); + return probeResult; + }, + }; +} + +const HOLDER: DurableLockHolder = { + pid: 4242, + startToken: "linux1:boot-a:11", +}; + +describe("locking", () => { + test("a root that exists as a plain file fails fast instead of polling the full timeout", async () => { + const parent = temporaryRoot(); + const rootAsFile = join(parent, "not-a-directory"); + writeFileSync(rootAsFile, "oops", "utf8"); + + const registry = createDurableRegistry({ + root: rootAsFile, + lockTimeoutMs: 5000, + }); + + const start = Date.now(); + const result = await registry.acquireLock(HOLDER); + const elapsedMs = Date.now() - start; + + expect(result).toEqual({ status: "unavailable" }); + expect(elapsedMs).toBeLessThan(1000); + }); + + test("an injected clock keeps nowMs and stat consistent through the registry surface", async () => { + const root = temporaryRoot(); + + const setupRegistry = createDurableRegistry({ + root, + identity: recordingIdentity({ status: "gone" }), + }); + expect((await setupRegistry.acquireLock(HOLDER)).status).toBe("acquired"); + + mkdirSync(join(root, "registry.lock.reclaim")); + + let clock = 3_600_000; + const registry = createDurableRegistry({ + root, + identity: recordingIdentity({ status: "gone" }), + lockTimeoutMs: 200, + lockPollIntervalMs: 10, + delay: async (ms) => { + clock += ms; + }, + clock: { + nowMs: () => clock, + stat: () => ({ mtimeMs: 0 }), + }, + }); + + const result = await registry.acquireLock({ + pid: 5555, + startToken: "linux1:boot-a:55", + }); + + expect(result.status).toBe("acquired"); + }); + + test("acquires a free lock and releases it", async () => { + const registry = createDurableRegistry({ + root: temporaryRoot(), + identity: recordingIdentity({ status: "gone" }), + }); + + const first = await registry.acquireLock(HOLDER); + expect(first.status).toBe("acquired"); + + if (first.status === "acquired") { + first.handle.release(); + } + + const second = await registry.acquireLock(HOLDER); + expect(second.status).toBe("acquired"); + }); + + test("a live holder is never displaced", async () => { + const registry = createDurableRegistry({ + root: temporaryRoot(), + identity: recordingIdentity({ status: "alive" }), + lockTimeoutMs: 60, + lockPollIntervalMs: 10, + }); + + expect((await registry.acquireLock(HOLDER)).status).toBe("acquired"); + expect( + await registry.acquireLock({ + pid: 9999, + startToken: "linux1:boot-a:99", + }), + ).toEqual({ status: "unavailable" }); + }); + + test("an unverifiable holder is never displaced", async () => { + const registry = createDurableRegistry({ + root: temporaryRoot(), + identity: recordingIdentity({ + status: "unknown", + reason: "permission-denied", + }), + lockTimeoutMs: 60, + lockPollIntervalMs: 10, + }); + + expect((await registry.acquireLock(HOLDER)).status).toBe("acquired"); + expect( + await registry.acquireLock({ + pid: 9999, + startToken: "linux1:boot-a:99", + }), + ).toEqual({ status: "unavailable" }); + }); + + test("a provably dead holder's lock is reclaimed", async () => { + const identity = recordingIdentity({ status: "gone" }); + const registry = createDurableRegistry({ + root: temporaryRoot(), + identity, + lockTimeoutMs: 200, + lockPollIntervalMs: 10, + }); + + expect((await registry.acquireLock(HOLDER)).status).toBe("acquired"); + + const reclaimed = await registry.acquireLock({ + pid: 9999, + startToken: "linux1:boot-a:99", + }); + expect(reclaimed.status).toBe("acquired"); + + expect(identity.probeCalls).toContainEqual({ + pid: HOLDER.pid, + expectedToken: HOLDER.startToken, + }); + for (const call of identity.probeCalls) { + expect(call.pid).not.toBe(9999); + } + }); + + test("a zero wait budget refuses even a provably dead holder's lock", async () => { + const registry = createDurableRegistry({ + root: temporaryRoot(), + identity: recordingIdentity({ status: "gone" }), + lockTimeoutMs: 0, + }); + + expect((await registry.acquireLock(HOLDER)).status).toBe("acquired"); + + const result = await registry.acquireLock({ + pid: 9999, + startToken: "linux1:boot-a:99", + }); + expect(result).toEqual({ status: "unavailable" }); + }); + + test("a corrupt holder record is reclaimable, not permanent", async () => { + const root = temporaryRoot(); + const registry = createDurableRegistry({ + root, + identity: recordingIdentity({ status: "alive" }), + lockTimeoutMs: 200, + lockPollIntervalMs: 10, + }); + + expect((await registry.acquireLock(HOLDER)).status).toBe("acquired"); + writeFileSync(join(root, "registry.lock", "holder.json"), "{ bad", "utf8"); + + const reclaimed = await registry.acquireLock(HOLDER); + expect(reclaimed.status).toBe("acquired"); + }); + + test("a holder file that parses but is not a plain object is reclaimable, not a rejection", async () => { + for (const holderFileContent of ["null", "[]", "42"]) { + const root = temporaryRoot(); + const registry = createDurableRegistry({ + root, + identity: recordingIdentity({ status: "alive" }), + lockTimeoutMs: 200, + lockPollIntervalMs: 10, + }); + + expect((await registry.acquireLock(HOLDER)).status).toBe("acquired"); + writeFileSync( + join(root, "registry.lock", "holder.json"), + holderFileContent, + "utf8", + ); + + const reclaimed = await registry.acquireLock(HOLDER); + expect(reclaimed.status).toBe("acquired"); + } + }); + + test("releasing a lock the caller no longer owns does nothing", async () => { + const registry = createDurableRegistry({ + root: temporaryRoot(), + identity: recordingIdentity({ status: "alive" }), + lockTimeoutMs: 60, + lockPollIntervalMs: 10, + }); + + const first = await registry.acquireLock(HOLDER); + if (first.status !== "acquired") { + throw new Error("expected the first acquisition to succeed"); + } + first.handle.release(); + + const second = await registry.acquireLock(HOLDER); + expect(second.status).toBe("acquired"); + + first.handle.release(); + + const third = await registry.acquireLock({ + pid: 5555, + startToken: "linux1:boot-a:55", + }); + expect(third).toEqual({ status: "unavailable" }); + }); + + test("the lock holder file carries a generation, pid, and token", async () => { + const root = temporaryRoot(); + const registry = createDurableRegistry({ + root, + identity: recordingIdentity({ status: "gone" }), + }); + + await registry.acquireLock(HOLDER); + const raw = readFileSync(join(root, "registry.lock", "holder.json"), "utf8"); + + expect(JSON.parse(raw).pid).toBe(4242); + expect(JSON.parse(raw).startToken).toBe("linux1:boot-a:11"); + expect(JSON.parse(raw).generation).toMatch(/^[0-9a-f]+$/); + }); +}); diff --git a/tests/fixtures/durable-child.mjs b/tests/fixtures/durable-child.mjs new file mode 100644 index 0000000..7b455db --- /dev/null +++ b/tests/fixtures/durable-child.mjs @@ -0,0 +1,14 @@ +const timer = setInterval(() => {}, 1000); + +function shutdown() { + clearInterval(timer); + process.exit(0); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +if (process.argv[2] === "reap-on-stdin-close") { + process.stdin.on("close", shutdown); + process.stdin.resume(); +} diff --git a/tests/helpers/core-fixtures.ts b/tests/helpers/core-fixtures.ts new file mode 100644 index 0000000..5e87400 --- /dev/null +++ b/tests/helpers/core-fixtures.ts @@ -0,0 +1,436 @@ +import type { ConfiguredCommand } from "../../src/config.js"; +import { + createStartupState, + type SpawnFunction, + type SpawnedChild, + type StartupState, +} from "../../src/core.js"; +import type { + DurableLockResult, + DurableReadResult, + DurableRecord, + DurableRegistry, +} from "../../src/durable-registry.js"; +import type { LogEvent, Logger } from "../../src/logger.js"; +import type { ProcessIdentityController } from "../../src/process-identity.js"; +import type { + ProcessTreeController, + ProcessTreeStopOptions, + ProcessTreeStopResult, +} from "../../src/process-tree.js"; + +export class FakeChild implements SpawnedChild { + public readonly onceCalls: Array<"error" | "exit"> = []; + public unrefCalls = 0; + private readonly errorListeners: Array<(error: Error) => void> = []; + private readonly exitListeners: Array< + (code: number | null, signal: NodeJS.Signals | null) => void + > = []; + + public constructor( + public readonly pid?: number, + private readonly options: { + throwOnOnce?: boolean; + throwOnUnref?: boolean; + } = {}, + ) {} + + public once(event: "error", listener: (error: Error) => void): this; + public once( + event: "exit", + listener: ( + code: number | null, + signal: NodeJS.Signals | null, + ) => void, + ): this; + public once( + event: "error" | "exit", + listener: + | ((error: Error) => void) + | ((code: number | null, signal: NodeJS.Signals | null) => void), + ): this { + this.onceCalls.push(event); + + if (this.options.throwOnOnce) { + throw new Error("Test child once failure"); + } + + if (event === "error") { + this.errorListeners.push(listener as (error: Error) => void); + } else { + this.exitListeners.push( + listener as ( + code: number | null, + signal: NodeJS.Signals | null, + ) => void, + ); + } + + return this; + } + + public unref(): void { + this.unrefCalls += 1; + + if (this.options.throwOnUnref) { + throw new Error("Test child unref failure"); + } + } + + public emitError(error: Error): void { + const listeners = this.errorListeners.splice(0); + + for (const listener of listeners) { + listener(error); + } + } + + public emitExit( + code: number | null, + signal: NodeJS.Signals | null, + ): void { + const listeners = this.exitListeners.splice(0); + + for (const listener of listeners) { + listener(code, signal); + } + } +} + +export class FakeProcessTree implements ProcessTreeController { + public readonly calls: Array<{ + pid: number | undefined; + options: ProcessTreeStopOptions; + }> = []; + + public constructor( + private readonly results: ProcessTreeStopResult[] = [ + { status: "stopped" }, + ], + ) {} + + public async stop( + pid: number | undefined, + options: ProcessTreeStopOptions, + ): Promise { + this.calls.push({ pid, options }); + return this.results.shift() ?? { status: "stopped" }; + } +} + +export function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +export class DeferredProcessTree implements ProcessTreeController { + public readonly calls: Array<{ + pid: number | undefined; + options: ProcessTreeStopOptions; + }> = []; + public readonly stops = new Map< + number | undefined, + ReturnType> + >(); + + public stop( + pid: number | undefined, + options: ProcessTreeStopOptions, + ): Promise { + this.calls.push({ pid, options }); + const pending = deferred(); + this.stops.set(pid, pending); + return pending.promise; + } +} + +type CommandPolicies = Pick< + ConfiguredCommand, + "onExistingProcess" | "stopOnExit" +>; + +const DEFAULT_POLICIES: CommandPolicies = { + onExistingProcess: "skip", + stopOnExit: true, +}; + +export function globalCommand( + executable: string, + args: string[] = [], + name = "Global command", + index = 0, + policies: CommandPolicies = DEFAULT_POLICIES, +): ConfiguredCommand { + return { name, executable, args, ...policies, scope: "global", index }; +} + +export function projectCommand( + projectRoot: string, + executable: string, + args: string[] = [], + name = "Project command", + index = 0, + policies: CommandPolicies = DEFAULT_POLICIES, +): ConfiguredCommand { + return { + name, + executable, + args, + ...policies, + scope: "project", + projectRoot, + index, + }; +} + +export function createLogger(): Logger & { events: LogEvent[] } { + const events: LogEvent[] = []; + + return { + events, + write(event): void { + events.push(event); + }, + }; +} + +export function createSpawn( + children: SpawnedChild[], + calls: unknown[][], +): SpawnFunction { + let childIndex = 0; + + return (executable, args, options) => { + calls.push([executable, args, options]); + const child = children[childIndex]; + childIndex += 1; + + if (!child) { + throw new Error("Test did not provide a fake child"); + } + + return child; + }; +} + +export function createDependencies( + children: SpawnedChild[] = [], + processTree: ProcessTreeController = new FakeProcessTree(), +): { + calls: unknown[][]; + logger: Logger & { events: LogEvent[] }; + processTree: ProcessTreeController; + state: StartupState; + spawn: SpawnFunction; +} { + const calls: unknown[][] = []; + + return { + calls, + logger: createLogger(), + processTree, + state: createStartupState(), + spawn: createSpawn(children, calls), + }; +} + +export function createGatedIdentityState( + processKey: string, + transitionTail: Promise, +): StartupState { + return new Map([ + [ + processKey, + { + processKey, + records: [], + cleanupUnconfirmed: false, + status: "stable", + transitionTail, + pendingTransitions: 0, + nextCreationOrder: 0, + }, + ], + ]) as StartupState; +} + +export function seedAdopted( + state: StartupState, + processKey: string, + pid: number, + startToken: string, +): void { + state.set(processKey, { + processKey, + records: [ + { + origin: "adopted", + pid, + startToken, + context: { scope: "global", index: 0, name: "Helper" }, + creationOrder: 0, + owners: new Set(), + stopOnExit: false, + status: "active", + rootExited: false, + }, + ], + cleanupUnconfirmed: false, + status: "stable", + transitionTail: Promise.resolve(), + pendingTransitions: 0, + nextCreationOrder: 1, + } as never); +} + +export class FakeRegistry implements DurableRegistry { + public readonly writes: DurableRecord[][] = []; + public releases = 0; + public acquireCalls = 0; + + public constructor( + private readonly readResult: DurableReadResult = { + status: "loaded", + records: [], + }, + private readonly lockAvailable = true, + private readonly fileExists = true, + ) {} + + public hashIdentity(signature: string): string { + return `identity-${signature}`; + } + + public hashProjectRoot(normalizedRoot: string): string { + return `root-${normalizedRoot}`; + } + + public exists(): boolean { + return this.fileExists; + } + + public read(): DurableReadResult { + return this.readResult; + } + + public write(records: readonly DurableRecord[]): boolean { + this.writes.push([...records]); + return true; + } + + public async acquireLock(): Promise { + this.acquireCalls += 1; + if (!this.lockAvailable) { + return { status: "unavailable" }; + } + return { + status: "acquired", + handle: { + release: () => { + this.releases += 1; + }, + }, + }; + } +} + +export class RoundTrippingRegistry implements DurableRegistry { + private records: DurableRecord[] = []; + public readonly writes: DurableRecord[][] = []; + public releases = 0; + + public hashIdentity(signature: string): string { + return `identity-${signature}`; + } + + public hashProjectRoot(normalizedRoot: string): string { + return `root-${normalizedRoot}`; + } + + public exists(): boolean { + return this.writes.length > 0; + } + + public read(): DurableReadResult { + return { status: "loaded", records: [...this.records] }; + } + + public write(records: readonly DurableRecord[]): boolean { + this.records = [...records]; + this.writes.push([...records]); + return true; + } + + public async acquireLock(): Promise { + return { + status: "acquired", + handle: { + release: () => { + this.releases += 1; + }, + }, + }; + } +} + +export function aliveIdentity(token = "linux1:boot-a:11"): ProcessIdentityController { + return { + async describe() { + return { status: "described" as const, token }; + }, + async probe() { + return { status: "alive" as const }; + }, + }; +} + +const HELPER_IDENTITY = `identity-${JSON.stringify(["helper", []])}`; + +export function persistedRecord( + overrides: Partial< + Pick< + DurableRecord, + "identityHash" | "pid" | "startToken" | "creationOrder" | "recordedAt" + > + > = {}, +): DurableRecord { + return { + scope: "global", + identityHash: HELPER_IDENTITY, + pid: 4242, + startToken: "linux1:boot-a:11", + stopOnExit: false, + creationOrder: 0, + recordedAt: "2026-09-03T01:46:57.997Z", + ...overrides, + }; +} + +export function orphanRecord( + overrides: Partial< + Pick< + DurableRecord, + "identityHash" | "pid" | "startToken" | "creationOrder" | "recordedAt" + > + > = {}, +): DurableRecord { + return { + scope: "global", + identityHash: `identity-${JSON.stringify(["orphan-tool", []])}`, + pid: 7777, + startToken: "linux1:boot-a:99", + stopOnExit: false, + creationOrder: 0, + recordedAt: "2026-09-03T00:00:00.000Z", + ...overrides, + }; +} diff --git a/tests/logger.test.ts b/tests/logger.test.ts index 9fca359..ce43993 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -147,6 +147,18 @@ const FILE_EVENT_CASES = [ expected: 'command.skipped scope=global index=6 name="Global helper" reason=duplicate', }, + { + label: "global command blocked by an unavailable durable subsystem", + event: { + type: "command.skipped", + scope: "global", + index: 7, + name: "Global helper", + reason: "durable-unavailable", + }, + expected: + 'command.skipped scope=global index=7 name="Global helper" reason=durable-unavailable', + }, { label: "project stop requested", event: { @@ -302,10 +314,18 @@ describe("Logger console output", () => { name: "Started helper", reason: "already-started", }); + logger.write({ + type: "command.skipped", + scope: "global", + index: 4, + name: "Blocked helper", + reason: "durable-unavailable", + }); expect(output.lines).toEqual([ 'startup-commands: command skipped scope=global index=2 name="Repeated helper" reason=duplicate', 'startup-commands: command skipped scope=project index=3 name="Started helper" reason=already-started', + 'startup-commands: command skipped scope=global index=4 name="Blocked helper" reason=durable-unavailable', ]); }); @@ -500,3 +520,53 @@ describe("Logger file output", () => { expect(existsSync(expectedPath)).toBe(true); }); }); + +describe("durable log events", () => { + test("formats every durable event without leaking a start token", () => { + const output = createConsole(); + const directory = createTempDirectory(); + const logger = createLogger({ + console: output, + filePath: join(directory, "durable.log"), + now: () => new Date("2026-09-03T01:46:57.997Z"), + }); + + logger.write({ + type: "durable.record-adopted", + scope: "global", + index: 0, + name: "Helper", + pid: 4242, + }); + logger.write({ + type: "durable.record-dropped", + scope: "project", + pid: 5555, + }); + logger.write({ + type: "durable.record-unverifiable", + scope: "global", + index: 1, + name: "Helper", + pid: 6666, + }); + logger.write({ + type: "durable.reconciled", + scope: "global", + stoppedCount: 2, + }); + logger.write({ type: "durable.registry-quarantined" }); + logger.write({ type: "durable.unavailable", cause: "lock-unavailable" }); + + expect(output.lines).toHaveLength(6); + expect(output.lines[0]).toContain("record adopted"); + expect(output.lines[0]).toContain("pid=4242"); + expect(output.lines[3]).toContain("stoppedCount=2"); + expect(output.lines[4]).toContain("registry quarantined"); + expect(output.lines[5]).toContain("cause=lock-unavailable"); + for (const line of output.lines) { + expect(line).not.toContain("linux1:"); + expect(line).not.toContain("registry.json"); + } + }); +}); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 729722e..356ae42 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -5,7 +5,7 @@ const manifest: Record = packageManifest; test("manifest exposes the exact public release metadata", () => { expect(manifest.name).toBe("opencode-startup-commands"); - expect(manifest.version).toBe("1.1.0"); + expect(manifest.version).toBe("1.2.0"); expect(manifest).not.toHaveProperty("private"); expect(manifest.description).toBe( "Launch trusted background commands when OpenCode initializes a project or directory.", @@ -19,7 +19,7 @@ test("manifest exposes the exact public release metadata", () => { "git+https://github.com/PixelWinner/opencode-startup-commands.git", ); expect(manifest.homepage).toBe( - "https://github.com/PixelWinner/opencode-startup-commands#readme", + "https://github.com/PixelWinner/opencode-startup-commands", ); expect(manifest.bugs).toEqual({ url: "https://github.com/PixelWinner/opencode-startup-commands/issues", diff --git a/tests/process-identity.test.ts b/tests/process-identity.test.ts new file mode 100644 index 0000000..be24728 --- /dev/null +++ b/tests/process-identity.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, test } from "bun:test"; +import { + createProcessIdentityController, + type ProcessIdentityController, + type ProcessQueryChild, + type ProcessQuerySpawn, + type ProcessQuerySpawnOptions, +} from "../src/process-identity.js"; + +const BOOT_ID_PATH = "/proc/sys/kernel/random/boot_id"; + +function linuxController( + files: Record, +): ProcessIdentityController { + return createProcessIdentityController({ + platform: "linux", + readTextFile(path: string): string { + const value = files[path]; + if (value === undefined) { + throw Object.assign(new Error("Missing test file"), { + code: "ENOENT", + }); + } + if (value instanceof Error) { + throw value; + } + return value; + }, + }); +} + +function statLine(starttime: number): string { + const fields = Array.from({ length: 50 }, (_unused, index) => + String(index + 1), + ); + fields[18] = String(starttime); + return `4242 (my (weird) proc) S ${fields.join(" ")}\n`; +} + +describe("linux process identity", () => { + test("describes a live process as boot id paired with start ticks", async () => { + const controller = linuxController({ + [BOOT_ID_PATH]: "6f9619ff-8b86-d011-b42d-00c04fc964ff\n", + "/proc/4242/stat": statLine(998877), + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "described", + token: "linux1:6f9619ff-8b86-d011-b42d-00c04fc964ff:998877", + }); + }); + + test("reports a missing process as gone", async () => { + const controller = linuxController({ + [BOOT_ID_PATH]: "b001dead\n", + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "gone", + }); + }); + + test("reports a malformed boot id as unknown, never gone", async () => { + const controller = linuxController({ + [BOOT_ID_PATH]: "6F9619FF-8B86-D011-B42D-00C04FC964FF\n", + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "unknown", + reason: "malformed-output", + }); + }); + + test("reports a non-hex boot id as unknown, never gone", async () => { + const controller = linuxController({ + [BOOT_ID_PATH]: "notarealboot\n", + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "unknown", + reason: "malformed-output", + }); + }); + + test("rejects an unsafe process identifier without reading anything", async () => { + let reads = 0; + const controller = createProcessIdentityController({ + platform: "linux", + readTextFile(): string { + reads += 1; + return ""; + }, + }); + + await expect(controller.describe(0)).resolves.toEqual({ + status: "gone", + }); + expect(reads).toBe(0); + }); +}); + +describe("probe comparison", () => { + test("matching token is alive", async () => { + const controller = linuxController({ + [BOOT_ID_PATH]: "b001dead\n", + "/proc/7/stat": statLine(11), + }); + + await expect(controller.probe(7, "linux1:b001dead:11")).resolves.toEqual({ + status: "alive", + }); + }); + + test("same pid with a different start tick is gone", async () => { + const controller = linuxController({ + [BOOT_ID_PATH]: "b001dead\n", + "/proc/7/stat": statLine(12), + }); + + await expect(controller.probe(7, "linux1:b001dead:11")).resolves.toEqual({ + status: "gone", + }); + }); + + test("same pid and tick after a reboot is gone", async () => { + const controller = linuxController({ + [BOOT_ID_PATH]: "b002dead\n", + "/proc/7/stat": statLine(11), + }); + + await expect(controller.probe(7, "linux1:b001dead:11")).resolves.toEqual({ + status: "gone", + }); + }); + + test("a token tagged for another platform is gone without probing", async () => { + let reads = 0; + const controller = createProcessIdentityController({ + platform: "linux", + readTextFile(): string { + reads += 1; + return ""; + }, + }); + + await expect( + controller.probe(7, "win1:638912345678901234"), + ).resolves.toEqual({ status: "gone" }); + expect(reads).toBe(0); + }); + + test("permission failure is unknown, never gone", async () => { + const controller = linuxController({ + [BOOT_ID_PATH]: Object.assign(new Error("denied"), { code: "EACCES" }), + }); + + await expect(controller.probe(7, "linux1:b001dead:11")).resolves.toEqual({ + status: "unknown", + reason: "permission-denied", + }); + }); + + test("malformed stat content is unknown, never gone", async () => { + const controller = linuxController({ + [BOOT_ID_PATH]: "b001dead\n", + "/proc/7/stat": "no closing paren here\n", + }); + + await expect(controller.probe(7, "linux1:b001dead:11")).resolves.toEqual({ + status: "unknown", + reason: "malformed-output", + }); + }); +}); + +class FakeQueryChild implements ProcessQueryChild { + public readonly killCalls: Array = []; + public unrefCalls = 0; + private dataListener?: (chunk: Buffer | string) => void; + private errorListener?: (error: Error) => void; + private closeListener?: (code: number | null) => void; + + public readonly stdout = { + on: ( + _event: "data", + listener: (chunk: Buffer | string) => void, + ): unknown => { + this.dataListener = listener; + return this.stdout; + }, + }; + + public once( + event: "error", + listener: (error: Error) => void, + ): ProcessQueryChild; + public once( + event: "close", + listener: (code: number | null) => void, + ): ProcessQueryChild; + public once( + event: "error" | "close", + listener: ((error: Error) => void) | ((code: number | null) => void), + ): ProcessQueryChild { + if (event === "error") { + this.errorListener = listener as (error: Error) => void; + } else { + this.closeListener = listener as (code: number | null) => void; + } + return this; + } + + public kill(signal?: NodeJS.Signals | number): boolean { + this.killCalls.push(signal); + return true; + } + + public unref(): void { + this.unrefCalls += 1; + } + + public emitData(chunk: string): void { + this.dataListener?.(chunk); + } + + public emitClose(exitCode: number | null): void { + this.closeListener?.(exitCode); + } + + public emit(output: string, exitCode: number | null): void { + this.emitData(output); + this.emitClose(exitCode); + } +} + +interface RecordedSpawn { + executable: string; + args: string[]; + options: ProcessQuerySpawnOptions; +} + +function queryController( + platform: NodeJS.Platform, + respond: (child: FakeQueryChild, call: RecordedSpawn) => void, + env: NodeJS.ProcessEnv = { SystemRoot: "C:\\Windows" }, +): { controller: ProcessIdentityController; calls: RecordedSpawn[] } { + const calls: RecordedSpawn[] = []; + const spawn: ProcessQuerySpawn = (executable, args, options) => { + const call = { executable, args, options }; + calls.push(call); + const child = new FakeQueryChild(); + queueMicrotask(() => respond(child, call)); + return child; + }; + + return { + calls, + controller: createProcessIdentityController({ + platform, + env, + spawn, + timeoutMs: 50, + }), + }; +} + +describe("windows process identity", () => { + test("describes a live process from utc ticks", async () => { + const { controller, calls } = queryController("win32", (child) => { + child.emit("638912345678901234\r\n", 0); + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "described", + token: "win1:638912345678901234", + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.executable).toBe( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + expect(calls[0]?.args).toContain("-NoProfile"); + expect(calls[0]?.args).toContain("-NonInteractive"); + expect(calls[0]?.options).toEqual({ + detached: false, + shell: false, + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + }); + + test("the absent sentinel means the process is gone", async () => { + const { controller } = queryController("win32", (child) => { + child.emit("absent\r\n", 0); + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "gone", + }); + }); + + test("the denied sentinel is a permission failure, not gone", async () => { + const { controller } = queryController("win32", (child) => { + child.emit("denied\r\n", 0); + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "unknown", + reason: "permission-denied", + }); + }); + + test("empty output is malformed, not gone", async () => { + const { controller } = queryController("win32", (child) => { + child.emit("", 0); + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "unknown", + reason: "malformed-output", + }); + }); + + test("non-numeric output is malformed, not gone", async () => { + const { controller } = queryController("win32", (child) => { + child.emit("Get-Process : Cannot find a process\r\n", 1); + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "unknown", + reason: "malformed-output", + }); + }); + + test("a missing SystemRoot makes the facility unavailable", async () => { + const { controller, calls } = queryController( + "win32", + (child) => { + child.emit("1\r\n", 0); + }, + {}, + ); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "unknown", + reason: "facility-unavailable", + }); + expect(calls).toHaveLength(0); + }); + + test("a hung utility times out and is killed", async () => { + let spawned: FakeQueryChild | undefined; + const { controller } = queryController("win32", (child) => { + spawned = child; + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "unknown", + reason: "timeout", + }); + expect(spawned?.killCalls).toEqual(["SIGKILL"]); + expect(spawned?.unrefCalls).toBe(1); + }); +}); + +describe("macos process identity", () => { + test("normalizes lstart whitespace into a token", async () => { + const { controller, calls } = queryController("darwin", (child) => { + child.emit("Wed Sep 3 01:46:57 2026\n", 0); + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "described", + token: "mac1:Wed Sep 3 01:46:57 2026", + }); + expect(calls[0]?.executable).toBe("/bin/ps"); + expect(calls[0]?.args).toEqual(["-p", "4242", "-o", "lstart="]); + }); + + test("pins the ps time zone and locale so a host time-zone change cannot invalidate a token", async () => { + const { controller, calls } = queryController( + "darwin", + (child) => { + child.emit("Wed Sep 3 01:46:57 2026\n", 0); + }, + { PATH: "/usr/bin", HOME: "/Users/dev", TZ: "Europe/Kyiv" }, + ); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "described", + token: "mac1:Wed Sep 3 01:46:57 2026", + }); + expect(calls[0]?.options.env).toEqual({ + PATH: "/usr/bin", + HOME: "/Users/dev", + TZ: "UTC", + LC_ALL: "C", + }); + }); + + test("empty ps output means the process is gone", async () => { + const { controller } = queryController("darwin", (child) => { + child.emit("\n", 1); + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "gone", + }); + }); +}); + +describe("query output cap", () => { + const CAP = 4096; + + test("caps accumulated stdout instead of letting one chunk overshoot the limit", async () => { + const upToBoundary = "9".repeat(CAP - 6); + const overflowChunk = "9".repeat(50); + const { controller } = queryController("win32", (child) => { + child.emitData(upToBoundary); + child.emitData(overflowChunk); + child.emitClose(0); + }); + + await expect(controller.describe(4242)).resolves.toEqual({ + status: "described", + token: `win1:${"9".repeat(CAP)}`, + }); + }); +}); + +describe("identity privacy", () => { + test("malformed output never appears in the result", async () => { + const secret = "C:\\Users\\someone\\secret-helper.exe"; + const { controller } = queryController("win32", (child) => { + child.emit(`error near ${secret}\r\n`, 1); + }); + + const result = await controller.describe(4242); + expect(JSON.stringify(result)).not.toContain("secret-helper"); + expect(result).toEqual({ + status: "unknown", + reason: "malformed-output", + }); + }); +}); diff --git a/tests/readme.test.ts b/tests/readme.test.ts index 38a2f7e..aed09eb 100644 --- a/tests/readme.test.ts +++ b/tests/readme.test.ts @@ -4,10 +4,10 @@ import { join } from "node:path"; const readme = readFileSync(join(import.meta.dir, "..", "README.md"), "utf8"); const packageName = "opencode-startup-commands"; -const npmRegistration = `${packageName}@1.1.0`; +const npmRegistration = `${packageName}@1.2.0`; const gitRegistration = `${packageName}@git+https://github.com/PixelWinner/` + - `${packageName}.git#v1.1.0`; + `${packageName}.git#v1.2.0`; function pluginRegistrations(markdown: string): string[] { const registrations: string[] = []; @@ -81,6 +81,7 @@ test("keeps the concise approved public structure", () => { "Compatibility", "Configuration", "Lifecycle and deduplication", + "Process lifetime across OpenCode restarts", "Security", "Logging", "Development", @@ -167,6 +168,137 @@ test("documents degraded restart blockers and process visibility", () => { expect(readme).toMatch(/full OpenCode restart[^\n]*updating plugin code/i); }); +test("scopes rediscovery to recorded processes with a matching identity", () => { + expect(readme).toMatch( + /re-adopts only[^\n]*`stopOnExit: false`[^\n]*recorded in its (?:own )?registry/i, + ); + expect(readme).toMatch(/whose `\(pid, startToken\)` pair still matches/i); + expect(readme).toMatch( + /cannot rediscover[^\n]*`stopOnExit: true`[^\n]*tracked only in memory/i, + ); + expect(readme).toMatch( + /processes it recorded[^\n]*registry[^\n]*did not launch/i, + ); +}); + +test("documents durable survival and re-adoption after a full OpenCode exit", () => { + expect(readme).toMatch( + /`stopOnExit: false`[^\n]*survives an OpenCode exit[^\n]*next OpenCode process re-adopts it/i, + ); + expect(readme).toMatch(/instead of starting a second copy/i); + expect(readme).toMatch( + /`stopOnExit: true`[^\n]*never recorded[^\n]*never re-adopted/i, + ); +}); + +test("tabulates all six onExistingProcess and stopOnExit combinations", () => { + for (const policy of ["start", "skip", "restart"]) { + for (const stopOnExit of ["true", "false"]) { + expect(readme).toMatch( + new RegExp(`^\\| \`${policy}\` \\| \`${stopOnExit}\` \\| \\S`, "m"), + ); + } + } + expect(readme).toMatch(/`skip` \| `false` \|[^\n]*Reuses the surviving process/i); + expect(readme).toMatch( + /`start` \| `false` \|[^\n]*starts one more[^\n]*both are remembered/i, + ); + expect(readme).toMatch( + /`restart` \| `false` \|[^\n]*Stops the surviving process[^\n]*exactly one replacement/i, + ); + expect(readme).toMatch(/`skip` \| `true` \|[^\n]*Nothing survived/i); + expect(readme).toMatch(/`start` \| `true` \|[^\n]*Nothing survived/i); + expect(readme).toMatch(/`restart` \| `true` \|[^\n]*Nothing survived/i); +}); + +test("documents the deferred stopOnExit transition and configuration reconciliation", () => { + expect(readme).toMatch( + /Changing `stopOnExit` from `false` to `true`[^\n]*next activation[^\n]*not the running one/i, + ); + expect(readme).toMatch( + /surviving process is stopped[^\n]*replacement[^\n]*stopped when its session closes/i, + ); + expect(readme).toMatch( + /Removing a command from a cleanly loaded configuration stops its durable process/i, + ); + expect(readme).toMatch( + /kept unless that stop is confirmed[^\n]*retried on every later cleanly loaded activation[^\n]*waits out the stop budget/i, + ); + expect(readme).toMatch( + /configuration error stops nothing[^\n]*adoption still runs/i, + ); + expect(readme).toMatch( + /stopping is destructive[^\n]*both the global and the project file[^\n]*no error/i, + ); +}); + +test("documents the fail-closed probe rule and the absence of an external runtime", () => { + expect(readme).toMatch( + /cannot determine whether a remembered process is alive[^\n]*fails closed/i, + ); + expect(readme).toMatch( + /neither stops that process nor starts a duplicate/i, + ); + expect(readme).toMatch( + /No external runtime, daemon, or supervisor[^\n]*no runtime dependencies/i, + ); +}); + +test("documents what the registry stores, where, and how it is matched", () => { + expect(readme).toContain( + "%LOCALAPPDATA%\\opencode\\startup-commands\\\\registry.json", + ); + expect(readme).toContain( + "~/Library/Application Support/OpenCode/startup-commands//registry.json", + ); + expect(readme).toContain( + "$XDG_STATE_HOME/opencode/startup-commands//registry.json", + ); + expect(readme).toMatch( + /hash of the command identity[^\n]*PID[^\n]*start token/i, + ); + expect(readme).toMatch( + /registry is kept per host[^\n]*`` directory[^\n]*shared between machines keeps each machine's processes separate/i, + ); + expect(readme).toMatch( + /renaming the machine starts a new registry[^\n]*forgotten, never stopped[^\n]*launches fresh ones[^\n]*by hand after a rename/i, + ); + expect(readme).toMatch( + /re-identified by the `\(pid, startToken\)` pair, never by the token alone/i, + ); + expect(readme).toMatch( + /no token contains a PID[^\n]*can carry the same token/i, + ); + expect(readme).toMatch(/every comparison[^\n]*keyed by PID/i); + expect(readme).toMatch( + /deleted registry file[^\n]*`\(pid, startToken\)` pair no longer matches[^\n]*forgotten rather than stopped[^\n]*starts fresh/i, + ); + expect(readme).toMatch( + /cannot be parsed is set aside[^\n]*continues with an empty registry[^\n]*forgotten rather than stopped/i, + ); + expect(readme).toMatch( + /cannot be read at all is left in place[^\n]*skipped for that activation rather than started/i, + ); + expect(readme).toMatch( + /Executable paths, arguments, environment variables, and project paths are never recorded/i, + ); +}); + +test("documents the one-time 1.1.0 to 1.2.0 migration", () => { + expect(readme).toContain("### Upgrading from 1.1.0"); + expect(readme).toMatch( + /1\.1\.0 kept no record[^\n]*1\.2\.0 cannot identify them/i, + ); + expect(readme).toMatch( + /^1\. Stop any helpers left running by `stopOnExit: false`\.\r?$/m, + ); + expect(readme).toMatch(/^2\. Fully exit every OpenCode process\.\r?$/m); + expect(readme).toMatch(/^3\. Install 1\.2\.0\.\r?$/m); + expect(readme).toMatch( + /^4\. Start OpenCode\. New `stopOnExit: false` commands are now remembered\.\r?$/m, + ); +}); + test("documents platform stop escalation, limitations, and sanitized events", () => { expect(readme).toMatch(/POSIX[^\n]*SIGTERM[^\n]*5 seconds[^\n]*SIGKILL/i); expect(readme).toMatch( diff --git a/tests/release-draft.test.ts b/tests/release-draft.test.ts index 9448cdb..89fab91 100644 --- a/tests/release-draft.test.ts +++ b/tests/release-draft.test.ts @@ -211,21 +211,21 @@ describe("release environment validation", () => { const candidate = await makeCandidate(); const environment = { ...validEnvironment(candidate.directory), - RELEASE_TAG: "v1.1.0", + RELEASE_TAG: "v1.2.0", }; expect(validateReleaseEnvironment(environment)).toEqual({ repository, - tag: "v1.1.0", + tag: "v1.2.0", commitSha, candidateDirectory: candidate.directory, - tarballName: "opencode-startup-commands-1.1.0.tgz", + tarballName: "opencode-startup-commands-1.2.0.tgz", tarballPath: join( candidate.directory, - "opencode-startup-commands-1.1.0.tgz", + "opencode-startup-commands-1.2.0.tgz", ), checksumPath: join(candidate.directory, checksumName), - title: "v1.1.0", + title: "v1.2.0", }); }); }); diff --git a/tests/release-package.test.ts b/tests/release-package.test.ts index f370341..448bbb0 100644 --- a/tests/release-package.test.ts +++ b/tests/release-package.test.ts @@ -38,8 +38,16 @@ const allowedPaths = [ "dist/config.d.ts", "dist/core.js", "dist/core.d.ts", + "dist/durable-lock.js", + "dist/durable-lock.d.ts", + "dist/durable-registry.js", + "dist/durable-registry.d.ts", + "dist/error-code.js", + "dist/error-code.d.ts", "dist/logger.js", "dist/logger.d.ts", + "dist/process-identity.js", + "dist/process-identity.d.ts", "dist/process-tree.d.ts", "dist/process-tree.js", "dist/server.js", @@ -50,7 +58,7 @@ const allowedPaths = [ const releaseManifest = { name: "opencode-startup-commands", - version: "1.1.0", + version: "1.2.0", main: "./dist/server.js", types: "./dist/server.d.ts", exports: { @@ -76,10 +84,10 @@ const releaseManifest = { }; const packResult = { - id: "opencode-startup-commands@1.1.0", + id: "opencode-startup-commands@1.2.0", name: "opencode-startup-commands", - version: "1.1.0", - filename: "opencode-startup-commands-1.1.0.tgz", + version: "1.2.0", + filename: "opencode-startup-commands-1.2.0.tgz", size: 1024, unpackedSize: 4096, shasum: "0123456789abcdef0123456789abcdef01234567", @@ -126,7 +134,7 @@ describe("package file contract", () => { } }); - test("requires every path in the exact 15-file package", () => { + test("requires every path in the exact 23-file package", () => { for (const requiredPath of allowedPaths) { expect(() => validatePackageFilePaths( @@ -189,8 +197,8 @@ describe("package limits and identity", () => { test("requires the exact package identity and tarball filename", () => { expect(validatePackageIdentity(packResult)).toEqual({ name: "opencode-startup-commands", - version: "1.1.0", - filename: "opencode-startup-commands-1.1.0.tgz", + version: "1.2.0", + filename: "opencode-startup-commands-1.2.0.tgz", }); for (const changed of [ @@ -653,10 +661,10 @@ test("formats a deterministic SHA-256 checksum line", () => { expect( formatChecksumLine( digest, - "opencode-startup-commands-1.1.0.tgz", + "opencode-startup-commands-1.2.0.tgz", ), ).toBe( - `${digest} opencode-startup-commands-1.1.0.tgz\n`, + `${digest} opencode-startup-commands-1.2.0.tgz\n`, ); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index aa796ab..5877c17 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1,4 +1,6 @@ import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { normalize, resolve } from "node:path"; import type { PluginInput } from "@opencode-ai/plugin"; import type { ConfigLoadResult, @@ -9,7 +11,9 @@ import { type SpawnedChild, type StartupSpawnOptions, } from "../src/core.js"; +import type { DurableRecord, DurableRegistry } from "../src/durable-registry.js"; import type { LogEvent, Logger } from "../src/logger.js"; +import type { ProcessIdentityController } from "../src/process-identity.js"; import type { ProcessTreeController, ProcessTreeStopResult, @@ -28,6 +32,9 @@ interface FixtureOptions { logger?: Logger; pids?: readonly number[]; stop?: ProcessTreeController["stop"]; + registry?: DurableRegistry; + identity?: ProcessIdentityController; + resolveOwnerIdentity?: StartupCommandsServerDependencies["resolveOwnerIdentity"]; } function createFixture(options?: FixtureOptions) { @@ -107,6 +114,9 @@ function createFixture(options?: FixtureOptions) { events.push(event); }, }, + registry: options?.registry, + identity: options?.identity, + resolveOwnerIdentity: options?.resolveOwnerIdentity, }; return { @@ -548,3 +558,293 @@ test("ignores legacy tuple options", async () => { { type: "batch.skipped", reason: "no-valid-commands" }, ]); }); + +test("computes projectRootHash as a real 64-character lowercase hex digest", async () => { + const hashProjectRootCalls: string[] = []; + const writes: DurableRecord[][] = []; + const expectedNormalizedRoot = + process.platform === "win32" + ? normalize(resolve(input.worktree)).toLowerCase() + : normalize(resolve(input.worktree)); + const expectedProjectRootHash = createHash("sha256") + .update(expectedNormalizedRoot) + .digest("hex"); + const command: ConfiguredCommand = { + ...projectCommand(input.worktree), + stopOnExit: false, + }; + const expectedIdentityHash = createHash("sha256") + .update(JSON.stringify([command.executable, command.args])) + .digest("hex"); + const persisted: DurableRecord = { + scope: "project", + identityHash: expectedIdentityHash, + projectRootHash: expectedProjectRootHash, + pid: 4321, + startToken: "linux1:boot-a:1", + stopOnExit: false, + creationOrder: 0, + recordedAt: "2026-09-03T00:00:00.000Z", + }; + + const registry: DurableRegistry = { + hashIdentity(signature: string): string { + return createHash("sha256").update(signature).digest("hex"); + }, + hashProjectRoot(normalizedRoot: string): string { + hashProjectRootCalls.push(normalizedRoot); + return createHash("sha256").update(normalizedRoot).digest("hex"); + }, + exists: () => true, + read() { + return { status: "loaded", records: [persisted] }; + }, + write(records) { + writes.push([...records]); + return true; + }, + async acquireLock() { + return { status: "acquired", handle: { release: () => {} } }; + }, + }; + const identity: ProcessIdentityController = { + describe() { + throw new Error( + "describe should not run: the owner thunk resolves an identity, " + + "and the only persisted record is adopted, not spawned", + ); + }, + async probe() { + return { status: "alive" }; + }, + }; + const fixture = createFixture({ + registry, + identity, + resolveOwnerIdentity: async () => ({ + pid: 4242, + startToken: "owner-token-abc", + }), + }); + fixture.setProjectConfig({ + commands: [command], + diagnostics: [], + }); + + await fixture.server.server(input); + + expect(hashProjectRootCalls).toEqual([expectedNormalizedRoot]); + expect(fixture.spawnCalls).toEqual([]); + expect( + fixture.events.some((event) => event.type === "durable.record-adopted"), + ).toBe(true); + const written = writes.at(-1) ?? []; + expect(written).toHaveLength(1); + expect(written[0]?.scope).toBe("project"); + expect(written[0]?.identityHash).toBe(expectedIdentityHash); + expect(written[0]?.projectRootHash).toBe(expectedProjectRootHash); + expect(written[0]?.pid).toBe(4321); +}); + +test("never calls the durable registry when the identity dependency is absent", async () => { + const fixture = createFixture({ + registry: { + hashIdentity(): string { + throw new Error("registry must not be used without identity"); + }, + hashProjectRoot(): string { + throw new Error("registry must not be used without identity"); + }, + exists(): boolean { + throw new Error("registry must not be used without identity"); + }, + read() { + throw new Error("registry must not be used without identity"); + }, + write() { + throw new Error("registry must not be used without identity"); + }, + acquireLock() { + throw new Error("registry must not be used without identity"); + }, + }, + }); + fixture.setGlobalConfig({ commands: [globalCommand()], diagnostics: [] }); + + const hooks = await fixture.server.server(input); + + expect(fixture.spawnCalls).toHaveLength(1); + expect(typeof hooks.dispose).toBe("function"); +}); + +test("never calls the durable registry when the registry dependency is absent", async () => { + const identity: ProcessIdentityController = { + describe() { + throw new Error("identity must not be used without a registry"); + }, + probe() { + throw new Error("identity must not be used without a registry"); + }, + }; + const fixture = createFixture({ identity }); + fixture.setGlobalConfig({ commands: [globalCommand()], diagnostics: [] }); + + const hooks = await fixture.server.server(input); + + expect(fixture.spawnCalls).toHaveLength(1); + expect(typeof hooks.dispose).toBe("function"); +}); + +test("passes a resolved owner identity through to the durable lock holder", async () => { + const acquireLockCalls: Array<{ pid: number; startToken: string }> = []; + const registry: DurableRegistry = { + hashIdentity(signature: string): string { + return createHash("sha256").update(signature).digest("hex"); + }, + hashProjectRoot(normalizedRoot: string): string { + return createHash("sha256").update(normalizedRoot).digest("hex"); + }, + exists: () => true, + read() { + throw new Error("not exercised by this test"); + }, + write() { + throw new Error("not exercised by this test"); + }, + async acquireLock(holder) { + acquireLockCalls.push(holder); + return { status: "unavailable" }; + }, + }; + const identity: ProcessIdentityController = { + describe() { + throw new Error( + "describe should not run when the owner thunk resolves an identity", + ); + }, + probe() { + throw new Error("probe is not exercised by this test"); + }, + }; + const fixture = createFixture({ + registry, + identity, + resolveOwnerIdentity: async () => ({ + pid: 4242, + startToken: "owner-token-abc", + }), + }); + fixture.setProjectConfig({ + commands: [projectCommand(input.worktree)], + diagnostics: [], + }); + + await fixture.server.server(input); + + expect(acquireLockCalls).toEqual([{ pid: 4242, startToken: "owner-token-abc" }]); +}); + +test("falls back to core's identity resolution instead of fabricating a token", async () => { + const acquireLockCalls: Array<{ pid: number; startToken: string }> = []; + const describeCalls: Array = []; + const registry: DurableRegistry = { + hashIdentity(signature: string): string { + return createHash("sha256").update(signature).digest("hex"); + }, + hashProjectRoot(normalizedRoot: string): string { + return createHash("sha256").update(normalizedRoot).digest("hex"); + }, + exists: () => true, + read() { + throw new Error("not exercised by this test"); + }, + write() { + throw new Error("not exercised by this test"); + }, + async acquireLock(holder) { + acquireLockCalls.push(holder); + return { status: "unavailable" }; + }, + }; + const identity: ProcessIdentityController = { + async describe(pid) { + describeCalls.push(pid); + return { status: "described", token: "fallback-token" }; + }, + probe() { + throw new Error("probe is not exercised by this test"); + }, + }; + const fixture = createFixture({ + registry, + identity, + resolveOwnerIdentity: async () => { + throw new Error("identity source unavailable"); + }, + }); + fixture.setProjectConfig({ + commands: [projectCommand(input.worktree)], + diagnostics: [], + }); + + const hooks = await fixture.server.server(input); + + expect(describeCalls).toEqual([process.pid]); + expect(acquireLockCalls).toEqual([ + { pid: process.pid, startToken: "fallback-token" }, + ]); + expect(typeof hooks.dispose).toBe("function"); +}); + +test("resolves authoritative to false when either config scope has diagnostics", async () => { + const acquireLockCalls: Array<{ pid: number; startToken: string }> = []; + const registry: DurableRegistry = { + hashIdentity(signature: string): string { + return createHash("sha256").update(signature).digest("hex"); + }, + hashProjectRoot(normalizedRoot: string): string { + return createHash("sha256").update(normalizedRoot).digest("hex"); + }, + exists: () => true, + read() { + throw new Error("not exercised by this test"); + }, + write() { + throw new Error("not exercised by this test"); + }, + async acquireLock(holder) { + acquireLockCalls.push(holder); + return { status: "unavailable" }; + }, + }; + const identity: ProcessIdentityController = { + describe() { + throw new Error( + "describe should not run when the owner thunk resolves an identity", + ); + }, + probe() { + throw new Error("probe is not exercised by this test"); + }, + }; + const fixture = createFixture({ + registry, + identity, + resolveOwnerIdentity: async () => ({ + pid: 4242, + startToken: "owner-token-abc", + }), + }); + fixture.setGlobalConfig({ + commands: [], + diagnostics: [{ scope: "global", reason: "invalid-document" }], + }); + fixture.setProjectConfig({ + commands: [projectCommand(input.worktree)], + diagnostics: [], + }); + + await fixture.server.server(input); + + expect(acquireLockCalls).toEqual([{ pid: 4242, startToken: "owner-token-abc" }]); +}); diff --git a/tests/workflows.test.ts b/tests/workflows.test.ts index a3d2063..4b168f8 100644 --- a/tests/workflows.test.ts +++ b/tests/workflows.test.ts @@ -217,7 +217,7 @@ describe("release workflow modes and permissions", () => { expect(tagPackage).toContain("process.env.RELEASE_TAG"); expect(tagPackage).toContain("process.env.GITHUB_OUTPUT"); expect(tagPackage).toContain("tarball_name="); - expect(tagPackage).not.toContain("opencode-startup-commands-1.1.0.tgz"); + expect(tagPackage).not.toContain("opencode-startup-commands-1.2.0.tgz"); expect(tagPackage).not.toContain("contents: write"); }); @@ -272,6 +272,45 @@ describe("release workflow modes and permissions", () => { }); }); +describe("the durable suites ride the existing three-platform test matrix", () => { + function getMatrixOperatingSystems(job: string): string[] { + const match = job.match(/matrix:\n\s+os:\n((?:\s*- \S+\n)+)/); + if (!match) { + return []; + } + return match[1] + .split("\n") + .map((line) => line.trim().replace(/^-\s*/, "")) + .filter(Boolean) + .sort(); + } + + test("test keeps exactly the three-OS matrix and the unnarrowed full suite", () => { + const expectedOperatingSystems = [ + "macos-latest", + "ubuntu-latest", + "windows-latest", + ].sort(); + + for (const workflow of workflows) { + const testJob = getJob(workflow, "test"); + expect(testJob).not.toBe(""); + expect(getMatrixOperatingSystems(testJob)).toEqual( + expectedOperatingSystems, + ); + expect(testJob).toMatch(/^\s*- run: bun test\s*$/m); + + expect(testJob).not.toContain("tests/durable-integration.test.ts"); + expect(testJob).not.toContain("durable-integration:"); + + const verifyBuild = getJob(workflow, "verify-build"); + expect(verifyBuild).toContain("run: bun run typecheck"); + expect(verifyBuild).not.toContain("tests/durable-integration.test.ts"); + expect(verifyBuild).not.toContain("durable-integration:"); + } + }); +}); + describe("release workflow mutation and injection boundaries", () => { test("contains no publishing credentials or repository-history mutations", () => { expect(release).not.toMatch(/id-token:\s*write/);