diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 22d61e6..78c0cf7 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.0.0 + placeholder: 1.1.0 validations: required: true - type: input diff --git a/README.md b/README.md index 2ee0f83..46bb4bb 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Register the exact npm release in `opencode.json` or `opencode.jsonc`: ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-startup-commands@1.0.0"] + "plugin": ["opencode-startup-commands@1.1.0"] } ``` @@ -33,7 +33,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.0.0"] + "plugin": ["opencode-startup-commands@git+https://github.com/PixelWinner/opencode-startup-commands.git#v1.1.0"] } ``` @@ -55,13 +55,15 @@ Each file is strict JSON; comments and trailing commas are not accepted. A missi { "name": "Start helper", "executable": "/absolute/path/to/helper", - "args": ["--watch"] + "args": ["--watch"], + "onExistingProcess": "skip", + "stopOnExit": true } ] } ``` -`commands` must be an array. Each valid entry needs a non-empty `name`, a non-empty `executable`, and a string array named `args`; invalid entries are skipped without blocking later valid entries. +`commands` must be an array. Each valid entry needs a non-empty `name`, a non-empty `executable`, and a string array named `args`. `onExistingProcess` accepts only `start`, `skip`, or `restart` and defaults to `skip`; `stopOnExit` must be a boolean and defaults to `true`. Invalid entries are skipped without blocking later valid entries. Project commands receive the original OpenCode worktree root as `cwd`; only the deduplication key normalizes that root. Global commands inherit OpenCode's current directory. Both inherit its environment and `PATH`. @@ -69,14 +71,26 @@ Project commands receive the original OpenCode worktree root as `cwd`; only the - Global commands are loaded before project commands; array order is preserved within each scope. - One missing or invalid scope does not block valid commands from the other scope. -- Duplicate identity is the exact `executable` plus the ordered `args`; `name` is not part of identity. -- An exact global duplicate takes precedence over a project command. -- A global identity starts once per OpenCode process. -- A project identity starts once per normalized project root per OpenCode process. -- Identity is recorded before spawning, so failed launches are not retried in that process. +- Process identity is the exact `executable` plus the ordered `args`; project identities also include the normalized root, while global identities are shared by OpenCode instances in one process. `name`, `onExistingProcess`, and `stopOnExit` are not part of identity. +- Same-batch duplicate removal is global-first and first within each scope: the global/first duplicate winner supplies both policies before policy evaluation. +- `start` appends one additional, separately owned process record. +- `skip` attaches the current owner to and reuses the oldest active process record without changing its creation-time `stopOnExit`. +- `restart` stops all plugin-owned tracked records, regardless of `stopOnExit`, and starts one replacement only after complete confirmed cleanup. +- For `stopOnExit: true`, project ownership uses the normalized root and cleanup waits for that project's final owner; global cleanup waits for the final OpenCode plugin-instance owner. +- `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. - Bare `opencode serve` does not launch commands until a project or directory initializes the plugin. -Fully restart OpenCode after changing registration, revision, or command files. Restarting reloads configuration and clears process-wide launch identities. +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. + +### Process stopping + +- On POSIX, process-group cleanup sends `SIGTERM`, waits 5 seconds, then sends `SIGKILL`. +- On Windows, the plugin directly invokes trusted `taskkill.exe /T` and waits 5 seconds; only if the tree still remains does it invoke `taskkill.exe /T /F` for forced cleanup. +- 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. ## Security @@ -99,7 +113,7 @@ Events appear as `startup-commands:` messages on OpenCode's stderr and in: | macOS | `~/Library/Logs/OpenCode/opencode-startup-commands.log` | | Linux | `$XDG_STATE_HOME/opencode/log/opencode-startup-commands.log`, or `~/.local/state/opencode/log/opencode-startup-commands.log` | -The active file rotates at 1 MiB and retains one `.1` archive. Logging failures do not prevent command startup. Logs omit configuration and executable paths, arguments, environment variables, raw configuration, and raw errors; they retain only sanitized lifecycle context such as scope, safe display name, PID, exit code, and signal. +The active file rotates at 1 MiB and retains one `.1` archive. Logging failures do not prevent command startup. Logs omit configuration and executable paths, arguments, environment variables, raw configuration, and raw errors; they retain only sanitized lifecycle context such as scope, safe display name, PID, exit code, and signal. Stop logging uses sanitized requested, forced, and failed stop events without process commands or raw errors. ## Development diff --git a/dist/config.d.ts b/dist/config.d.ts index 8fcdc40..e0310a7 100644 --- a/dist/config.d.ts +++ b/dist/config.d.ts @@ -1,8 +1,11 @@ export type ConfigScope = "global" | "project"; +export type OnExistingProcessPolicy = "start" | "skip" | "restart"; interface ConfiguredCommandFields { name: string; executable: string; args: string[]; + onExistingProcess: OnExistingProcessPolicy; + stopOnExit: boolean; index: number; } export type ConfiguredCommand = (ConfiguredCommandFields & { diff --git a/dist/config.d.ts.map b/dist/config.d.ts.map index 93dd969..487e862 100644 --- a/dist/config.d.ts.map +++ b/dist/config.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE/C,UAAU,uBAAuB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,iBAAiB,GACzB,CAAC,uBAAuB,GAAG;IACzB,KAAK,EAAE,QAAQ,CAAC;IAChB,WAAW,CAAC,EAAE,KAAK,CAAC;CACrB,CAAC,GACF,CAAC,uBAAuB,GAAG;IACzB,KAAK,EAAE,SAAS,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC,CAAC;AAEP,MAAM,MAAM,sBAAsB,GAC9B,kBAAkB,GAClB,cAAc,GACd,kBAAkB,GAClB,mBAAmB,GACnB,iBAAiB,CAAC;AAEtB,KAAK,2BAA2B,GAAG,OAAO,CACxC,sBAAsB,EACtB,iBAAiB,CAClB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GACxB;IACE,KAAK,EAAE,WAAW,CAAC;IACnB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GACD;IACE,KAAK,EAAE,WAAW,CAAC;IACnB,MAAM,EAAE,2BAA2B,CAAC;IACpC,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,IAAI,CAAC,EAAE,KAAK,CAAC;CACd,CAAC;AAEN,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,WAAW,EAAE,gBAAgB,EAAE,CAAC;CACjC;AAID,wBAAgB,uBAAuB,CAAC,IAAI,GAAE,MAAkB,GAAG,MAAM,CAExE;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEjE;AAiCD,wBAAgB,cAAc,CAC5B,KAAK,EAAE,QAAQ,EACf,QAAQ,EAAE,MAAM,GACf,gBAAgB,CAAC;AACpB,wBAAgB,cAAc,CAC5B,KAAK,EAAE,SAAS,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,gBAAgB,CAAC"} \ No newline at end of file +{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE/C,MAAM,MAAM,uBAAuB,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAAC;AAEnE,UAAU,uBAAuB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,iBAAiB,EAAE,uBAAuB,CAAC;IAC3C,UAAU,EAAE,OAAO,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,iBAAiB,GACzB,CAAC,uBAAuB,GAAG;IACzB,KAAK,EAAE,QAAQ,CAAC;IAChB,WAAW,CAAC,EAAE,KAAK,CAAC;CACrB,CAAC,GACF,CAAC,uBAAuB,GAAG;IACzB,KAAK,EAAE,SAAS,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC,CAAC;AAEP,MAAM,MAAM,sBAAsB,GAC9B,kBAAkB,GAClB,cAAc,GACd,kBAAkB,GAClB,mBAAmB,GACnB,iBAAiB,CAAC;AAEtB,KAAK,2BAA2B,GAAG,OAAO,CACxC,sBAAsB,EACtB,iBAAiB,CAClB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GACxB;IACE,KAAK,EAAE,WAAW,CAAC;IACnB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GACD;IACE,KAAK,EAAE,WAAW,CAAC;IACnB,MAAM,EAAE,2BAA2B,CAAC;IACpC,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,IAAI,CAAC,EAAE,KAAK,CAAC;CACd,CAAC;AAEN,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,WAAW,EAAE,gBAAgB,EAAE,CAAC;CACjC;AAID,wBAAgB,uBAAuB,CAAC,IAAI,GAAE,MAAkB,GAAG,MAAM,CAExE;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEjE;AAiCD,wBAAgB,cAAc,CAC5B,KAAK,EAAE,QAAQ,EACf,QAAQ,EAAE,MAAM,GACf,gBAAgB,CAAC;AACpB,wBAAgB,cAAc,CAC5B,KAAK,EAAE,SAAS,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,gBAAgB,CAAC"} \ No newline at end of file diff --git a/dist/config.js b/dist/config.js index 08ae88f..6f039b5 100644 --- a/dist/config.js +++ b/dist/config.js @@ -76,7 +76,13 @@ export function loadConfigFile(...args) { typeof value.executable !== "string" || !value.executable.trim() || !Array.isArray(value.args) || - !value.args.every((argument) => typeof argument === "string")) { + !value.args.every((argument) => typeof argument === "string") || + (value.onExistingProcess !== undefined && + value.onExistingProcess !== "start" && + value.onExistingProcess !== "skip" && + value.onExistingProcess !== "restart") || + (value.stopOnExit !== undefined && + typeof value.stopOnExit !== "boolean")) { diagnostics.push(invalidCommandDiagnostic(scope, index, value)); return; } @@ -84,6 +90,9 @@ export function loadConfigFile(...args) { name: value.name.trim(), executable: value.executable, args: value.args, + onExistingProcess: value.onExistingProcess ?? + "skip", + stopOnExit: value.stopOnExit ?? true, index, }; if (scope === "project") { diff --git a/dist/config.js.map b/dist/config.js.map index fe31e91..a82c0ed 100644 --- a/dist/config.js.map +++ b/dist/config.js.map @@ -1 +1 @@ -{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/core.d.ts b/dist/core.d.ts index 12159f1..7c44ab2 100644 --- a/dist/core.d.ts +++ b/dist/core.d.ts @@ -1,17 +1,47 @@ import type { ConfiguredCommand } from "./config.js"; import type { Logger } from "./logger.js"; -export interface StartupState { - started: Set; -} +import type { ProcessTreeController, ProcessTreeStopResult } from "./process-tree.js"; export interface SpawnedChild { readonly pid?: number; once(event: "error", listener: (error: Error) => void): SpawnedChild; once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): SpawnedChild; unref(): void; } +type OwnerToken = symbol; +type RetryTombstone = "spawn-failed" | "natural-exit"; +type IdentityStatus = "stable" | "restarting" | "degraded"; +type ProcessRecordStatus = "active" | "stopping"; +interface ManagedCommandContext { + scope: "global" | "project"; + index: number; + name: string; +} +interface ProcessRecord { + child: SpawnedChild; + pid?: number; + context: ManagedCommandContext; + creationOrder: number; + owners: Set; + stopOnExit: boolean; + status: ProcessRecordStatus; + stopPromise?: Promise; + rootExited: boolean; +} +interface IdentityEntry { + processKey: string; + records: ProcessRecord[]; + retryTombstone?: RetryTombstone; + cleanupUnconfirmed: boolean; + status: IdentityStatus; + transitionTail: Promise; + pendingTransitions: number; + nextCreationOrder: number; +} +export type StartupState = Map; export interface StartupSpawnOptions { cwd?: string; - detached: false; + detached: true; + shell: false; stdio: "ignore"; windowsHide: true; } @@ -19,9 +49,15 @@ export type SpawnFunction = (command: string, args: string[], options: StartupSp export interface StartupDependencies { spawn: SpawnFunction; state: StartupState; + processTree: ProcessTreeController; logger: Logger; } +export interface StartupActivation { + dispose(): 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): void; +export declare function runStartupCommands(commands: readonly ConfiguredCommand[], dependencies: StartupDependencies): Promise; +export {}; //# 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 cb130e8..aae68e3 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,EAAY,MAAM,EAAE,MAAM,aAAa,CAAC;AAMpD,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACtB;AAED,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,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,KAAK,CAAC;IAChB,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,MAAM,EAAE,MAAM,CAAC;CAChB;AA6BD,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,YAAY,CAWd;AAED,eAAO,MAAM,YAAY,cAAyC,CAAC;AA6CnE,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,EACtC,YAAY,EAAE,mBAAmB,GAChC,IAAI,CAoKN"} \ 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 0b66471..cf16c25 100644 --- a/dist/core.js +++ b/dist/core.js @@ -2,26 +2,182 @@ import { normalize, resolve } from "node:path"; const ALLOWED_ERROR_CODES = new Set(["EACCES", "ENOENT", "EPERM"]); const PROCESS_STATE_KEY = Symbol.for("opencode.startup-commands.state"); const processGlobal = globalThis; +function isRecord(value) { + return typeof value === "object" && value !== null; +} +function isNonnegativeSafeInteger(value) { + return (typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0); +} +function isOptionalPid(value) { + return (value === undefined || + (typeof value === "number" && Number.isSafeInteger(value) && value > 0)); +} +function isPromiseLike(value) { + try { + return isRecord(value) && typeof value.then === "function"; + } + catch { + return false; + } +} +function isManagedCommandContext(value) { + if (!isRecord(value)) { + return false; + } + try { + return ((value.scope === "global" || value.scope === "project") && + isNonnegativeSafeInteger(value.index) && + typeof value.name === "string"); + } + catch { + return false; + } +} +function isSpawnedChild(value) { + if (!isRecord(value)) { + return false; + } + try { + return (isOptionalPid(value.pid) && + typeof value.once === "function" && + typeof value.unref === "function"); + } + catch { + return false; + } +} +function isOwnerSet(value) { + if (!(value instanceof Set)) { + return false; + } + try { + return [...value].every((owner) => typeof owner === "symbol"); + } + catch { + return false; + } +} +function isProcessRecord(value) { + if (!isRecord(value)) { + return false; + } + try { + return (isSpawnedChild(value.child) && + 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"); + } + catch { + return false; + } +} +function isIdentityEntry(value, processKey) { + if (!isRecord(value)) { + return false; + } + try { + if (value.processKey !== processKey || + !Array.isArray(value.records) || + !value.records.every(isProcessRecord) || + (value.retryTombstone !== undefined && + value.retryTombstone !== "spawn-failed" && + value.retryTombstone !== "natural-exit") || + typeof value.cleanupUnconfirmed !== "boolean" || + (value.status !== "stable" && + value.status !== "restarting" && + value.status !== "degraded") || + !isPromiseLike(value.transitionTail) || + !isNonnegativeSafeInteger(value.pendingTransitions) || + !isNonnegativeSafeInteger(value.nextCreationOrder) || + (value.retryTombstone !== undefined && value.records.length > 0)) { + return false; + } + let previousCreationOrder = -1; + for (const record of value.records) { + if (record.creationOrder <= previousCreationOrder || + record.creationOrder >= value.nextCreationOrder) { + return false; + } + previousCreationOrder = record.creationOrder; + } + return true; + } + catch { + return false; + } +} function isStartupState(value) { - if (typeof value !== "object" || - value === null || - !("started" in value) || - !(value.started instanceof Set)) { + if (!(value instanceof Map)) { return false; } try { - return [...value.started].every((key) => typeof key === "string"); + return [...value].every(([processKey, entry]) => typeof processKey === "string" && + isIdentityEntry(entry, processKey)); } catch { return false; } } +function createIdentityEntry(processKey) { + return { + processKey, + records: [], + cleanupUnconfirmed: false, + status: "stable", + transitionTail: Promise.resolve(), + pendingTransitions: 0, + nextCreationOrder: 0, + }; +} +export function createStartupState() { + return new Map(); +} +function convertLegacyState(value) { + if (!isRecord(value)) { + return undefined; + } + let started; + try { + started = value.started; + } + catch { + return undefined; + } + if (!(started instanceof Set)) { + return undefined; + } + let processKeys; + try { + processKeys = [...started]; + } + catch { + return undefined; + } + if (!processKeys.every((processKey) => typeof processKey === "string")) { + return undefined; + } + const state = createStartupState(); + for (const processKey of processKeys) { + const entry = createIdentityEntry(processKey); + entry.cleanupUnconfirmed = true; + entry.status = "degraded"; + state.set(processKey, entry); + } + return state; +} export function getOrCreateProcessState(registry) { const existingState = registry[PROCESS_STATE_KEY]; if (isStartupState(existingState)) { return existingState; } - const state = { started: new Set() }; + const state = convertLegacyState(existingState) ?? createStartupState(); registry[PROCESS_STATE_KEY] = state; return state; } @@ -58,7 +214,248 @@ function writeLog(logger, event) { // Logging must not affect command execution or child lifecycle callbacks. } } -export function runStartupCommands(commands, dependencies) { +function pruneIdentityIfEmpty(state, entry) { + if (entry.records.length === 0 && + entry.retryTombstone === undefined && + !entry.cleanupUnconfirmed && + entry.pendingTransitions === 0 && + state.get(entry.processKey) === entry) { + state.delete(entry.processKey); + } +} +async function withIdentityTransition(state, processKey, transition) { + const entry = state.get(processKey) ?? createIdentityEntry(processKey); + if (!state.has(processKey)) { + state.set(processKey, entry); + } + entry.pendingTransitions += 1; + const previous = entry.transitionTail; + let release; + entry.transitionTail = new Promise((resolveTransition) => { + release = resolveTransition; + }); + try { + await Promise.resolve(previous).catch(() => undefined); + return await transition(entry); + } + finally { + entry.pendingTransitions -= 1; + release(); + pruneIdentityIfEmpty(state, entry); + } +} +function getValidatedPid(child) { + try { + const pid = child.pid; + return typeof pid === "number" && + Number.isSafeInteger(pid) && + pid > 0 + ? pid + : undefined; + } + catch { + return undefined; + } +} +function spawnRecord(command, owner, entry, dependencies) { + const spawnOptions = { + detached: true, + shell: false, + stdio: "ignore", + windowsHide: true, + }; + if (command.scope === "project") { + spawnOptions.cwd = command.projectRoot; + } + let child; + try { + child = dependencies.spawn(command.executable, command.args, spawnOptions); + } + catch (error) { + writeLog(dependencies.logger, { + type: "command.spawn-failed", + scope: command.scope, + index: command.index, + name: command.name, + code: getErrorCode(error), + }); + if (entry.records.length === 0) { + entry.retryTombstone = "spawn-failed"; + } + return undefined; + } + const pid = getValidatedPid(child); + const record = { + child, + pid, + context: { + scope: command.scope, + index: command.index, + name: command.name, + }, + creationOrder: entry.nextCreationOrder, + owners: new Set([owner]), + stopOnExit: command.stopOnExit, + status: "active", + rootExited: false, + }; + entry.nextCreationOrder += 1; + entry.records.push(record); + writeLog(dependencies.logger, { + type: "command.spawned", + ...record.context, + pid, + }); + try { + child.once("error", (error) => { + writeLog(dependencies.logger, { + type: "command.child-error", + ...record.context, + code: getErrorCode(error), + }); + if (record.pid === undefined) { + void withIdentityTransition(dependencies.state, entry.processKey, async (currentEntry) => { + if (!currentEntry.records.includes(record)) { + return; + } + currentEntry.records = currentEntry.records.filter((candidate) => candidate !== record); + if (currentEntry.records.length === 0) { + currentEntry.retryTombstone = "spawn-failed"; + } + }).catch(() => undefined); + } + }); + } + catch { + // A spawned child remains successful even if lifecycle hooks are unavailable. + } + try { + child.once("exit", (exitCode, signal) => { + record.rootExited = true; + writeLog(dependencies.logger, { + type: "command.exited", + ...record.context, + exitCode, + signal, + }); + void withIdentityTransition(dependencies.state, entry.processKey, async (currentEntry) => { + if (!currentEntry.records.includes(record) || + record.stopPromise !== undefined) { + return; + } + const result = await requestRecordStop(record, "root-exited", dependencies); + if (!currentEntry.records.includes(record)) { + return; + } + applyRecordStopResult(currentEntry, record, result); + if (currentEntry.records.length === 0) { + currentEntry.retryTombstone = "natural-exit"; + } + }).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; +} +function requestRecordStop(record, trigger, dependencies) { + if (record.stopPromise) { + return record.stopPromise; + } + record.status = "stopping"; + writeLog(dependencies.logger, { + type: "command.stop-requested", + ...record.context, + pid: record.pid, + trigger, + }); + let stopPromise; + try { + stopPromise = Promise.resolve(dependencies.processTree.stop(record.pid, { + isRootExited: () => record.rootExited, + onForce: () => { + writeLog(dependencies.logger, { + type: "command.stop-forced", + ...record.context, + pid: record.pid, + trigger, + }); + }, + })); + } + catch { + stopPromise = Promise.resolve({ + status: "failed", + reason: "unconfirmed", + addressability: record.rootExited ? "lost" : "safe", + }); + } + record.stopPromise = stopPromise + .catch(() => ({ + status: "failed", + reason: "unconfirmed", + addressability: record.rootExited ? "lost" : "safe", + })) + .then((result) => { + if (result.status === "failed") { + writeLog(dependencies.logger, { + type: "command.stop-failed", + ...record.context, + pid: record.pid, + trigger, + reason: result.reason, + }); + } + return result; + }); + return record.stopPromise; +} +function applyRecordStopResult(entry, record, result) { + if (result.status === "stopped") { + entry.records = entry.records.filter((candidate) => candidate !== record); + return; + } + if (record.rootExited || result.addressability === "lost") { + entry.records = entry.records.filter((candidate) => candidate !== record); + entry.cleanupUnconfirmed = true; + entry.status = "degraded"; + return; + } + 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; + }, + }; writeLog(dependencies.logger, { type: "plugin.initialized", commandCount: commands.length, @@ -68,7 +465,7 @@ export function runStartupCommands(commands, dependencies) { type: "batch.skipped", reason: "no-valid-commands", }); - return; + return activation; } const globalCommands = new Map(); const projectCommands = new Map(); @@ -104,7 +501,7 @@ export function runStartupCommands(commands, dependencies) { ...globalCommands.values(), ...uniqueProjectCommands, ]; - let commandStarted = false; + let commandAttempted = false; duplicateCommands .sort((left, right) => left.order - right.order) .forEach(({ command }) => { @@ -116,90 +513,132 @@ export function runStartupCommands(commands, dependencies) { reason: "duplicate", }); }); - uniqueCommands.forEach(({ command, signature }) => { + for (const { command, signature } of uniqueCommands) { const processKey = getProcessKey(command, signature); - if (dependencies.state.started.has(processKey)) { - writeLog(dependencies.logger, { - type: "command.skipped", - scope: command.scope, - index: command.index, - name: command.name, - reason: "already-started", - }); - return; - } - dependencies.state.started.add(processKey); - commandStarted = true; - const spawnOptions = { - detached: false, - stdio: "ignore", - windowsHide: true, - }; - if (command.scope === "project") { - spawnOptions.cwd = command.projectRoot; - } - let child; - try { - child = dependencies.spawn(command.executable, command.args, spawnOptions); - } - catch (error) { - writeLog(dependencies.logger, { - type: "command.spawn-failed", - scope: command.scope, - index: command.index, - name: command.name, - code: getErrorCode(error), - }); - return; - } - writeLog(dependencies.logger, { - type: "command.spawned", - scope: command.scope, - index: command.index, - name: command.name, - pid: child.pid, - }); - try { - child.once("error", (error) => { + 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 true; + } + if (command.onExistingProcess === "start") { + const record = spawnRecord(command, owner, entry, dependencies); + if (record) { + claimedKeys.add(processKey); + } + return true; + } + 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.child-error", + type: "command.skipped", scope: command.scope, index: command.index, name: command.name, - code: getErrorCode(error), + reason: "already-started", }); - }); - } - catch { - // A spawned child remains successful even if lifecycle hooks are unavailable. - } - try { - child.once("exit", (exitCode, signal) => { + return false; + } + if (entry.cleanupUnconfirmed) { + entry.status = "degraded"; writeLog(dependencies.logger, { - type: "command.exited", + type: "command.stop-failed", scope: command.scope, index: command.index, name: command.name, - exitCode, - signal, + pid: undefined, + trigger: "restart", + reason: "unconfirmed", }); + return true; + } + 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); + } + return; + } + stopFailed = true; + if (record.rootExited || result.addressability === "lost") { + entry.cleanupUnconfirmed = true; + for (const recordOwner of owners) { + transferredOwners.add(recordOwner); + } + return; + } + record.owners.clear(); + for (const recordOwner of owners) { + record.owners.add(recordOwner); + } + record.status = "active"; + record.stopPromise = undefined; + safeSurvivors.push(record); }); - } - 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. - } - }); - if (!commandStarted) { + 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; + }); + commandAttempted ||= attempted; + } + if (!commandAttempted) { writeLog(dependencies.logger, { type: "batch.skipped", reason: "already-started", }); } + return activation; } //# sourceMappingURL=core.js.map \ No newline at end of file diff --git a/dist/core.js.map b/dist/core.js.map index 3494acf..c758ed6 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":"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"} \ 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/logger.d.ts b/dist/logger.d.ts index f397c82..9611dfc 100644 --- a/dist/logger.d.ts +++ b/dist/logger.d.ts @@ -1,5 +1,14 @@ +import type { ProcessTreeFailureReason } from "./process-tree.js"; type LogErrorCode = "EACCES" | "ENOENT" | "EPERM"; type LogScope = "global" | "project"; +export type CommandStopTrigger = "scope-disposed" | "root-exited" | "restart"; +interface CommandStopContext { + scope: LogScope; + index: number; + name: string; + pid?: number; + trigger: CommandStopTrigger; +} export type LogEvent = { type: "plugin.initialized"; commandCount: number; @@ -46,7 +55,14 @@ export type LogEvent = { name: string; exitCode: number | null; signal: NodeJS.Signals | null; -}; +} | ({ + type: "command.stop-requested"; +} & CommandStopContext) | ({ + type: "command.stop-forced"; +} & CommandStopContext) | ({ + type: "command.stop-failed"; + reason: ProcessTreeFailureReason; +} & CommandStopContext); export interface Logger { write(event: LogEvent): void; } diff --git a/dist/logger.d.ts.map b/dist/logger.d.ts.map index 0cee9d8..aa3a1d9 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":"AAUA,KAAK,YAAY,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAClD,KAAK,QAAQ,GAAG,QAAQ,GAAG,SAAS,CAAC;AAErC,MAAM,MAAM,QAAQ,GAChB;IAAE,IAAI,EAAE,oBAAoB,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACpD;IACE,IAAI,EAAE,uBAAuB,CAAC;IAC9B,KAAK,EAAE,QAAQ,CAAC;IAChB,MAAM,EACF,kBAAkB,GAClB,cAAc,GACd,kBAAkB,GAClB,mBAAmB,CAAC;CACzB,GACD;IACE,IAAI,EAAE,iBAAiB,CAAC;IACxB,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GACD;IACE,IAAI,EAAE,eAAe,CAAC;IACtB,MAAM,EAAE,iBAAiB,GAAG,mBAAmB,CAAC;CACjD,GACD;IACE,IAAI,EAAE,iBAAiB,CAAC;IACxB,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,WAAW,GAAG,iBAAiB,CAAC;CACzC,GACD;IACE,IAAI,EAAE,iBAAiB,CAAC;IACxB,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GACD;IACE,IAAI,EAAE,sBAAsB,CAAC;IAC7B,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,YAAY,CAAC;CACrB,GACD;IACE,IAAI,EAAE,qBAAqB,CAAC;IAC5B,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,YAAY,CAAC;CACrB,GACD;IACE,IAAI,EAAE,gBAAgB,CAAC;IACvB,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;CAC/B,CAAC;AAEN,MAAM,WAAW,MAAM;IACrB,KAAK,CAAC,KAAK,EAAE,QAAQ,GAAG,IAAI,CAAC;CAC9B;AAED,UAAU,aAAa;IACrB,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,UAAU,aAAa;IACrB,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAiJD,wBAAgB,YAAY,CAAC,OAAO,GAAE,aAAkB,GAAG,MAAM,CAgBhE"} \ 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 998cac6..cb43818 100644 --- a/dist/logger.js +++ b/dist/logger.js @@ -15,6 +15,9 @@ function formatName(name) { function formatCode(code) { return code ? ` code=${code}` : ""; } +function formatStopContext(event) { + return `scope=${event.scope} index=${event.index} name=${formatName(event.name)}${event.pid === undefined ? "" : ` pid=${event.pid}`} trigger=${event.trigger}`; +} function assertNeverEvent(event) { throw new Error("Unhandled log event"); } @@ -38,6 +41,12 @@ function formatConsoleEvent(event) { return `child error scope=${event.scope} index=${event.index} name=${formatName(event.name)}${formatCode(event.code)}`; case "command.exited": return `exited scope=${event.scope} index=${event.index} name=${formatName(event.name)} exitCode=${event.exitCode === null ? "null" : event.exitCode} signal=${event.signal ?? "null"}`; + case "command.stop-requested": + return `stop requested ${formatStopContext(event)}`; + case "command.stop-forced": + return `stop forced ${formatStopContext(event)}`; + case "command.stop-failed": + return `stop failed ${formatStopContext(event)} reason=${event.reason}`; default: return assertNeverEvent(event); } @@ -61,6 +70,11 @@ function formatFileEvent(event) { return `${event.type} scope=${event.scope} index=${event.index} name=${formatName(event.name)}${formatCode(event.code)}`; case "command.exited": return `${event.type} scope=${event.scope} index=${event.index} name=${formatName(event.name)} exitCode=${event.exitCode === null ? "null" : event.exitCode} signal=${event.signal ?? "null"}`; + case "command.stop-requested": + case "command.stop-forced": + return `${event.type} ${formatStopContext(event)}`; + case "command.stop-failed": + return `${event.type} ${formatStopContext(event)} reason=${event.reason}`; default: return assertNeverEvent(event); } diff --git a/dist/logger.js.map b/dist/logger.js.map index e787872..eae9fb2 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-tree.d.ts b/dist/process-tree.d.ts new file mode 100644 index 0000000..c7680dd --- /dev/null +++ b/dist/process-tree.d.ts @@ -0,0 +1,41 @@ +export type ProcessTreeFailureReason = "missing-pid" | "permission-denied" | "facility-unavailable" | "unconfirmed"; +export type ProcessTreeAddressability = "safe" | "lost"; +export type ProcessTreeStopResult = { + status: "stopped"; +} | { + status: "failed"; + reason: ProcessTreeFailureReason; + addressability: ProcessTreeAddressability; +}; +export interface ProcessTreeStopOptions { + isRootExited(): boolean; + onForce(): void; +} +export interface ProcessTreeController { + stop(pid: number | undefined, options: ProcessTreeStopOptions): Promise; +} +export interface ProcessUtilityChild { + once(event: "error", listener: (error: Error) => void): ProcessUtilityChild; + once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): ProcessUtilityChild; + kill(signal?: NodeJS.Signals | number): boolean; + unref(): void; +} +export interface ProcessUtilitySpawnOptions { + detached: false; + shell: false; + stdio: "ignore"; + windowsHide: true; +} +export type ProcessUtilitySpawn = (executable: string, args: string[], options: ProcessUtilitySpawnOptions) => ProcessUtilityChild; +export interface ProcessTreeControllerOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + gracePeriodMs?: number; + pollIntervalMs?: number; + utilityTimeoutMs?: number; + kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean; + spawn?: ProcessUtilitySpawn; + delay?: (milliseconds: number) => Promise; +} +export declare function createProcessTreeController(options?: ProcessTreeControllerOptions): ProcessTreeController; +//# sourceMappingURL=process-tree.d.ts.map \ No newline at end of file diff --git a/dist/process-tree.d.ts.map b/dist/process-tree.d.ts.map new file mode 100644 index 0000000..5aa4203 --- /dev/null +++ b/dist/process-tree.d.ts.map @@ -0,0 +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 diff --git a/dist/process-tree.js b/dist/process-tree.js new file mode 100644 index 0000000..4ebb5fd --- /dev/null +++ b/dist/process-tree.js @@ -0,0 +1,327 @@ +import { spawn as spawnProcess } from "node:child_process"; +import { win32 } from "node:path"; +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) + ? { status: "sent" } + : { status: "failed", reason: "unconfirmed" }; + } + catch (error) { + const code = getErrorCode(error); + if (code === "ESRCH") { + return { status: "absent" }; + } + if (code === "EPERM" || code === "EACCES") { + return { status: "failed", reason: "permission-denied" }; + } + return { status: "failed", reason: "unconfirmed" }; + } +} +function failure(reason, options) { + let rootExited = true; + try { + rootExited = options.isRootExited(); + } + catch { } + return { + status: "failed", + reason, + addressability: rootExited ? "lost" : "safe", + }; +} +function rootExited(options) { + try { + return options.isRootExited(); + } + catch { + return true; + } +} +function lostFailure() { + return { + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }; +} +function positiveFiniteOrDefault(value, fallback) { + return value !== undefined && Number.isFinite(value) && value > 0 + ? value + : fallback; +} +function nonnegativeFiniteOrDefault(value, fallback) { + return value !== undefined && Number.isFinite(value) && value >= 0 + ? value + : fallback; +} +async function stopPosixProcessGroup(pid, options, dependencies) { + const groupPid = -pid; + const initialProbe = signalProcess(dependencies.kill, groupPid, 0); + if (initialProbe.status === "absent") { + return { status: "stopped" }; + } + if (initialProbe.status === "failed") { + return failure(initialProbe.reason, options); + } + const gracefulSignal = signalProcess(dependencies.kill, groupPid, "SIGTERM"); + if (gracefulSignal.status === "absent") { + return { status: "stopped" }; + } + if (gracefulSignal.status === "failed") { + return failure(gracefulSignal.reason, options); + } + const graceProbeCount = Math.max(1, Math.ceil(dependencies.gracePeriodMs / dependencies.pollIntervalMs)); + for (let index = 0; index < graceProbeCount; index += 1) { + const remainingGrace = Math.max(0, dependencies.gracePeriodMs - index * dependencies.pollIntervalMs); + try { + await dependencies.delay(Math.min(dependencies.pollIntervalMs, remainingGrace)); + } + catch { + return failure("unconfirmed", options); + } + const probe = signalProcess(dependencies.kill, groupPid, 0); + if (probe.status === "absent") { + return { status: "stopped" }; + } + if (probe.status === "failed") { + return failure(probe.reason, options); + } + } + try { + options.onForce(); + } + catch { } + const forceSignal = signalProcess(dependencies.kill, groupPid, "SIGKILL"); + try { + await dependencies.delay(dependencies.pollIntervalMs); + } + catch { + return failure(forceSignal.status === "failed" ? forceSignal.reason : "unconfirmed", options); + } + const finalProbe = signalProcess(dependencies.kill, groupPid, 0); + if (finalProbe.status === "absent") { + return { status: "stopped" }; + } + if (finalProbe.status === "failed") { + return failure(finalProbe.reason, options); + } + return failure(forceSignal.status === "failed" ? forceSignal.reason : "unconfirmed", options); +} +function resolveTaskkillPath(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", "taskkill.exe"); +} +function utilityFailureReason(error) { + const code = getErrorCode(error); + if (code === "EPERM" || code === "EACCES") { + return "permission-denied"; + } + if (code === "ENOENT") { + return "facility-unavailable"; + } + return "unconfirmed"; +} +function runUtility(spawn, executable, args, timeoutMs) { + let child; + try { + child = spawn(executable, args, { + detached: false, + shell: false, + stdio: "ignore", + windowsHide: true, + }); + } + catch (error) { + return Promise.resolve({ + status: "failed", + reason: utilityFailureReason(error), + timedOut: false, + }); + } + return new Promise((resolve) => { + let settled = false; + let timer; + const settle = (result) => { + if (settled) { + return; + } + settled = true; + if (timer !== undefined) { + clearTimeout(timer); + } + resolve(result); + }; + try { + child.once("error", (error) => { + settle({ + status: "failed", + reason: utilityFailureReason(error), + timedOut: false, + }); + }); + child.once("exit", (code) => { + settle(code === 0 + ? { status: "stopped" } + : { + status: "failed", + reason: "unconfirmed", + timedOut: false, + }); + }); + } + catch (error) { + settle({ + status: "failed", + reason: utilityFailureReason(error), + timedOut: false, + }); + } + if (!settled) { + timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + try { + child.kill("SIGKILL"); + } + catch { } + try { + child.unref(); + } + catch { } + resolve({ status: "failed", reason: "unconfirmed", timedOut: true }); + }, timeoutMs); + } + }); +} +function probeWindowsRoot(pid, options, kill) { + if (rootExited(options)) { + return { status: "lost" }; + } + const probe = signalProcess(kill, pid, 0); + if (probe.status === "absent") { + return { status: "lost" }; + } + if (probe.status === "failed") { + return probe; + } + return { status: "present" }; +} +function rootProbeFailure(probe, options) { + return probe.status === "lost" ? lostFailure() : failure(probe.reason, options); +} +async function stopWindowsProcessTree(pid, options, dependencies) { + if (rootExited(options)) { + return lostFailure(); + } + const taskkillPath = resolveTaskkillPath(dependencies.env); + if (!taskkillPath) { + return failure("facility-unavailable", options); + } + const gracefulResult = await runUtility(dependencies.spawn, taskkillPath, [ + "/PID", + String(pid), + "/T", + ], dependencies.utilityTimeoutMs); + if (gracefulResult.status === "stopped") { + return gracefulResult; + } + if (gracefulResult.reason === "facility-unavailable") { + return failure(gracefulResult.reason, options); + } + let rootProbe = probeWindowsRoot(pid, options, dependencies.kill); + if (rootProbe.status !== "present") { + return rootProbeFailure(rootProbe, options); + } + if (!gracefulResult.timedOut) { + try { + await dependencies.delay(dependencies.gracePeriodMs); + } + catch { + return failure("unconfirmed", options); + } + } + rootProbe = probeWindowsRoot(pid, options, dependencies.kill); + if (rootProbe.status !== "present") { + return rootProbeFailure(rootProbe, options); + } + try { + options.onForce(); + } + catch { } + if (rootExited(options)) { + return lostFailure(); + } + const forcedResult = await runUtility(dependencies.spawn, taskkillPath, [ + "/PID", + String(pid), + "/T", + "/F", + ], dependencies.utilityTimeoutMs); + if (forcedResult.status === "stopped") { + return forcedResult; + } + rootProbe = probeWindowsRoot(pid, options, dependencies.kill); + if (rootProbe.status !== "present") { + return rootProbeFailure(rootProbe, options); + } + return failure(forcedResult.reason, options); +} +export function createProcessTreeController(options) { + const platform = options?.platform ?? process.platform; + const gracePeriodMs = nonnegativeFiniteOrDefault(options?.gracePeriodMs, DEFAULT_GRACE_PERIOD_MS); + const pollIntervalMs = positiveFiniteOrDefault(options?.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS); + const utilityTimeoutMs = nonnegativeFiniteOrDefault(options?.utilityTimeoutMs, DEFAULT_UTILITY_TIMEOUT_MS); + const kill = options?.kill ?? process.kill; + const env = options?.env ?? process.env; + const spawn = options?.spawn ?? + ((executable, args, spawnOptions) => spawnProcess(executable, args, spawnOptions)); + const delay = options?.delay ?? + ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + return { + async stop(pid, stopOptions) { + if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) { + return { + status: "failed", + reason: "missing-pid", + addressability: "lost", + }; + } + if (platform !== "win32") { + return stopPosixProcessGroup(pid, stopOptions, { + gracePeriodMs, + pollIntervalMs, + kill, + delay, + }); + } + return stopWindowsProcessTree(pid, stopOptions, { + env, + gracePeriodMs, + utilityTimeoutMs, + kill, + spawn, + delay, + }); + }, + }; +} +//# sourceMappingURL=process-tree.js.map \ No newline at end of file diff --git a/dist/process-tree.js.map b/dist/process-tree.js.map new file mode 100644 index 0000000..5a85369 --- /dev/null +++ b/dist/process-tree.js.map @@ -0,0 +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 diff --git a/dist/server-internal.d.ts b/dist/server-internal.d.ts index c8ca0a4..0a16f1c 100644 --- a/dist/server-internal.d.ts +++ b/dist/server-internal.d.ts @@ -1,14 +1,10 @@ import type { PluginModule } from "@opencode-ai/plugin"; import type { loadConfigFile, resolveGlobalConfigPath, resolveProjectConfigPath } from "./config.js"; -import { type SpawnFunction, type StartupState } from "./core.js"; -import type { Logger } from "./logger.js"; -export interface StartupCommandsServerDependencies { +import { type StartupDependencies } from "./core.js"; +export interface StartupCommandsServerDependencies extends StartupDependencies { loadConfigFile: typeof loadConfigFile; resolveGlobalConfigPath: typeof resolveGlobalConfigPath; resolveProjectConfigPath: typeof resolveProjectConfigPath; - spawn: SpawnFunction; - state: StartupState; - logger: Logger; } 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 7a35b81..d988d22 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,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,WAAW,CAAC;AACnB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,MAAM,WAAW,iCAAiC;IAChD,cAAc,EAAE,OAAO,cAAc,CAAC;IACtC,uBAAuB,EAAE,OAAO,uBAAuB,CAAC;IACxD,wBAAwB,EAAE,OAAO,wBAAwB,CAAC;IAC1D,KAAK,EAAE,aAAa,CAAC;IACrB,KAAK,EAAE,YAAY,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;CAChB;AA2BD,wBAAgB,2BAA2B,CACzC,YAAY,EAAE,iCAAiC,GAC9C,YAAY,CA6Bd"} \ 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,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 diff --git a/dist/server-internal.js b/dist/server-internal.js index 1aed2c2..bb3572d 100644 --- a/dist/server-internal.js +++ b/dist/server-internal.js @@ -32,8 +32,10 @@ export function createStartupCommandsServer(dependencies) { ]) { writeConfigDiagnostic(diagnostic, dependencies.logger); } - runStartupCommands([...globalConfig.commands, ...projectConfig.commands], dependencies); - return {}; + const activation = await runStartupCommands([...globalConfig.commands, ...projectConfig.commands], dependencies); + return { + dispose: () => activation.dispose(), + }; }, }; } diff --git a/dist/server-internal.js.map b/dist/server-internal.js.map index 2bb8585..923d22d 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,GAGnB,MAAM,WAAW,CAAC;AAYnB,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,kBAAkB,CAChB,CAAC,GAAG,YAAY,CAAC,QAAQ,EAAE,GAAG,aAAa,CAAC,QAAQ,CAAC,EACrD,YAAY,CACb,CAAC;YAEF,OAAO,EAAE,CAAC;QACZ,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,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 diff --git a/dist/server.d.ts.map b/dist/server.d.ts.map index 5f07459..70d33cb 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":"AAUA,QAAA,MAAM,qBAAqB,4CAOzB,CAAC;AAEH,eAAe,qBAAqB,CAAC"} \ No newline at end of file +{"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 diff --git a/dist/server.js b/dist/server.js index 398ecfb..cfbbd04 100644 --- a/dist/server.js +++ b/dist/server.js @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { loadConfigFile, resolveGlobalConfigPath, resolveProjectConfigPath, } from "./config.js"; import { processState } from "./core.js"; import { createLogger } from "./logger.js"; +import { createProcessTreeController } from "./process-tree.js"; import { createStartupCommandsServer } from "./server-internal.js"; const startupCommandsServer = createStartupCommandsServer({ loadConfigFile, @@ -9,6 +10,7 @@ const startupCommandsServer = createStartupCommandsServer({ resolveProjectConfigPath, spawn, state: processState, + processTree: createProcessTreeController(), logger: createLogger(), }); export default startupCommandsServer; diff --git a/dist/server.js.map b/dist/server.js.map index 403fd87..e1f5c8b 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,sBAAsB,CAAC;AAEnE,MAAM,qBAAqB,GAAG,2BAA2B,CAAC;IACxD,cAAc;IACd,uBAAuB;IACvB,wBAAwB;IACxB,KAAK;IACL,KAAK,EAAE,YAAY;IACnB,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,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 diff --git a/package.json b/package.json index cec247c..07d6c20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-startup-commands", - "version": "1.0.0", + "version": "1.1.0", "description": "Launch trusted background commands when OpenCode initializes a project or directory.", "license": "MIT", "author": { diff --git a/scripts/release-package.mjs b/scripts/release-package.mjs index ed6977d..413db2c 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.0.0"; +const PACKAGE_VERSION = "1.1.0"; const PACKAGE_FILENAME = `${PACKAGE_NAME}-${PACKAGE_VERSION}.tgz`; const MAX_COMPRESSED_SIZE = 2 * 1024 * 1024; const MAX_UNPACKED_SIZE = 5 * 1024 * 1024; @@ -31,6 +31,8 @@ const REQUIRED_PACKAGE_PATHS = [ "dist/core.d.ts", "dist/logger.js", "dist/logger.d.ts", + "dist/process-tree.d.ts", + "dist/process-tree.js", "dist/server.js", "dist/server.d.ts", "dist/server-internal.js", diff --git a/src/config.ts b/src/config.ts index ec942d9..d389c07 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,10 +4,14 @@ import { join } from "node:path"; export type ConfigScope = "global" | "project"; +export type OnExistingProcessPolicy = "start" | "skip" | "restart"; + interface ConfiguredCommandFields { name: string; executable: string; args: string[]; + onExistingProcess: OnExistingProcessPolicy; + stopOnExit: boolean; index: number; } @@ -160,7 +164,13 @@ export function loadConfigFile( typeof value.executable !== "string" || !value.executable.trim() || !Array.isArray(value.args) || - !value.args.every((argument) => typeof argument === "string") + !value.args.every((argument) => typeof argument === "string") || + (value.onExistingProcess !== undefined && + value.onExistingProcess !== "start" && + value.onExistingProcess !== "skip" && + value.onExistingProcess !== "restart") || + (value.stopOnExit !== undefined && + typeof value.stopOnExit !== "boolean") ) { diagnostics.push(invalidCommandDiagnostic(scope, index, value)); return; @@ -170,6 +180,10 @@ export function loadConfigFile( name: value.name.trim(), executable: value.executable, args: value.args, + onExistingProcess: + (value.onExistingProcess as OnExistingProcessPolicy | undefined) ?? + "skip", + stopOnExit: value.stopOnExit ?? true, index, }; diff --git a/src/core.ts b/src/core.ts index 599e6be..3877d30 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,15 +1,19 @@ import { normalize, resolve } from "node:path"; import type { ConfiguredCommand } from "./config.js"; -import type { LogEvent, Logger } from "./logger.js"; +import type { + CommandStopTrigger, + LogEvent, + Logger, +} from "./logger.js"; +import type { + ProcessTreeController, + ProcessTreeStopResult, +} from "./process-tree.js"; type LogErrorCode = NonNullable< Extract["code"] >; -export interface StartupState { - started: Set; -} - export interface SpawnedChild { readonly pid?: number; once(event: "error", listener: (error: Error) => void): SpawnedChild; @@ -23,9 +27,46 @@ export interface SpawnedChild { unref(): void; } +type OwnerToken = symbol; +type RetryTombstone = "spawn-failed" | "natural-exit"; +type IdentityStatus = "stable" | "restarting" | "degraded"; +type ProcessRecordStatus = "active" | "stopping"; + +interface ManagedCommandContext { + scope: "global" | "project"; + index: number; + name: string; +} + +interface ProcessRecord { + child: SpawnedChild; + pid?: number; + context: ManagedCommandContext; + creationOrder: number; + owners: Set; + stopOnExit: boolean; + status: ProcessRecordStatus; + stopPromise?: Promise; + rootExited: boolean; +} + +interface IdentityEntry { + processKey: string; + records: ProcessRecord[]; + retryTombstone?: RetryTombstone; + cleanupUnconfirmed: boolean; + status: IdentityStatus; + transitionTail: Promise; + pendingTransitions: number; + nextCreationOrder: number; +} + +export type StartupState = Map; + export interface StartupSpawnOptions { cwd?: string; - detached: false; + detached: true; + shell: false; stdio: "ignore"; windowsHide: true; } @@ -39,9 +80,14 @@ export type SpawnFunction = ( export interface StartupDependencies { spawn: SpawnFunction; state: StartupState; + processTree: ProcessTreeController; logger: Logger; } +export interface StartupActivation { + dispose(): Promise; +} + interface IndexedCommand { command: ConfiguredCommand; order: number; @@ -52,23 +98,213 @@ const ALLOWED_ERROR_CODES = new Set(["EACCES", "ENOENT", "EPERM"]); const PROCESS_STATE_KEY = Symbol.for("opencode.startup-commands.state"); const processGlobal = globalThis as unknown as Record; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isNonnegativeSafeInteger(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0 + ); +} + +function isOptionalPid(value: unknown): value is number | undefined { + return ( + value === undefined || + (typeof value === "number" && Number.isSafeInteger(value) && value > 0) + ); +} + +function isPromiseLike(value: unknown): value is PromiseLike { + try { + return isRecord(value) && typeof value.then === "function"; + } catch { + return false; + } +} + +function isManagedCommandContext( + value: unknown, +): value is ManagedCommandContext { + if (!isRecord(value)) { + return false; + } + + try { + return ( + (value.scope === "global" || value.scope === "project") && + isNonnegativeSafeInteger(value.index) && + typeof value.name === "string" + ); + } catch { + return false; + } +} + +function isSpawnedChild(value: unknown): value is SpawnedChild { + if (!isRecord(value)) { + return false; + } + + try { + return ( + isOptionalPid(value.pid) && + typeof value.once === "function" && + typeof value.unref === "function" + ); + } catch { + return false; + } +} + +function isOwnerSet(value: unknown): value is Set { + if (!(value instanceof Set)) { + return false; + } + + try { + return [...value].every((owner) => typeof owner === "symbol"); + } catch { + return false; + } +} + +function isProcessRecord(value: unknown): value is ProcessRecord { + if (!isRecord(value)) { + return false; + } + + try { + return ( + isSpawnedChild(value.child) && + 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" + ); + } catch { + return false; + } +} + +function isIdentityEntry( + value: unknown, + processKey: string, +): value is IdentityEntry { + if (!isRecord(value)) { + return false; + } + + try { + if ( + value.processKey !== processKey || + !Array.isArray(value.records) || + !value.records.every(isProcessRecord) || + (value.retryTombstone !== undefined && + value.retryTombstone !== "spawn-failed" && + value.retryTombstone !== "natural-exit") || + typeof value.cleanupUnconfirmed !== "boolean" || + (value.status !== "stable" && + value.status !== "restarting" && + value.status !== "degraded") || + !isPromiseLike(value.transitionTail) || + !isNonnegativeSafeInteger(value.pendingTransitions) || + !isNonnegativeSafeInteger(value.nextCreationOrder) || + (value.retryTombstone !== undefined && value.records.length > 0) + ) { + return false; + } + + let previousCreationOrder = -1; + for (const record of value.records) { + if ( + record.creationOrder <= previousCreationOrder || + record.creationOrder >= value.nextCreationOrder + ) { + return false; + } + previousCreationOrder = record.creationOrder; + } + + return true; + } catch { + return false; + } +} + function isStartupState(value: unknown): value is StartupState { - if ( - typeof value !== "object" || - value === null || - !("started" in value) || - !(value.started instanceof Set) - ) { + if (!(value instanceof Map)) { return false; } try { - return [...value.started].every((key) => typeof key === "string"); + return [...value].every( + ([processKey, entry]) => + typeof processKey === "string" && + isIdentityEntry(entry, processKey), + ); } catch { return false; } } +function createIdentityEntry(processKey: string): IdentityEntry { + return { + processKey, + records: [], + cleanupUnconfirmed: false, + status: "stable", + transitionTail: Promise.resolve(), + pendingTransitions: 0, + nextCreationOrder: 0, + }; +} + +export function createStartupState(): StartupState { + return new Map(); +} + +function convertLegacyState(value: unknown): StartupState | undefined { + if (!isRecord(value)) { + return undefined; + } + + let started: unknown; + try { + started = value.started; + } catch { + return undefined; + } + if (!(started instanceof Set)) { + return undefined; + } + + let processKeys: unknown[]; + try { + processKeys = [...started]; + } catch { + return undefined; + } + if (!processKeys.every((processKey) => typeof processKey === "string")) { + return undefined; + } + + const state = createStartupState(); + for (const processKey of processKeys as string[]) { + const entry = createIdentityEntry(processKey); + entry.cleanupUnconfirmed = true; + entry.status = "degraded"; + state.set(processKey, entry); + } + return state; +} + export function getOrCreateProcessState( registry: Record, ): StartupState { @@ -78,7 +314,7 @@ export function getOrCreateProcessState( return existingState; } - const state: StartupState = { started: new Set() }; + const state = convertLegacyState(existingState) ?? createStartupState(); registry[PROCESS_STATE_KEY] = state; return state; @@ -129,10 +365,336 @@ function writeLog(logger: Logger, event: LogEvent): void { } } -export function runStartupCommands( - commands: readonly ConfiguredCommand[], +function pruneIdentityIfEmpty( + state: StartupState, + entry: IdentityEntry, +): void { + if ( + entry.records.length === 0 && + entry.retryTombstone === undefined && + !entry.cleanupUnconfirmed && + entry.pendingTransitions === 0 && + state.get(entry.processKey) === entry + ) { + state.delete(entry.processKey); + } +} + +async function withIdentityTransition( + state: StartupState, + processKey: string, + transition: (entry: IdentityEntry) => Promise, +): Promise { + const entry = state.get(processKey) ?? createIdentityEntry(processKey); + if (!state.has(processKey)) { + state.set(processKey, entry); + } + + entry.pendingTransitions += 1; + const previous = entry.transitionTail; + let release!: () => void; + entry.transitionTail = new Promise((resolveTransition) => { + release = resolveTransition; + }); + + try { + await Promise.resolve(previous).catch(() => undefined); + return await transition(entry); + } finally { + entry.pendingTransitions -= 1; + release(); + pruneIdentityIfEmpty(state, entry); + } +} + +function getValidatedPid(child: SpawnedChild): number | undefined { + try { + const pid = child.pid; + return typeof pid === "number" && + Number.isSafeInteger(pid) && + pid > 0 + ? pid + : undefined; + } catch { + return undefined; + } +} + +function spawnRecord( + command: ConfiguredCommand, + owner: OwnerToken, + entry: IdentityEntry, + dependencies: StartupDependencies, +): ProcessRecord | undefined { + const spawnOptions: StartupSpawnOptions = { + detached: true, + shell: false, + stdio: "ignore", + windowsHide: true, + }; + + if (command.scope === "project") { + spawnOptions.cwd = command.projectRoot; + } + + let child: SpawnedChild; + try { + child = dependencies.spawn(command.executable, command.args, spawnOptions); + } catch (error) { + writeLog(dependencies.logger, { + type: "command.spawn-failed", + scope: command.scope, + index: command.index, + name: command.name, + code: getErrorCode(error), + }); + if (entry.records.length === 0) { + entry.retryTombstone = "spawn-failed"; + } + return undefined; + } + + const pid = getValidatedPid(child); + const record: ProcessRecord = { + child, + pid, + context: { + scope: command.scope, + index: command.index, + name: command.name, + }, + creationOrder: entry.nextCreationOrder, + owners: new Set([owner]), + stopOnExit: command.stopOnExit, + status: "active", + rootExited: false, + }; + entry.nextCreationOrder += 1; + entry.records.push(record); + + writeLog(dependencies.logger, { + type: "command.spawned", + ...record.context, + pid, + }); + + try { + child.once("error", (error) => { + writeLog(dependencies.logger, { + type: "command.child-error", + ...record.context, + code: getErrorCode(error), + }); + + if (record.pid === undefined) { + void withIdentityTransition( + dependencies.state, + entry.processKey, + async (currentEntry) => { + if (!currentEntry.records.includes(record)) { + return; + } + + currentEntry.records = currentEntry.records.filter( + (candidate) => candidate !== record, + ); + if (currentEntry.records.length === 0) { + currentEntry.retryTombstone = "spawn-failed"; + } + }, + ).catch(() => undefined); + } + }); + } catch { + // A spawned child remains successful even if lifecycle hooks are unavailable. + } + + try { + child.once("exit", (exitCode, signal) => { + record.rootExited = true; + writeLog(dependencies.logger, { + type: "command.exited", + ...record.context, + exitCode, + signal, + }); + + void withIdentityTransition( + dependencies.state, + entry.processKey, + async (currentEntry) => { + if ( + !currentEntry.records.includes(record) || + record.stopPromise !== undefined + ) { + return; + } + + const result = await requestRecordStop( + record, + "root-exited", + dependencies, + ); + if (!currentEntry.records.includes(record)) { + return; + } + + applyRecordStopResult(currentEntry, record, result); + if (currentEntry.records.length === 0) { + currentEntry.retryTombstone = "natural-exit"; + } + }, + ).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; +} + +function requestRecordStop( + record: ProcessRecord, + trigger: CommandStopTrigger, dependencies: StartupDependencies, +): Promise { + if (record.stopPromise) { + return record.stopPromise; + } + + record.status = "stopping"; + writeLog(dependencies.logger, { + type: "command.stop-requested", + ...record.context, + pid: record.pid, + trigger, + }); + + let stopPromise: Promise; + try { + stopPromise = Promise.resolve( + dependencies.processTree.stop(record.pid, { + isRootExited: () => record.rootExited, + onForce: () => { + writeLog(dependencies.logger, { + type: "command.stop-forced", + ...record.context, + pid: record.pid, + trigger, + }); + }, + }), + ); + } catch { + stopPromise = Promise.resolve({ + status: "failed", + reason: "unconfirmed", + addressability: record.rootExited ? "lost" : "safe", + }); + } + + record.stopPromise = stopPromise + .catch( + (): ProcessTreeStopResult => ({ + status: "failed", + reason: "unconfirmed", + addressability: record.rootExited ? "lost" : "safe", + }), + ) + .then((result) => { + if (result.status === "failed") { + writeLog(dependencies.logger, { + type: "command.stop-failed", + ...record.context, + pid: record.pid, + trigger, + reason: result.reason, + }); + } + return result; + }); + return record.stopPromise; +} + +function applyRecordStopResult( + entry: IdentityEntry, + record: ProcessRecord, + result: ProcessTreeStopResult, ): void { + if (result.status === "stopped") { + entry.records = entry.records.filter((candidate) => candidate !== record); + return; + } + + if (record.rootExited || result.addressability === "lost") { + entry.records = entry.records.filter((candidate) => candidate !== record); + entry.cleanupUnconfirmed = true; + entry.status = "degraded"; + return; + } + + record.status = "active"; + record.stopPromise = undefined; +} + +export async function runStartupCommands( + commands: readonly ConfiguredCommand[], + dependencies: StartupDependencies, +): Promise { + const owner = Symbol("startup-command-owner"); + const claimedKeys = new Set(); + let disposePromise: Promise | undefined; + const activation: StartupActivation = { + dispose(): Promise { + disposePromise ??= Promise.all( + [...claimedKeys].map((processKey) => + withIdentityTransition( + dependencies.state, + processKey, + async (entry) => { + const recordsToStop: ProcessRecord[] = []; + 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; + }, + }; + writeLog(dependencies.logger, { type: "plugin.initialized", commandCount: commands.length, @@ -143,7 +705,7 @@ export function runStartupCommands( type: "batch.skipped", reason: "no-valid-commands", }); - return; + return activation; } const globalCommands = new Map(); @@ -164,7 +726,6 @@ export function runStartupCommands( } const projectKey = getProcessKey(command, signature); - if (projectCommands.has(projectKey)) { duplicateCommands.push(indexedCommand); } else { @@ -178,7 +739,6 @@ export function runStartupCommands( duplicateCommands.push(indexedCommand); return false; } - return true; }, ); @@ -186,7 +746,7 @@ export function runStartupCommands( ...globalCommands.values(), ...uniqueProjectCommands, ]; - let commandStarted = false; + let commandAttempted = false; duplicateCommands .sort((left, right) => left.order - right.order) @@ -200,100 +760,160 @@ export function runStartupCommands( }); }); - uniqueCommands.forEach(({ command, signature }) => { + for (const { command, signature } of uniqueCommands) { const processKey = getProcessKey(command, signature); - - if (dependencies.state.started.has(processKey)) { - writeLog(dependencies.logger, { - type: "command.skipped", - scope: command.scope, - index: command.index, - name: command.name, - reason: "already-started", - }); - return; - } - - dependencies.state.started.add(processKey); - commandStarted = true; - - const spawnOptions: StartupSpawnOptions = { - detached: false, - stdio: "ignore", - windowsHide: true, - }; - - if (command.scope === "project") { - spawnOptions.cwd = command.projectRoot; - } - - let child: SpawnedChild; - - try { - child = dependencies.spawn( - command.executable, - command.args, - spawnOptions, - ); - } catch (error) { - writeLog(dependencies.logger, { - type: "command.spawn-failed", - scope: command.scope, - index: command.index, - name: command.name, - code: getErrorCode(error), - }); - return; - } - - writeLog(dependencies.logger, { - type: "command.spawned", - scope: command.scope, - index: command.index, - name: command.name, - pid: child.pid, - }); - - try { - child.once("error", (error) => { - writeLog(dependencies.logger, { - type: "command.child-error", - scope: command.scope, - index: command.index, - name: command.name, - code: getErrorCode(error), - }); - }); - } catch { - // A spawned child remains successful even if lifecycle hooks are unavailable. - } - - try { - child.once("exit", (exitCode, signal) => { - writeLog(dependencies.logger, { - type: "command.exited", - scope: command.scope, - index: command.index, - name: command.name, - exitCode, - signal, + 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 true; + } + + if (command.onExistingProcess === "start") { + const record = spawnRecord(command, owner, entry, dependencies); + if (record) { + claimedKeys.add(processKey); + } + return true; + } + + 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; + } + + 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; + } + + 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: 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; + } + + stopFailed = true; + if (record.rootExited || result.addressability === "lost") { + entry.cleanupUnconfirmed = true; + for (const recordOwner of owners) { + transferredOwners.add(recordOwner); + } + return; + } + + record.owners.clear(); + for (const recordOwner of owners) { + record.owners.add(recordOwner); + } + record.status = "active"; + record.stopPromise = undefined; + safeSurvivors.push(record); }); - }); - } 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. - } - }); + 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; + }, + ); + commandAttempted ||= attempted; + } - if (!commandStarted) { + if (!commandAttempted) { writeLog(dependencies.logger, { type: "batch.skipped", reason: "already-started", }); } + + return activation; } diff --git a/src/logger.ts b/src/logger.ts index b081753..5d0fef8 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -7,10 +7,24 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, win32 } from "node:path"; +import type { ProcessTreeFailureReason } from "./process-tree.js"; type LogErrorCode = "EACCES" | "ENOENT" | "EPERM"; type LogScope = "global" | "project"; +export type CommandStopTrigger = + | "scope-disposed" + | "root-exited" + | "restart"; + +interface CommandStopContext { + scope: LogScope; + index: number; + name: string; + pid?: number; + trigger: CommandStopTrigger; +} + export type LogEvent = | { type: "plugin.initialized"; commandCount: number } | { @@ -67,7 +81,13 @@ export type LogEvent = name: string; exitCode: number | null; signal: NodeJS.Signals | null; - }; + } + | ({ type: "command.stop-requested" } & CommandStopContext) + | ({ type: "command.stop-forced" } & CommandStopContext) + | ({ + type: "command.stop-failed"; + reason: ProcessTreeFailureReason; + } & CommandStopContext); export interface Logger { write(event: LogEvent): void; @@ -104,6 +124,12 @@ function formatCode(code: LogErrorCode | undefined): string { return code ? ` code=${code}` : ""; } +function formatStopContext(event: CommandStopContext): string { + return `scope=${event.scope} index=${event.index} name=${formatName(event.name)}${ + event.pid === undefined ? "" : ` pid=${event.pid}` + } trigger=${event.trigger}`; +} + function assertNeverEvent(event: never): never { throw new Error("Unhandled log event"); } @@ -134,6 +160,12 @@ function formatConsoleEvent(event: LogEvent): string { return `exited scope=${event.scope} index=${event.index} name=${formatName(event.name)} exitCode=${ event.exitCode === null ? "null" : event.exitCode } signal=${event.signal ?? "null"}`; + case "command.stop-requested": + return `stop requested ${formatStopContext(event)}`; + case "command.stop-forced": + return `stop forced ${formatStopContext(event)}`; + case "command.stop-failed": + return `stop failed ${formatStopContext(event)} reason=${event.reason}`; default: return assertNeverEvent(event); } @@ -164,6 +196,11 @@ function formatFileEvent(event: LogEvent): string { return `${event.type} scope=${event.scope} index=${event.index} name=${formatName(event.name)} exitCode=${ event.exitCode === null ? "null" : event.exitCode } signal=${event.signal ?? "null"}`; + case "command.stop-requested": + case "command.stop-forced": + return `${event.type} ${formatStopContext(event)}`; + case "command.stop-failed": + return `${event.type} ${formatStopContext(event)} reason=${event.reason}`; default: return assertNeverEvent(event); } diff --git a/src/process-tree.ts b/src/process-tree.ts new file mode 100644 index 0000000..22a8833 --- /dev/null +++ b/src/process-tree.ts @@ -0,0 +1,520 @@ +import { spawn as spawnProcess } from "node:child_process"; +import { win32 } from "node:path"; + +export type ProcessTreeFailureReason = + | "missing-pid" + | "permission-denied" + | "facility-unavailable" + | "unconfirmed"; + +export type ProcessTreeAddressability = "safe" | "lost"; + +export type ProcessTreeStopResult = + | { status: "stopped" } + | { + status: "failed"; + reason: ProcessTreeFailureReason; + addressability: ProcessTreeAddressability; + }; + +export interface ProcessTreeStopOptions { + isRootExited(): boolean; + onForce(): void; +} + +export interface ProcessTreeController { + stop( + pid: number | undefined, + options: ProcessTreeStopOptions, + ): Promise; +} + +export interface ProcessUtilityChild { + once(event: "error", listener: (error: Error) => void): ProcessUtilityChild; + once( + event: "exit", + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): ProcessUtilityChild; + kill(signal?: NodeJS.Signals | number): boolean; + unref(): void; +} + +export interface ProcessUtilitySpawnOptions { + detached: false; + shell: false; + stdio: "ignore"; + windowsHide: true; +} + +export type ProcessUtilitySpawn = ( + executable: string, + args: string[], + options: ProcessUtilitySpawnOptions, +) => ProcessUtilityChild; + +export interface ProcessTreeControllerOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + gracePeriodMs?: number; + pollIntervalMs?: number; + utilityTimeoutMs?: number; + kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean; + spawn?: ProcessUtilitySpawn; + delay?: (milliseconds: number) => Promise; +} + +const DEFAULT_GRACE_PERIOD_MS = 5_000; +const DEFAULT_POLL_INTERVAL_MS = 50; +const DEFAULT_UTILITY_TIMEOUT_MS = 5_000; + +type SignalResult = + | { status: "sent" } + | { status: "absent" } + | { status: "failed"; reason: ProcessTreeFailureReason }; + +type UtilityResult = + | { status: "stopped" } + | { + status: "failed"; + reason: ProcessTreeFailureReason; + timedOut: boolean; + }; + +type RootProbeResult = + | { status: "present" } + | { 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, + signal: NodeJS.Signals | 0, +): SignalResult { + try { + return kill(pid, signal) + ? { status: "sent" } + : { status: "failed", reason: "unconfirmed" }; + } catch (error) { + const code = getErrorCode(error); + + if (code === "ESRCH") { + return { status: "absent" }; + } + if (code === "EPERM" || code === "EACCES") { + return { status: "failed", reason: "permission-denied" }; + } + return { status: "failed", reason: "unconfirmed" }; + } +} + +function failure( + reason: ProcessTreeFailureReason, + options: ProcessTreeStopOptions, +): ProcessTreeStopResult { + let rootExited = true; + + try { + rootExited = options.isRootExited(); + } catch {} + + return { + status: "failed", + reason, + addressability: rootExited ? "lost" : "safe", + }; +} + +function rootExited(options: ProcessTreeStopOptions): boolean { + try { + return options.isRootExited(); + } catch { + return true; + } +} + +function lostFailure(): ProcessTreeStopResult { + return { + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }; +} + +function positiveFiniteOrDefault(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 0 + ? value + : fallback; +} + +function nonnegativeFiniteOrDefault( + value: number | undefined, + fallback: number, +): number { + return value !== undefined && Number.isFinite(value) && value >= 0 + ? value + : fallback; +} + +async function stopPosixProcessGroup( + pid: number, + options: ProcessTreeStopOptions, + dependencies: { + gracePeriodMs: number; + pollIntervalMs: number; + kill: NonNullable; + delay: NonNullable; + }, +): Promise { + const groupPid = -pid; + const initialProbe = signalProcess(dependencies.kill, groupPid, 0); + + if (initialProbe.status === "absent") { + return { status: "stopped" }; + } + if (initialProbe.status === "failed") { + return failure(initialProbe.reason, options); + } + + const gracefulSignal = signalProcess(dependencies.kill, groupPid, "SIGTERM"); + if (gracefulSignal.status === "absent") { + return { status: "stopped" }; + } + if (gracefulSignal.status === "failed") { + return failure(gracefulSignal.reason, options); + } + + const graceProbeCount = Math.max( + 1, + Math.ceil(dependencies.gracePeriodMs / dependencies.pollIntervalMs), + ); + + for (let index = 0; index < graceProbeCount; index += 1) { + const remainingGrace = Math.max( + 0, + dependencies.gracePeriodMs - index * dependencies.pollIntervalMs, + ); + + try { + await dependencies.delay( + Math.min(dependencies.pollIntervalMs, remainingGrace), + ); + } catch { + return failure("unconfirmed", options); + } + + const probe = signalProcess(dependencies.kill, groupPid, 0); + if (probe.status === "absent") { + return { status: "stopped" }; + } + if (probe.status === "failed") { + return failure(probe.reason, options); + } + } + + try { + options.onForce(); + } catch {} + + const forceSignal = signalProcess(dependencies.kill, groupPid, "SIGKILL"); + + try { + await dependencies.delay(dependencies.pollIntervalMs); + } catch { + return failure( + forceSignal.status === "failed" ? forceSignal.reason : "unconfirmed", + options, + ); + } + + const finalProbe = signalProcess(dependencies.kill, groupPid, 0); + if (finalProbe.status === "absent") { + return { status: "stopped" }; + } + if (finalProbe.status === "failed") { + return failure(finalProbe.reason, options); + } + + return failure( + forceSignal.status === "failed" ? forceSignal.reason : "unconfirmed", + options, + ); +} + +function resolveTaskkillPath(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", "taskkill.exe"); +} + +function utilityFailureReason(error: unknown): ProcessTreeFailureReason { + const code = getErrorCode(error); + if (code === "EPERM" || code === "EACCES") { + return "permission-denied"; + } + if (code === "ENOENT") { + return "facility-unavailable"; + } + return "unconfirmed"; +} + +function runUtility( + spawn: ProcessUtilitySpawn, + executable: string, + args: string[], + timeoutMs: number, +): Promise { + let child: ProcessUtilityChild; + + try { + child = spawn(executable, args, { + detached: false, + shell: false, + stdio: "ignore", + windowsHide: true, + }); + } catch (error) { + return Promise.resolve({ + status: "failed", + reason: utilityFailureReason(error), + timedOut: false, + }); + } + + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + const settle = (result: UtilityResult): void => { + if (settled) { + return; + } + settled = true; + if (timer !== undefined) { + clearTimeout(timer); + } + resolve(result); + }; + + try { + child.once("error", (error) => { + settle({ + status: "failed", + reason: utilityFailureReason(error), + timedOut: false, + }); + }); + child.once("exit", (code) => { + settle( + code === 0 + ? { status: "stopped" } + : { + status: "failed", + reason: "unconfirmed", + timedOut: false, + }, + ); + }); + } catch (error) { + settle({ + status: "failed", + reason: utilityFailureReason(error), + timedOut: false, + }); + } + + if (!settled) { + timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + + try { + child.kill("SIGKILL"); + } catch {} + try { + child.unref(); + } catch {} + + resolve({ status: "failed", reason: "unconfirmed", timedOut: true }); + }, timeoutMs); + } + }); +} + +function probeWindowsRoot( + pid: number, + options: ProcessTreeStopOptions, + kill: NonNullable, +): RootProbeResult { + if (rootExited(options)) { + return { status: "lost" }; + } + + const probe = signalProcess(kill, pid, 0); + if (probe.status === "absent") { + return { status: "lost" }; + } + if (probe.status === "failed") { + return probe; + } + return { status: "present" }; +} + +function rootProbeFailure( + probe: Exclude, + options: ProcessTreeStopOptions, +): ProcessTreeStopResult { + return probe.status === "lost" ? lostFailure() : failure(probe.reason, options); +} + +async function stopWindowsProcessTree( + pid: number, + options: ProcessTreeStopOptions, + dependencies: { + env: NodeJS.ProcessEnv; + gracePeriodMs: number; + utilityTimeoutMs: number; + kill: NonNullable; + spawn: ProcessUtilitySpawn; + delay: NonNullable; + }, +): Promise { + if (rootExited(options)) { + return lostFailure(); + } + + const taskkillPath = resolveTaskkillPath(dependencies.env); + if (!taskkillPath) { + return failure("facility-unavailable", options); + } + + const gracefulResult = await runUtility(dependencies.spawn, taskkillPath, [ + "/PID", + String(pid), + "/T", + ], dependencies.utilityTimeoutMs); + if (gracefulResult.status === "stopped") { + return gracefulResult; + } + if (gracefulResult.reason === "facility-unavailable") { + return failure(gracefulResult.reason, options); + } + + let rootProbe = probeWindowsRoot(pid, options, dependencies.kill); + if (rootProbe.status !== "present") { + return rootProbeFailure(rootProbe, options); + } + + if (!gracefulResult.timedOut) { + try { + await dependencies.delay(dependencies.gracePeriodMs); + } catch { + return failure("unconfirmed", options); + } + } + + rootProbe = probeWindowsRoot(pid, options, dependencies.kill); + if (rootProbe.status !== "present") { + return rootProbeFailure(rootProbe, options); + } + + try { + options.onForce(); + } catch {} + + if (rootExited(options)) { + return lostFailure(); + } + + const forcedResult = await runUtility(dependencies.spawn, taskkillPath, [ + "/PID", + String(pid), + "/T", + "/F", + ], dependencies.utilityTimeoutMs); + if (forcedResult.status === "stopped") { + return forcedResult; + } + + rootProbe = probeWindowsRoot(pid, options, dependencies.kill); + if (rootProbe.status !== "present") { + return rootProbeFailure(rootProbe, options); + } + + return failure(forcedResult.reason, options); +} + +export function createProcessTreeController( + options?: ProcessTreeControllerOptions, +): ProcessTreeController { + const platform = options?.platform ?? process.platform; + const gracePeriodMs = nonnegativeFiniteOrDefault( + options?.gracePeriodMs, + DEFAULT_GRACE_PERIOD_MS, + ); + const pollIntervalMs = positiveFiniteOrDefault( + options?.pollIntervalMs, + DEFAULT_POLL_INTERVAL_MS, + ); + const utilityTimeoutMs = nonnegativeFiniteOrDefault( + options?.utilityTimeoutMs, + DEFAULT_UTILITY_TIMEOUT_MS, + ); + const kill = options?.kill ?? process.kill; + const env = options?.env ?? process.env; + const spawn: ProcessUtilitySpawn = + options?.spawn ?? + ((executable, args, spawnOptions) => + spawnProcess(executable, args, spawnOptions)); + const delay = + options?.delay ?? + ((milliseconds: number) => + new Promise((resolve) => setTimeout(resolve, milliseconds))); + + return { + async stop(pid, stopOptions): Promise { + if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) { + return { + status: "failed", + reason: "missing-pid", + addressability: "lost", + }; + } + + if (platform !== "win32") { + return stopPosixProcessGroup(pid, stopOptions, { + gracePeriodMs, + pollIntervalMs, + kill, + delay, + }); + } + + return stopWindowsProcessTree(pid, stopOptions, { + env, + gracePeriodMs, + utilityTimeoutMs, + kill, + spawn, + delay, + }); + }, + }; +} diff --git a/src/server-internal.ts b/src/server-internal.ts index e88e81d..08d16db 100644 --- a/src/server-internal.ts +++ b/src/server-internal.ts @@ -7,18 +7,15 @@ import type { } from "./config.js"; import { runStartupCommands, - type SpawnFunction, - type StartupState, + type StartupDependencies, } from "./core.js"; import type { Logger } from "./logger.js"; -export interface StartupCommandsServerDependencies { +export interface StartupCommandsServerDependencies + extends StartupDependencies { loadConfigFile: typeof loadConfigFile; resolveGlobalConfigPath: typeof resolveGlobalConfigPath; resolveProjectConfigPath: typeof resolveProjectConfigPath; - spawn: SpawnFunction; - state: StartupState; - logger: Logger; } function writeConfigDiagnostic( @@ -69,12 +66,14 @@ export function createStartupCommandsServer( writeConfigDiagnostic(diagnostic, dependencies.logger); } - runStartupCommands( + const activation = await runStartupCommands( [...globalConfig.commands, ...projectConfig.commands], dependencies, ); - return {}; + return { + dispose: () => activation.dispose(), + }; }, }; } diff --git a/src/server.ts b/src/server.ts index b7694c2..ed9d5d4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { } from "./config.js"; import { processState } from "./core.js"; import { createLogger } from "./logger.js"; +import { createProcessTreeController } from "./process-tree.js"; import { createStartupCommandsServer } from "./server-internal.js"; const startupCommandsServer = createStartupCommandsServer({ @@ -14,6 +15,7 @@ const startupCommandsServer = createStartupCommandsServer({ resolveProjectConfigPath, spawn, state: processState, + processTree: createProcessTreeController(), logger: createLogger(), }); diff --git a/tests/community-files.test.ts b/tests/community-files.test.ts index 5421ef5..71b203c 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.0.0", + placeholder: "1.1.0", }, validations: { required: true }, }, diff --git a/tests/config.test.ts b/tests/config.test.ts index 34e2ecf..94112ee 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -84,6 +84,8 @@ describe("Configuration loading", () => { name: "Global Helper", executable: " /opt/helper ", args: ["", " exact argument "], + onExistingProcess: "skip", + stopOnExit: true, scope: "global", index: 0, }, @@ -115,6 +117,8 @@ describe("Configuration loading", () => { name: "Project Helper", executable: "project-helper", args: ["--project"], + onExistingProcess: "skip", + stopOnExit: true, scope: "project", projectRoot, index: 0, @@ -124,6 +128,104 @@ describe("Configuration loading", () => { }); }); + test.each([ + { + configured: {}, + expected: { onExistingProcess: "skip", stopOnExit: true }, + }, + { + configured: { onExistingProcess: "start", stopOnExit: false }, + expected: { onExistingProcess: "start", stopOnExit: false }, + }, + { + configured: { onExistingProcess: "skip", stopOnExit: true }, + expected: { onExistingProcess: "skip", stopOnExit: true }, + }, + { + configured: { onExistingProcess: "restart", stopOnExit: false }, + expected: { onExistingProcess: "restart", stopOnExit: false }, + }, + ])("normalizes lifecycle policy %#", ({ configured, expected }) => { + const filePath = join(createTempDirectory(), "lifecycle.json"); + writeConfig( + filePath, + JSON.stringify({ + commands: [ + { + name: "Lifecycle helper", + executable: "helper", + args: ["--watch"], + ...configured, + }, + ], + }), + ); + + expect(loadConfigFile("global", filePath).commands).toEqual([ + { + name: "Lifecycle helper", + executable: "helper", + args: ["--watch"], + ...expected, + scope: "global", + index: 0, + }, + ]); + }); + + test.each([ + { configured: { onExistingProcess: "replace" } }, + { configured: { onExistingProcess: 1 } }, + { configured: { onExistingProcess: null } }, + { configured: { stopOnExit: "yes" } }, + { configured: { stopOnExit: 1 } }, + { configured: { stopOnExit: null } }, + ])("isolates invalid lifecycle policy %#", ({ configured }) => { + const filePath = join(createTempDirectory(), "invalid-lifecycle.json"); + writeConfig( + filePath, + JSON.stringify({ + commands: [ + { + name: "Invalid lifecycle", + executable: "invalid-helper", + args: [], + ...configured, + }, + { + name: "Valid lifecycle", + executable: "valid-helper", + args: [], + onExistingProcess: "start", + stopOnExit: false, + }, + ], + }), + ); + + expect(loadConfigFile("global", filePath)).toEqual({ + commands: [ + { + name: "Valid lifecycle", + executable: "valid-helper", + args: [], + onExistingProcess: "start", + stopOnExit: false, + scope: "global", + index: 1, + }, + ], + diagnostics: [ + { + scope: "global", + reason: "invalid-command", + index: 0, + name: "Invalid lifecycle", + }, + ], + }); + }); + test("loads a valid document with one leading UTF-8 BOM", () => { const filePath = join(createTempDirectory(), "bom.json"); writeConfig( @@ -145,6 +247,8 @@ describe("Configuration loading", () => { name: "BOM Helper", executable: "helper", args: [], + onExistingProcess: "skip", + stopOnExit: true, scope: "global", index: 0, }, @@ -230,6 +334,8 @@ describe("Configuration loading", () => { name: "Retained Helper", executable: " helper ", args: ["--safe"], + onExistingProcess: "skip", + stopOnExit: true, scope: "global", index: 10, }, diff --git a/tests/config.type-test.ts b/tests/config.type-test.ts index 6364199..a1aa43a 100644 --- a/tests/config.type-test.ts +++ b/tests/config.type-test.ts @@ -2,6 +2,7 @@ import { loadConfigFile, type ConfigDiagnostic, type ConfiguredCommand, + type OnExistingProcessPolicy, } from "../src/config.js"; loadConfigFile("global", "global.json"); @@ -13,10 +14,23 @@ loadConfigFile("project", "project.json"); // @ts-expect-error global configuration does not accept a project root loadConfigFile("global", "global.json", "project-root"); +const onExistingProcessPolicies: OnExistingProcessPolicy[] = [ + "start", + "skip", + "restart", +]; +void onExistingProcessPolicies; + +// @ts-expect-error onExistingProcess rejects unsupported policies +const invalidOnExistingProcessPolicy: OnExistingProcessPolicy = "replace"; +void invalidOnExistingProcessPolicy; + const globalCommand = { name: "Global Helper", executable: "helper", args: [], + onExistingProcess: "skip", + stopOnExit: true, scope: "global", index: 0, } satisfies ConfiguredCommand; @@ -26,17 +40,44 @@ const projectCommand = { name: "Project Helper", executable: "helper", args: [], + onExistingProcess: "start", + stopOnExit: false, scope: "project", projectRoot: "project-root", index: 0, } satisfies ConfiguredCommand; void projectCommand; +// @ts-expect-error configured commands require onExistingProcess +const commandWithoutOnExistingProcess: ConfiguredCommand = { + name: "Global Helper", + executable: "helper", + args: [], + stopOnExit: true, + scope: "global", + index: 0, +}; +void commandWithoutOnExistingProcess; + +const commandWithNonBooleanStopOnExit: ConfiguredCommand = { + name: "Global Helper", + executable: "helper", + args: [], + onExistingProcess: "skip", + // @ts-expect-error configured commands require boolean stopOnExit + stopOnExit: "yes", + scope: "global", + index: 0, +}; +void commandWithNonBooleanStopOnExit; + // @ts-expect-error configured commands require their source array index const globalCommandWithoutIndex: ConfiguredCommand = { name: "Global Helper", executable: "helper", args: [], + onExistingProcess: "skip", + stopOnExit: true, scope: "global", }; void globalCommandWithoutIndex; @@ -46,6 +87,8 @@ const projectWithoutRoot: ConfiguredCommand = { name: "Project Helper", executable: "helper", args: [], + onExistingProcess: "skip", + stopOnExit: true, scope: "project", index: 0, }; @@ -56,6 +99,8 @@ const globalWithRoot: ConfiguredCommand = { name: "Global Helper", executable: "helper", args: [], + onExistingProcess: "skip", + stopOnExit: true, scope: "global", projectRoot: "project-root", index: 0, diff --git a/tests/core.test.ts b/tests/core.test.ts index ffb3b23..2f6b739 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; import type { ConfiguredCommand } from "../src/config.js"; import { + createStartupState, getOrCreateProcessState, runStartupCommands, type SpawnFunction, @@ -9,6 +10,11 @@ import { type StartupState, } from "../src/core.js"; import type { LogEvent, Logger } from "../src/logger.js"; +import type { + ProcessTreeController, + ProcessTreeStopOptions, + ProcessTreeStopResult, +} from "../src/process-tree.js"; class FakeChild implements SpawnedChild { public readonly onceCalls: Array<"error" | "exit"> = []; @@ -88,13 +94,80 @@ class FakeChild implements SpawnedChild { } } +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, scope: "global", index }; + return { name, executable, args, ...policies, scope: "global", index }; } function projectCommand( @@ -103,11 +176,13 @@ function projectCommand( args: string[] = [], name = "Project command", index = 0, + policies: CommandPolicies = DEFAULT_POLICIES, ): ConfiguredCommand { return { name, executable, args, + ...policies, scope: "project", projectRoot, index, @@ -146,9 +221,11 @@ function createSpawn( function createDependencies( children: SpawnedChild[] = [], + processTree: ProcessTreeController = new FakeProcessTree(), ): { calls: unknown[][]; logger: Logger & { events: LogEvent[] }; + processTree: ProcessTreeController; state: StartupState; spawn: SpawnFunction; } { @@ -157,114 +234,1559 @@ function createDependencies( return { calls, logger: createLogger(), - state: { started: new Set() }, + processTree, + state: createStartupState(), spawn: createSpawn(children, calls), }; } -describe("getOrCreateProcessState", () => { - const stateKey = Symbol.for("opencode.startup-commands.state"); +function createGatedIdentityState( + processKey: string, + transitionTail: Promise, +): StartupState { + return new Map([ + [ + processKey, + { + processKey, + records: [], + cleanupUnconfirmed: false, + status: "stable", + transitionTail, + pendingTransitions: 0, + nextCreationOrder: 0, + }, + ], + ]) as StartupState; +} + +describe("getOrCreateProcessState", () => { + const stateKey = Symbol.for("opencode.startup-commands.state"); + + test("creates one empty process-entry map", () => { + const registry: Record = {}; + const state = getOrCreateProcessState(registry); + + expect(state).toBeInstanceOf(Map); + expect(state.size).toBe(0); + expect(registry[stateKey]).toBe(state); + }); + + test("reuses a structurally valid process-entry map", () => { + const processKey = 'global:["helper",["--watch"]]'; + const existing = new Map([ + [ + processKey, + { + processKey, + records: [ + { + child: new FakeChild(101), + pid: 101, + context: { scope: "global", index: 0, name: "Helper" }, + creationOrder: 0, + owners: new Set([Symbol("owner")]), + stopOnExit: true, + status: "active", + rootExited: false, + }, + ], + cleanupUnconfirmed: false, + status: "stable", + transitionTail: Promise.resolve(), + pendingTransitions: 0, + nextCreationOrder: 1, + }, + ], + ]) as StartupState; + const registry: Record = { [stateKey]: existing }; + + expect(getOrCreateProcessState(registry)).toBe(existing); + }); + + test("assimilates a structurally valid then-only transition tail", async () => { + const processKey = 'global:["helper",[]]'; + const thenOnlyTail = { + then(onFulfilled: () => void): Promise { + return Promise.resolve().then(onFulfilled); + }, + }; + const existing = new Map([ + [ + processKey, + { + processKey, + records: [], + cleanupUnconfirmed: false, + status: "stable", + transitionTail: thenOnlyTail, + pendingTransitions: 0, + nextCreationOrder: 0, + }, + ], + ]) as unknown as StartupState; + const registry: Record = { [stateKey]: existing }; + const dependencies = createDependencies([new FakeChild(101)]); + dependencies.state = getOrCreateProcessState(registry); + + await runStartupCommands([globalCommand("helper")], dependencies); + const entry = existing.get(processKey)!; + await entry.transitionTail; + await runStartupCommands([globalCommand("helper")], dependencies); + + expect(dependencies.state).toBe(existing); + expect(dependencies.calls).toHaveLength(1); + expect(entry.pendingTransitions).toBe(0); + expect(entry.transitionTail).toBeInstanceOf(Promise); + expect(entry.records[0]?.owners.size).toBe(2); + }); + + test("converts legacy identities into degraded cleanup blockers", () => { + const legacy = { + started: new Set([ + 'global:["helper",["--watch"]]', + 'project:C:\\work:["project-helper",[]]', + ]), + }; + const registry: Record = { [stateKey]: legacy }; + + const state = getOrCreateProcessState(registry); + + expect(state).not.toBe(legacy); + expect(registry[stateKey]).toBe(state); + expect([...state.keys()]).toEqual([ + 'global:["helper",["--watch"]]', + 'project:C:\\work:["project-helper",[]]', + ]); + for (const entry of state.values()) { + expect(entry.records).toEqual([]); + expect(entry.cleanupUnconfirmed).toBe(true); + expect(entry.status).toBe("degraded"); + expect(entry.retryTombstone).toBeUndefined(); + } + }); + + test("replaces malformed map keys and entries", () => { + const child = new FakeChild(101); + const owner = Symbol("owner"); + const processKey = 'global:["helper",[]]'; + const validRecord = { + child, + pid: 101, + context: { scope: "global", index: 0, name: "Helper" }, + creationOrder: 0, + owners: new Set([owner]), + stopOnExit: true, + status: "active", + rootExited: false, + }; + const validEntry = { + processKey, + records: [validRecord], + cleanupUnconfirmed: false, + status: "stable", + transitionTail: Promise.resolve(), + pendingTransitions: 0, + nextCreationOrder: 1, + }; + const malformedMaps: Map[] = [ + new Map([[1, validEntry]]), + new Map([[processKey, { ...validEntry, processKey: "other" }]]), + new Map([[processKey, { ...validEntry, records: {} }]]), + new Map([[processKey, { ...validEntry, records: [{ ...validRecord, pid: 0 }] }]]), + new Map([[processKey, { ...validEntry, records: [{ ...validRecord, child: {} }] }]]), + new Map([[processKey, { ...validEntry, records: [{ ...validRecord, context: null }] }]]), + new Map([[processKey, { ...validEntry, records: [{ ...validRecord, creationOrder: -1 }] }]]), + new Map([[processKey, { ...validEntry, records: [{ ...validRecord, owners: new Set(["owner"]) }] }]]), + new Map([[processKey, { ...validEntry, records: [{ ...validRecord, stopOnExit: "yes" }] }]]), + new Map([[processKey, { ...validEntry, records: [{ ...validRecord, status: "unknown" }] }]]), + new Map([[processKey, { ...validEntry, records: [{ ...validRecord, stopPromise: {} }] }]]), + new Map([[processKey, { ...validEntry, records: [{ ...validRecord, rootExited: 0 }] }]]), + new Map([[processKey, { ...validEntry, retryTombstone: "unknown" }]]), + new Map([[processKey, { ...validEntry, retryTombstone: "spawn-failed" }]]), + new Map([[processKey, { ...validEntry, cleanupUnconfirmed: "no" }]]), + new Map([[processKey, { ...validEntry, status: "unknown" }]]), + new Map([[processKey, { ...validEntry, transitionTail: {} }]]), + new Map([[processKey, { ...validEntry, pendingTransitions: -1 }]]), + new Map([[processKey, { ...validEntry, nextCreationOrder: 0 }]]), + new Map([[processKey, { ...validEntry, records: [validRecord, validRecord], nextCreationOrder: 1 }]]), + ]; + + for (const malformedState of malformedMaps) { + const registry: Record = { + [stateKey]: malformedState, + }; + + const state = getOrCreateProcessState(registry); + + expect(state).not.toBe(malformedState); + expect(state).toBeInstanceOf(Map); + expect(state.size).toBe(0); + expect(registry[stateKey]).toBe(state); + } + }); + + test.each([ + null, + {}, + { started: false }, + { started: new Set([1]) }, + ])("replaces malformed non-map process state: %p", (malformedState) => { + const registry: Record = { + [stateKey]: malformedState, + }; + + const state = getOrCreateProcessState(registry); + + expect(state).not.toBe(malformedState); + expect(state).toBeInstanceOf(Map); + expect(state.size).toBe(0); + expect(registry[stateKey]).toBe(state); + }); +}); + +describe("runStartupCommands", () => { + test("launches global commands before project commands with scope-specific cwd", async () => { + const firstChild = new FakeChild(101); + const secondChild = new FakeChild(102); + const dependencies = createDependencies([firstChild, secondChild]); + const projectRoot = resolve("workspace", "project"); + + await runStartupCommands( + [ + globalCommand("C:\\Tools\\global.exe", ["", " spaced value "]), + projectCommand(projectRoot, "project-tool", ["--watch"]), + ], + dependencies, + ); + + expect(dependencies.calls).toEqual([ + [ + "C:\\Tools\\global.exe", + ["", " spaced value "], + { + detached: true, + shell: false, + stdio: "ignore", + windowsHide: true, + }, + ], + [ + "project-tool", + ["--watch"], + { + cwd: projectRoot, + detached: true, + shell: false, + stdio: "ignore", + windowsHide: true, + }, + ], + ]); + expect(firstChild.unrefCalls).toBe(1); + expect(secondChild.unrefCalls).toBe(1); + expect(dependencies.state.size).toBe(2); + expect([...dependencies.state.values()].map(({ records }) => records.map(({ pid }) => pid))).toEqual([ + [101], + [102], + ]); + expect( + [...dependencies.state.values()].map(({ records }) => records[0]?.owners.size), + ).toEqual([1, 1]); + expect(dependencies.logger.events).toEqual([ + { type: "plugin.initialized", commandCount: 2 }, + { + type: "command.spawned", + scope: "global", + index: 0, + name: "Global command", + pid: 101, + }, + { + type: "command.spawned", + scope: "project", + index: 0, + name: "Project command", + pid: 102, + }, + ]); + }); + + test("validates one PID snapshot before retaining or logging it", async () => { + const pidReads = [0, 101, 101, -1]; + const child: SpawnedChild = { + get pid(): number | undefined { + return pidReads.shift(); + }, + once(): SpawnedChild { + return child; + }, + unref(): void {}, + }; + const dependencies = createDependencies([child]); + + await runStartupCommands([globalCommand("helper")], dependencies); + + expect(pidReads).toEqual([101, 101, -1]); + expect([...dependencies.state.values()][0]?.records[0]?.pid).toBeUndefined(); + expect(dependencies.logger.events).toContainEqual({ + type: "command.spawned", + scope: "global", + index: 0, + name: "Global command", + pid: undefined, + }); + }); + + test.each(["start", "skip", "restart"] as const)( + "spawns one owned record from an empty identity with %s", + async (onExistingProcess) => { + const dependencies = createDependencies([new FakeChild(101)]); + + await runStartupCommands( + [ + globalCommand("helper", [], "Helper", 0, { + onExistingProcess, + stopOnExit: true, + }), + ], + dependencies, + ); + + const entry = [...dependencies.state.values()][0]; + expect(dependencies.calls).toHaveLength(1); + expect(dependencies.state.size).toBe(1); + expect(entry?.records).toHaveLength(1); + expect(entry?.records[0]?.pid).toBe(101); + expect(entry?.records[0]?.owners.size).toBe(1); + }, + ); + + test("appends one separately owned record for each start activation", async () => { + const dependencies = createDependencies([ + new FakeChild(101), + new FakeChild(102), + new FakeChild(103), + ]); + const command = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + + await runStartupCommands([command], dependencies); + await runStartupCommands([command], dependencies); + await runStartupCommands([command], dependencies); + + const records = [...dependencies.state.values()][0]?.records ?? []; + const owners = records.flatMap((record) => [...record.owners]); + expect(dependencies.calls).toHaveLength(3); + expect(records.map(({ pid }) => pid)).toEqual([101, 102, 103]); + expect(records.map(({ owners }) => owners.size)).toEqual([1, 1, 1]); + expect(new Set(owners).size).toBe(3); + }); + + test("skip claims only the oldest active record without changing its policy", async () => { + const dependencies = createDependencies([ + new FakeChild(101), + new FakeChild(102), + ]); + const start = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: false, + }); + const skip = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "skip", + stopOnExit: true, + }); + + await runStartupCommands([start], dependencies); + await runStartupCommands([start], dependencies); + await runStartupCommands([skip], dependencies); + + const records = [...dependencies.state.values()][0]?.records ?? []; + expect(dependencies.calls).toHaveLength(2); + expect(records).toHaveLength(2); + expect(records[0]?.owners.size).toBe(2); + expect(records[1]?.owners.size).toBe(1); + expect(records[0]?.stopOnExit).toBe(false); + }); + + test("restart waits for every old record before spawning one owner-union replacement", async () => { + const tree = new DeferredProcessTree(); + const dependencies = createDependencies( + [new FakeChild(101), new FakeChild(102), new FakeChild(103)], + tree, + ); + const persistentStart = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: false, + }); + const stoppableStart = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + const restart = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "restart", + stopOnExit: false, + }); + + await runStartupCommands([persistentStart], dependencies); + await runStartupCommands([stoppableStart], dependencies); + const restartPromise = runStartupCommands([restart], dependencies); + await Promise.resolve(); + await Promise.resolve(); + + expect(tree.calls.map(({ pid }) => pid).sort()).toEqual([101, 102]); + expect(dependencies.calls).toHaveLength(2); + expect([...dependencies.state.values()][0]?.status).toBe("restarting"); + + tree.stops.get(102)!.resolve({ status: "stopped" }); + await Promise.resolve(); + await Promise.resolve(); + expect(dependencies.calls).toHaveLength(2); + + tree.stops.get(101)!.resolve({ status: "stopped" }); + await restartPromise; + + const entry = [...dependencies.state.values()][0]; + expect(dependencies.calls).toHaveLength(3); + expect(entry?.records).toHaveLength(1); + expect(entry?.records[0]?.pid).toBe(103); + expect(entry?.records[0]?.owners.size).toBe(3); + expect(entry?.records[0]?.stopOnExit).toBe(false); + expect(entry?.status).toBe("stable"); + }); + + test("keeps complete restart cleanup final when replacement spawn fails", async () => { + const tree = new DeferredProcessTree(); + const dependencies = createDependencies( + [new FakeChild(101), new FakeChild(102)], + tree, + ); + const start = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + + await runStartupCommands([start], dependencies); + await runStartupCommands([start], dependencies); + const restartPromise = runStartupCommands( + [ + globalCommand("helper", [], "Replacement", 4, { + onExistingProcess: "restart", + stopOnExit: false, + }), + ], + dependencies, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(tree.calls.map(({ pid }) => pid).sort()).toEqual([101, 102]); + expect(dependencies.calls).toHaveLength(2); + + tree.stops.get(101)!.resolve({ status: "stopped" }); + tree.stops.get(102)!.resolve({ status: "stopped" }); + await restartPromise; + + const entry = [...dependencies.state.values()][0]!; + expect(entry.records).toHaveLength(0); + expect(entry.retryTombstone).toBe("spawn-failed"); + expect(entry.cleanupUnconfirmed).toBe(false); + expect(entry.status).toBe("stable"); + expect(dependencies.calls).toHaveLength(3); + + for (const [index, onExistingProcess] of ( + ["start", "skip", "restart"] as const + ).entries()) { + const eventStart = dependencies.logger.events.length; + await runStartupCommands( + [ + globalCommand("helper", [], `Blocked ${onExistingProcess}`, index, { + onExistingProcess, + stopOnExit: true, + }), + ], + dependencies, + ); + expect(dependencies.logger.events.slice(eventStart)).toContainEqual({ + type: "command.skipped", + scope: "global", + index, + name: `Blocked ${onExistingProcess}`, + reason: "already-started", + }); + } + + expect(dependencies.calls).toHaveLength(3); + expect(entry.records).toHaveLength(0); + expect(entry.retryTombstone).toBe("spawn-failed"); + }); + + test("retains safe restart survivors and transfers stopped ownership to the oldest", async () => { + const tree = new DeferredProcessTree(); + const dependencies = createDependencies( + [new FakeChild(101), new FakeChild(102), new FakeChild(103)], + tree, + ); + const start = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + const stoppedOwnerActivation = await runStartupCommands( + [start], + dependencies, + ); + await runStartupCommands([start], dependencies); + await runStartupCommands([start], dependencies); + const entry = [...dependencies.state.values()][0]!; + const stoppedOwner = [...entry.records[0]!.owners][0]!; + + const restartPromise = runStartupCommands( + [ + globalCommand("helper", [], "Restart", 0, { + onExistingProcess: "restart", + stopOnExit: false, + }), + ], + dependencies, + ); + await Promise.resolve(); + await Promise.resolve(); + + tree.stops.get(103)!.resolve({ + status: "failed", + reason: "facility-unavailable", + addressability: "safe", + }); + tree.stops.get(101)!.resolve({ status: "stopped" }); + tree.stops.get(102)!.resolve({ + status: "failed", + reason: "permission-denied", + addressability: "safe", + }); + await restartPromise; + + expect(entry.status).toBe("degraded"); + expect(entry.cleanupUnconfirmed).toBe(false); + expect(entry.records.map(({ pid }) => pid)).toEqual([102, 103]); + expect(entry.records.map(({ owners }) => owners.size)).toEqual([3, 1]); + expect(entry.records[0]!.owners.has(stoppedOwner)).toBe(true); + expect(dependencies.calls).toHaveLength(3); + + await stoppedOwnerActivation.dispose(); + + expect(entry.records[0]!.owners.has(stoppedOwner)).toBe(false); + expect(entry.records.map(({ owners }) => owners.size)).toEqual([2, 1]); + expect(tree.calls.map(({ pid }) => pid).sort()).toEqual([101, 102, 103]); + }); + + test("recovers from degraded safe survivors on a later restart", async () => { + const tree = new DeferredProcessTree(); + const dependencies = createDependencies( + [ + new FakeChild(101), + new FakeChild(102), + new FakeChild(103), + new FakeChild(104), + ], + tree, + ); + const start = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + await runStartupCommands([start], dependencies); + await runStartupCommands([start], dependencies); + await runStartupCommands([start], dependencies); + + const firstRestart = runStartupCommands( + [ + globalCommand("helper", [], "First restart", 1, { + onExistingProcess: "restart", + stopOnExit: true, + }), + ], + dependencies, + ); + await Promise.resolve(); + await Promise.resolve(); + tree.stops.get(101)!.resolve({ status: "stopped" }); + tree.stops.get(102)!.resolve({ + status: "failed", + reason: "permission-denied", + addressability: "safe", + }); + tree.stops.get(103)!.resolve({ + status: "failed", + reason: "facility-unavailable", + addressability: "safe", + }); + await firstRestart; + + const entry = [...dependencies.state.values()][0]!; + expect(entry.status).toBe("degraded"); + expect(dependencies.calls).toHaveLength(3); + + const secondRestart = runStartupCommands( + [ + globalCommand("helper", [], "Second restart", 2, { + onExistingProcess: "restart", + stopOnExit: false, + }), + ], + dependencies, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(tree.calls.map(({ pid }) => pid)).toEqual([ + 101, + 102, + 103, + 102, + 103, + ]); + expect(dependencies.calls).toHaveLength(3); + + tree.stops.get(103)!.resolve({ status: "stopped" }); + tree.stops.get(102)!.resolve({ status: "stopped" }); + await secondRestart; + + expect(entry.records).toHaveLength(1); + expect(entry.records[0]?.pid).toBe(104); + expect(entry.records[0]?.owners.size).toBe(5); + expect(entry.records[0]?.stopOnExit).toBe(false); + expect(entry.status).toBe("stable"); + expect(entry.retryTombstone).toBeUndefined(); + }); + + test("blocks restart after a lost stop while allowing known-record policies", async () => { + const tree = new DeferredProcessTree(); + const dependencies = createDependencies( + [new FakeChild(101), new FakeChild(102), new FakeChild(103)], + tree, + ); + const executable = "C:\\LOST_SECRET\\helper.exe"; + const args = ["--token=LOST_SECRET"]; + const start = globalCommand(executable, args, "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + await runStartupCommands([start], dependencies); + await runStartupCommands([start], dependencies); + + const firstRestart = runStartupCommands( + [ + globalCommand(executable, args, "First restart", 1, { + onExistingProcess: "restart", + stopOnExit: true, + }), + ], + dependencies, + ); + await Promise.resolve(); + await Promise.resolve(); + tree.stops.get(101)!.resolve({ + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }); + tree.stops.get(102)!.resolve({ + status: "failed", + reason: "permission-denied", + addressability: "safe", + }); + await firstRestart; + + const entry = [...dependencies.state.values()][0]!; + expect(entry.records.map(({ pid }) => pid)).toEqual([102]); + expect(entry.records[0]?.owners.size).toBe(3); + expect(entry.cleanupUnconfirmed).toBe(true); + expect(entry.status).toBe("degraded"); + + await runStartupCommands( + [ + globalCommand(executable, args, "Additional start", 2, { + onExistingProcess: "start", + stopOnExit: false, + }), + ], + dependencies, + ); + await runStartupCommands( + [ + globalCommand(executable, args, "Known skip", 3, { + onExistingProcess: "skip", + stopOnExit: true, + }), + ], + dependencies, + ); + + expect(entry.records.map(({ pid }) => pid)).toEqual([102, 103]); + expect(entry.records.map(({ owners }) => owners.size)).toEqual([4, 1]); + expect(entry.status).toBe("degraded"); + + const eventStart = dependencies.logger.events.length; + const stopCallStart = tree.calls.length; + const blockedRestart = runStartupCommands( + [ + globalCommand(executable, args, "Blocked restart", 7, { + onExistingProcess: "restart", + stopOnExit: true, + }), + ], + dependencies, + ); + await Promise.resolve(); + await Promise.resolve(); + + for (const { pid } of tree.calls.slice(stopCallStart)) { + tree.stops.get(pid)!.resolve({ status: "stopped" }); + } + await blockedRestart; + + expect(tree.calls.map(({ pid }) => pid)).toEqual([101, 102]); + expect(dependencies.calls).toHaveLength(3); + expect(entry.records.map(({ pid }) => pid)).toEqual([102, 103]); + expect(entry.cleanupUnconfirmed).toBe(true); + expect(entry.status).toBe("degraded"); + expect(dependencies.logger.events.slice(eventStart)).toEqual([ + { type: "plugin.initialized", commandCount: 1 }, + { + type: "command.stop-failed", + scope: "global", + index: 7, + name: "Blocked restart", + pid: undefined, + trigger: "restart", + reason: "unconfirmed", + }, + ]); + expect( + JSON.stringify(dependencies.logger.events.slice(eventStart)), + ).not.toContain("LOST_SECRET"); + }); + + test("blocks every policy when lost cleanup leaves no known record", async () => { + const tree = new DeferredProcessTree(); + const dependencies = createDependencies([new FakeChild(201)], tree); + const initial = await runStartupCommands( + [ + globalCommand("helper", [], "Initial", 0, { + onExistingProcess: "skip", + stopOnExit: true, + }), + ], + dependencies, + ); + const failedRestart = runStartupCommands( + [ + globalCommand("helper", [], "Lost restart", 1, { + onExistingProcess: "restart", + stopOnExit: true, + }), + ], + dependencies, + ); + await Promise.resolve(); + await Promise.resolve(); + tree.stops.get(201)!.resolve({ + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }); + await failedRestart; + + const entry = [...dependencies.state.values()][0]!; + expect(entry.records).toHaveLength(0); + expect(entry.cleanupUnconfirmed).toBe(true); + expect(entry.status).toBe("degraded"); + expect(entry.retryTombstone).toBeUndefined(); + + for (const [index, onExistingProcess] of ( + ["start", "skip", "restart"] as const + ).entries()) { + const eventStart = dependencies.logger.events.length; + await runStartupCommands( + [ + globalCommand("helper", [], `Blocked ${onExistingProcess}`, index, { + onExistingProcess, + stopOnExit: true, + }), + ], + dependencies, + ); + expect(dependencies.logger.events.slice(eventStart)).toContainEqual({ + type: "command.skipped", + scope: "global", + index, + name: `Blocked ${onExistingProcess}`, + reason: "already-started", + }); + } + + await initial.dispose(); + + expect(dependencies.calls).toHaveLength(1); + expect(tree.calls.map(({ pid }) => pid)).toEqual([201]); + expect(entry.records).toHaveLength(0); + expect(entry.cleanupUnconfirmed).toBe(true); + expect(entry.status).toBe("degraded"); + }); + + test("blocks every policy after the first spawn attempt fails", async () => { + const dependencies = createDependencies(); + + await runStartupCommands( + [ + globalCommand("helper", [], "Failed first spawn", 4, { + onExistingProcess: "start", + stopOnExit: true, + }), + ], + dependencies, + ); + + const entry = [...dependencies.state.values()][0]!; + expect(entry.records).toHaveLength(0); + expect(entry.retryTombstone).toBe("spawn-failed"); + expect(entry.cleanupUnconfirmed).toBe(false); + expect(entry.status).toBe("stable"); + + for (const [index, onExistingProcess] of ( + ["start", "skip", "restart"] as const + ).entries()) { + const eventStart = dependencies.logger.events.length; + await runStartupCommands( + [ + globalCommand("helper", [], `Blocked ${onExistingProcess}`, index, { + onExistingProcess, + stopOnExit: true, + }), + ], + dependencies, + ); + expect(dependencies.logger.events.slice(eventStart)).toContainEqual({ + type: "command.skipped", + scope: "global", + index, + name: `Blocked ${onExistingProcess}`, + reason: "already-started", + }); + } + + expect(dependencies.calls).toHaveLength(1); + expect(entry.records).toHaveLength(0); + expect(entry.retryTombstone).toBe("spawn-failed"); + }); + + test("preserves a live survivor when an additional start spawn fails", async () => { + const tree = new FakeProcessTree(); + const dependencies = createDependencies([new FakeChild(401)], tree); + const start = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + await runStartupCommands([start], dependencies); + const failedStart = await runStartupCommands([start], dependencies); + + const entry = [...dependencies.state.values()][0]!; + expect(dependencies.calls).toHaveLength(2); + expect(entry.records.map(({ pid }) => pid)).toEqual([401]); + expect(entry.records[0]?.owners.size).toBe(1); + expect(entry.retryTombstone).toBeUndefined(); + expect(entry.cleanupUnconfirmed).toBe(false); + expect(entry.status).toBe("stable"); + + await failedStart.dispose(); + await runStartupCommands( + [ + globalCommand("helper", [], "Claim survivor", 1, { + onExistingProcess: "skip", + stopOnExit: false, + }), + ], + dependencies, + ); + + expect(dependencies.calls).toHaveLength(2); + expect(entry.records.map(({ pid }) => pid)).toEqual([401]); + expect(entry.records[0]?.owners.size).toBe(2); + expect(entry.retryTombstone).toBeUndefined(); + expect(tree.calls).toHaveLength(0); + }); + + test.each(["throw", "reject"] as const)( + "contains adapter %s failures while attempting restart siblings", + async (failureMode) => { + const rawError = new Error("RAW_ADAPTER_FAILURE"); + const stopCalls: Array = []; + const processTree: ProcessTreeController = { + stop(pid): Promise { + stopCalls.push(pid); + if (pid === 302) { + return Promise.resolve({ status: "stopped" }); + } + if (failureMode === "throw") { + throw rawError; + } + return Promise.reject(rawError); + }, + }; + const dependencies = createDependencies( + [new FakeChild(301), new FakeChild(302)], + processTree, + ); + const start = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + const first = await runStartupCommands([start], dependencies); + const second = await runStartupCommands([start], dependencies); + + const restart = await runStartupCommands( + [ + globalCommand("helper", [], "Rejected restart", 6, { + onExistingProcess: "restart", + stopOnExit: true, + }), + ], + dependencies, + ); + + const entry = [...dependencies.state.values()][0]!; + expect(stopCalls).toEqual([301, 302]); + expect(entry.records.map(({ pid }) => pid)).toEqual([301]); + expect(entry.records[0]?.owners.size).toBe(3); + expect(entry.records[0]?.status).toBe("active"); + expect(entry.status).toBe("degraded"); + expect(dependencies.calls).toHaveLength(2); + + await expect(first.dispose()).resolves.toBeUndefined(); + await expect(second.dispose()).resolves.toBeUndefined(); + await expect(restart.dispose()).resolves.toBeUndefined(); + + expect(stopCalls).toEqual([301, 302, 301]); + expect(entry.records.map(({ pid }) => pid)).toEqual([301]); + expect(entry.records[0]?.owners.size).toBe(0); + expect(entry.records[0]?.status).toBe("active"); + expect( + dependencies.logger.events.filter( + (event) => event.type === "command.stop-failed", + ), + ).toEqual([ + { + type: "command.stop-failed", + scope: "global", + index: 0, + name: "Helper", + pid: 301, + trigger: "restart", + reason: "unconfirmed", + }, + { + type: "command.stop-failed", + scope: "global", + index: 0, + name: "Helper", + pid: 301, + trigger: "scope-disposed", + reason: "unconfirmed", + }, + ]); + expect(JSON.stringify(dependencies.logger.events)).not.toContain( + "RAW_ADAPTER_FAILURE", + ); + }, + ); + + test("spawns only once for same-batch duplicate start entries", async () => { + const dependencies = createDependencies([new FakeChild(101)]); + const start = { + onExistingProcess: "start", + stopOnExit: true, + } as const; + + await runStartupCommands( + [ + globalCommand("helper", [], "First", 0, start), + globalCommand("helper", [], "Second", 1, start), + ], + dependencies, + ); + + expect(dependencies.calls).toHaveLength(1); + expect([...dependencies.state.values()][0]?.records).toHaveLength(1); + }); + + test("uses both policies from the first same-scope duplicate", async () => { + const tree = new FakeProcessTree(); + const dependencies = createDependencies( + [new FakeChild(101), new FakeChild(102)], + tree, + ); + await runStartupCommands( + [ + globalCommand("helper", [], "Initial", 0, { + onExistingProcess: "start", + stopOnExit: true, + }), + ], + dependencies, + ); + + await runStartupCommands( + [ + globalCommand("helper", [], "Start winner", 0, { + onExistingProcess: "start", + stopOnExit: false, + }), + globalCommand("helper", [], "Restart duplicate", 1, { + onExistingProcess: "restart", + stopOnExit: true, + }), + ], + dependencies, + ); + + const records = [...dependencies.state.values()][0]?.records ?? []; + expect(tree.calls).toHaveLength(0); + expect(records.map(({ pid }) => pid)).toEqual([101, 102]); + expect(records[1]?.stopOnExit).toBe(false); + }); + + test("uses global duplicate policies without giving the project duplicate an owner", async () => { + const projectRoot = resolve("workspace", "project"); + const tree = new FakeProcessTree(); + const dependencies = createDependencies( + [new FakeChild(101), new FakeChild(102)], + tree, + ); + await runStartupCommands( + [ + globalCommand("helper", [], "Initial", 0, { + onExistingProcess: "start", + stopOnExit: true, + }), + ], + dependencies, + ); + + await runStartupCommands( + [ + projectCommand(projectRoot, "helper", [], "Project duplicate", 0, { + onExistingProcess: "restart", + stopOnExit: true, + }), + globalCommand("helper", [], "Global winner", 0, { + onExistingProcess: "start", + stopOnExit: false, + }), + ], + dependencies, + ); + + const records = [...dependencies.state.values()][0]?.records ?? []; + expect(dependencies.state.size).toBe(1); + expect(tree.calls).toHaveLength(0); + expect(records.map(({ owners }) => owners.size)).toEqual([1, 1]); + expect(records[1]?.stopOnExit).toBe(false); + }); + + test("keeps identity stable when name and process policies change", async () => { + const dependencies = createDependencies([new FakeChild(101)]); + + await runStartupCommands( + [ + globalCommand("helper", ["--watch"], "Original", 0, { + onExistingProcess: "start", + stopOnExit: false, + }), + ], + dependencies, + ); + await runStartupCommands( + [ + globalCommand("helper", ["--watch"], "Changed", 7, { + onExistingProcess: "skip", + stopOnExit: true, + }), + ], + dependencies, + ); + + const record = [...dependencies.state.values()][0]?.records[0]; + expect(dependencies.calls).toHaveLength(1); + expect(dependencies.state.size).toBe(1); + expect(record?.owners.size).toBe(2); + expect(record?.stopOnExit).toBe(false); + }); + + test("stops a shared global record only after its final owner disposes", async () => { + const tree = new FakeProcessTree(); + const dependencies = createDependencies([new FakeChild(101)], tree); + const command = globalCommand("helper"); + const first = await runStartupCommands([command], dependencies); + const second = await runStartupCommands([command], dependencies); + + await first.dispose(); + expect(tree.calls).toHaveLength(0); + + const cleanup = second.dispose(); + const repeatedCleanup = second.dispose(); + expect(repeatedCleanup).toBe(cleanup); + await Promise.all([cleanup, repeatedCleanup]); + + expect(tree.calls.map(({ pid }) => pid)).toEqual([101]); + expect(dependencies.state.size).toBe(0); + }); + + test("does not stop a pre-existing ownerless true sibling it never owned", async () => { + const tree = new FakeProcessTree([ + { + status: "failed", + reason: "unconfirmed", + addressability: "safe", + }, + { status: "stopped" }, + ]); + const dependencies = createDependencies( + [new FakeChild(101), new FakeChild(102), new FakeChild(103)], + tree, + ); + const command = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + const first = await runStartupCommands([command], dependencies); + await runStartupCommands([command], dependencies); + + await first.dispose(); + expect(tree.calls.map(({ pid }) => pid)).toEqual([101]); + expect( + [...dependencies.state.values()][0]?.records[0]?.owners.size, + ).toBe(0); + + const third = await runStartupCommands([command], dependencies); + await third.dispose(); + + expect(tree.calls.map(({ pid }) => pid)).toEqual([101, 103]); + expect( + [...dependencies.state.values()][0]?.records.map(({ pid }) => pid), + ).toEqual([101, 102]); + }); + + test("shares project ownership across equivalent normalized roots", async () => { + const tree = new FakeProcessTree(); + const dependencies = createDependencies([new FakeChild(101)], tree); + const projectRoot = resolve("workspace", "project"); + const equivalentRoot = resolve(projectRoot, "nested", ".."); + const first = await runStartupCommands( + [projectCommand(projectRoot, "helper")], + dependencies, + ); + const second = await runStartupCommands( + [projectCommand(equivalentRoot, "helper")], + dependencies, + ); + + await first.dispose(); + expect(tree.calls).toHaveLength(0); + await second.dispose(); + + expect(tree.calls.map(({ pid }) => pid)).toEqual([101]); + expect(dependencies.state.size).toBe(0); + }); + + test("disposes identical project commands independently for different roots", async () => { + const tree = new FakeProcessTree(); + const dependencies = createDependencies( + [new FakeChild(101), new FakeChild(102)], + tree, + ); + const firstRoot = resolve("workspace", "first"); + const secondRoot = resolve("workspace", "second"); + const activation = await runStartupCommands( + [ + projectCommand(firstRoot, "helper"), + projectCommand(secondRoot, "helper", [], "Second", 1), + ], + dependencies, + ); + + await activation.dispose(); + + expect(tree.calls.map(({ pid }) => pid).sort()).toEqual([101, 102]); + expect(dependencies.state.size).toBe(0); + }); + + test("isolates failures while running disposal snapshots in parallel", async () => { + const stopCalls: Array<{ + pid: number | undefined; + options: ProcessTreeStopOptions; + }> = []; + const stopGates = new Map< + number | undefined, + ReturnType> + >(); + const processTree: ProcessTreeController = { + stop(pid, options): Promise { + stopCalls.push({ pid, options }); + if (pid === 901) { + options.onForce(); + } + const gate = deferred(); + stopGates.set(pid, gate); + return gate.promise; + }, + }; + const attemptedEvents: LogEvent[] = []; + const logger: Logger = { + write(event): void { + attemptedEvents.push(event); + if ( + event.type === "command.stop-requested" || + event.type === "command.stop-forced" || + event.type === "command.stop-failed" + ) { + throw new Error("Test logger failure"); + } + }, + }; + const dependencies = createDependencies( + [ + new FakeChild(901), + new FakeChild(902), + new FakeChild(903), + new FakeChild(904), + new FakeChild(905), + new FakeChild(906), + ], + processTree, + ); + dependencies.logger = logger; + const start = { + onExistingProcess: "start", + stopOnExit: true, + } as const; + const alpha = globalCommand("alpha", [], "Alpha", 0, start); + const primary = await runStartupCommands( + [ + alpha, + globalCommand("beta", [], "Beta", 1), + globalCommand("gamma", [], "Gamma", 2), + globalCommand("delta", [], "Delta", 3), + ], + dependencies, + ); + await runStartupCommands([alpha], dependencies); + await runStartupCommands([alpha], dependencies); + const alphaKey = 'global:["alpha",[]]'; + const alphaEntry = dependencies.state.get(alphaKey)!; + const primaryOwner = [...alphaEntry.records[0]!.owners][0]!; + for (const record of alphaEntry.records.slice(1)) { + record.owners.clear(); + record.owners.add(primaryOwner); + } + + const disposal = primary.dispose(); + await Promise.resolve(); + await Promise.resolve(); + + expect(stopCalls.map(({ pid }) => pid).sort()).toEqual([ + 901, + 902, + 903, + 904, + 905, + 906, + ]); + expect(stopGates.size).toBe(6); + + stopGates.get(901)!.resolve({ status: "stopped" }); + stopGates.get(902)!.resolve({ status: "stopped" }); + stopGates.get(903)!.reject(new Error("RAW_ADAPTER_REJECTION")); + stopGates.get(904)!.resolve({ status: "stopped" }); + stopGates.get(905)!.resolve({ + status: "failed", + reason: "permission-denied", + addressability: "safe", + }); + stopGates.get(906)!.resolve({ + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }); + + await expect(disposal).resolves.toBeUndefined(); + + expect(alphaEntry.records.map(({ pid }) => pid)).toEqual([905]); + expect(alphaEntry.records[0]?.owners.size).toBe(0); + expect(alphaEntry.records[0]?.status).toBe("active"); + expect(alphaEntry.cleanupUnconfirmed).toBe(true); + expect(alphaEntry.status).toBe("degraded"); + expect(dependencies.state.has('global:["beta",[]]')).toBe(false); + expect(dependencies.state.has('global:["delta",[]]')).toBe(false); + + const rejectedEntry = dependencies.state.get('global:["gamma",[]]')!; + expect(rejectedEntry.records.map(({ pid }) => pid)).toEqual([903]); + expect(rejectedEntry.records[0]?.owners.size).toBe(0); + expect(rejectedEntry.records[0]?.status).toBe("active"); + expect(rejectedEntry.cleanupUnconfirmed).toBe(false); + expect( + attemptedEvents.filter((event) => event.type === "command.stop-requested"), + ).toHaveLength(6); + expect( + attemptedEvents.filter((event) => event.type === "command.stop-forced"), + ).toEqual([ + { + type: "command.stop-forced", + scope: "global", + index: 0, + name: "Alpha", + pid: 901, + trigger: "scope-disposed", + }, + ]); + expect( + attemptedEvents.filter((event) => event.type === "command.stop-failed"), + ).toHaveLength(3); + expect(JSON.stringify(attemptedEvents)).not.toContain( + "RAW_ADAPTER_REJECTION", + ); + }); + + test("retains and reclaims an ownerless false record before restart", async () => { + const tree = new FakeProcessTree(); + const dependencies = createDependencies( + [new FakeChild(101), new FakeChild(102)], + tree, + ); + const persistent = globalCommand("helper", [], "Persistent", 0, { + onExistingProcess: "skip", + stopOnExit: false, + }); + const first = await runStartupCommands([persistent], dependencies); + const originalRecord = [...dependencies.state.values()][0]?.records[0]; + + await first.dispose(); + expect(tree.calls).toHaveLength(0); + expect(originalRecord?.owners.size).toBe(0); + expect(dependencies.state.size).toBe(1); + + await runStartupCommands( + [ + globalCommand("helper", [], "Reclaim", 0, { + onExistingProcess: "skip", + stopOnExit: true, + }), + ], + dependencies, + ); + expect(dependencies.calls).toHaveLength(1); + expect([...dependencies.state.values()][0]?.records[0]).toBe(originalRecord); + expect(originalRecord?.owners.size).toBe(1); + expect(originalRecord?.stopOnExit).toBe(false); + + await runStartupCommands( + [ + globalCommand("helper", [], "Restart", 0, { + onExistingProcess: "restart", + stopOnExit: true, + }), + ], + dependencies, + ); + + expect(tree.calls.map(({ pid }) => pid)).toEqual([101]); + expect(dependencies.calls).toHaveLength(2); + expect([...dependencies.state.values()][0]?.records[0]?.pid).toBe(102); + }); + + test("reopens an identity after confirmed final cleanup", async () => { + const tree = new FakeProcessTree(); + const dependencies = createDependencies( + [new FakeChild(101), new FakeChild(102)], + tree, + ); + const command = globalCommand("helper"); + const first = await runStartupCommands([command], dependencies); - test("creates and stores a new state when none exists", () => { - const registry: Record = {}; + await first.dispose(); + expect(dependencies.state.size).toBe(0); - const state = getOrCreateProcessState(registry); + await runStartupCommands([command], dependencies); - expect(state.started).toEqual(new Set()); - expect(registry[stateKey]).toBe(state); + expect(dependencies.calls).toHaveLength(2); + expect([...dependencies.state.values()][0]?.records[0]?.pid).toBe(102); }); - test("reuses an existing state containing a string Set", () => { - const existing: StartupState = { started: new Set(["global:signature"]) }; - const registry: Record = { [stateKey]: existing }; + test("never retains a failed stop result after the root exits", async () => { + const child = new FakeChild(101); + const tree = new DeferredProcessTree(); + const dependencies = createDependencies([child], tree); + const activation = await runStartupCommands( + [globalCommand("helper")], + dependencies, + ); - expect(getOrCreateProcessState(registry)).toBe(existing); + const cleanup = activation.dispose(); + await Promise.resolve(); + await Promise.resolve(); + expect(tree.calls).toHaveLength(1); + + child.emitExit(0, null); + tree.stops.get(101)!.resolve({ + status: "failed", + reason: "unconfirmed", + addressability: "safe", + }); + await cleanup; + + const entry = [...dependencies.state.values()][0]; + expect(entry?.records).toEqual([]); + expect(entry?.cleanupUnconfirmed).toBe(true); + expect(entry?.status).toBe("degraded"); }); - test.each([ - null, - {}, - { started: false }, - { started: { has(): boolean { return false; } } }, - { started: new Set([1]) }, - ])("replaces malformed process state: %p", (malformedState) => { - const registry: Record = { - [stateKey]: malformedState, - }; + test("keeps a disposal stop authoritative in a stop/exit race", async () => { + const child = new FakeChild(111); + const tree = new DeferredProcessTree(); + const dependencies = createDependencies([child], tree); + const activation = await runStartupCommands( + [globalCommand("helper")], + dependencies, + ); + const entry = [...dependencies.state.values()][0]!; + const record = entry.records[0]!; - const state = getOrCreateProcessState(registry); + const cleanup = activation.dispose(); + await Promise.resolve(); + await Promise.resolve(); - expect(state).not.toBe(malformedState); - expect(state.started).toEqual(new Set()); - expect(registry[stateKey]).toBe(state); + expect(tree.calls).toHaveLength(1); + expect(tree.calls[0]?.options.isRootExited()).toBe(false); + + child.emitExit(0, null); + + expect(record.rootExited).toBe(true); + expect(tree.calls[0]?.options.isRootExited()).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + expect(tree.calls).toHaveLength(1); + + tree.stops.get(111)!.resolve({ + status: "failed", + reason: "unconfirmed", + addressability: "safe", + }); + await cleanup; + await entry.transitionTail; + + expect(tree.calls.map(({ pid }) => pid)).toEqual([111]); + expect(entry.records).toEqual([]); + expect(entry.cleanupUnconfirmed).toBe(true); + expect( + dependencies.logger.events.filter( + (event) => event.type === "command.stop-requested", + ), + ).toEqual([ + { + type: "command.stop-requested", + scope: "global", + index: 0, + name: "Global command", + pid: 111, + trigger: "scope-disposed", + }, + ]); }); -}); -describe("runStartupCommands", () => { - test("launches global commands before project commands with scope-specific cwd", () => { - const firstChild = new FakeChild(101); - const secondChild = new FakeChild(102); - const dependencies = createDependencies([firstChild, secondChild]); - const projectRoot = resolve("workspace", "project"); + test("keeps a restart stop authoritative in a stop/exit race", async () => { + const child = new FakeChild(121); + const tree = new DeferredProcessTree(); + const dependencies = createDependencies( + [child, new FakeChild(122)], + tree, + ); + await runStartupCommands([globalCommand("helper")], dependencies); + const entry = [...dependencies.state.values()][0]!; - runStartupCommands( + const restart = runStartupCommands( [ - globalCommand("C:\\Tools\\global.exe", ["", " spaced value "]), - projectCommand(projectRoot, "project-tool", ["--watch"]), + globalCommand("helper", [], "Restart", 1, { + onExistingProcess: "restart", + stopOnExit: true, + }), ], dependencies, ); + await Promise.resolve(); + await Promise.resolve(); - expect(dependencies.calls).toEqual([ - [ - "C:\\Tools\\global.exe", - ["", " spaced value "], - { - detached: false, - stdio: "ignore", - windowsHide: true, - }, - ], + expect(tree.calls).toHaveLength(1); + expect(tree.calls[0]?.options.isRootExited()).toBe(false); + + child.emitExit(0, null); + + expect(tree.calls[0]?.options.isRootExited()).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + expect(tree.calls).toHaveLength(1); + + tree.stops.get(121)!.resolve({ status: "stopped" }); + await restart; + await entry.transitionTail; + + expect(tree.calls.map(({ pid }) => pid)).toEqual([121]); + expect(entry.records.map(({ pid }) => pid)).toEqual([122]); + expect(entry.retryTombstone).toBeUndefined(); + }); + + test("ignores a stale old-child exit after successful restart", async () => { + const oldChild = new FakeChild(131); + const tree = new DeferredProcessTree(); + const dependencies = createDependencies( + [oldChild, new FakeChild(132)], + tree, + ); + await runStartupCommands([globalCommand("helper")], dependencies); + const entry = [...dependencies.state.values()][0]!; + + const restart = runStartupCommands( [ - "project-tool", - ["--watch"], - { - cwd: projectRoot, - detached: false, - stdio: "ignore", - windowsHide: true, - }, + globalCommand("helper", [], "Restart", 1, { + onExistingProcess: "restart", + stopOnExit: true, + }), ], - ]); - expect(firstChild.unrefCalls).toBe(1); - expect(secondChild.unrefCalls).toBe(1); - expect(dependencies.state.started).toHaveLength(2); - expect(dependencies.logger.events).toEqual([ - { type: "plugin.initialized", commandCount: 2 }, - { - type: "command.spawned", - scope: "global", - index: 0, - name: "Global command", - pid: 101, - }, - { - type: "command.spawned", - scope: "project", - index: 0, - name: "Project command", - pid: 102, - }, - ]); + dependencies, + ); + await Promise.resolve(); + await Promise.resolve(); + tree.stops.get(131)!.resolve({ status: "stopped" }); + await restart; + const replacement = entry.records[0]!; + + oldChild.emitExit(0, null); + await entry.transitionTail; + + expect(tree.calls.map(({ pid }) => pid)).toEqual([131]); + expect(entry.records).toEqual([replacement]); + expect(replacement.pid).toBe(132); + expect(entry.retryTombstone).toBeUndefined(); + expect(entry.cleanupUnconfirmed).toBe(false); }); - test("treats ordered arguments as part of the exact command signature", () => { + test("treats ordered arguments as part of the exact command signature", async () => { const dependencies = createDependencies([ new FakeChild(), new FakeChild(), ]); - runStartupCommands( + await runStartupCommands( [ globalCommand("tool", ["first", "second"]), globalCommand("tool", ["second", "first"], "Global command", 1), @@ -273,19 +1795,17 @@ describe("runStartupCommands", () => { ); expect(dependencies.calls).toHaveLength(2); - expect(dependencies.state.started).toEqual( - new Set([ - 'global:["tool",["first","second"]]', - 'global:["tool",["second","first"]]', - ]), - ); + expect([...dependencies.state.keys()]).toEqual([ + 'global:["tool",["first","second"]]', + 'global:["tool",["second","first"]]', + ]); }); - test("suppresses exact duplicates in the current global-first command list", () => { + test("suppresses exact duplicates in the current global-first command list", async () => { const projectRoot = resolve("workspace", "project"); const dependencies = createDependencies([new FakeChild(201)]); - runStartupCommands( + await runStartupCommands( [ globalCommand("tool", ["--same"], "Global winner"), globalCommand("tool", ["--same"], "Repeated global", 1), @@ -295,9 +1815,10 @@ describe("runStartupCommands", () => { ); expect(dependencies.calls).toHaveLength(1); - expect(dependencies.state.started).toEqual( - new Set(['global:["tool",["--same"]]']), - ); + expect([...dependencies.state.keys()]).toEqual([ + 'global:["tool",["--same"]]', + ]); + expect([...dependencies.state.values()][0]?.records[0]?.owners.size).toBe(1); expect(dependencies.logger.events).toEqual([ { type: "plugin.initialized", commandCount: 3 }, { @@ -324,11 +1845,11 @@ describe("runStartupCommands", () => { ]); }); - test("reports a same-project duplicate with its original index and name", () => { + test("reports a same-project duplicate with its original index and name", async () => { const projectRoot = resolve("workspace", "project"); const dependencies = createDependencies([new FakeChild(202)]); - runStartupCommands( + await runStartupCommands( [ projectCommand(projectRoot, "tool", ["--same"], "Project winner"), projectCommand( @@ -362,7 +1883,7 @@ describe("runStartupCommands", () => { ]); }); - test("processes globals first and lets an exact global duplicate win over an earlier project", () => { + test("processes globals first and lets an exact global duplicate win over an earlier project", async () => { const projectRoot = resolve("workspace", "project"); const dependencies = createDependencies([ new FakeChild(), @@ -372,7 +1893,7 @@ describe("runStartupCommands", () => { new FakeChild(), ]); - runStartupCommands( + await runStartupCommands( [ projectCommand(projectRoot, "project-first", [], "Project first", 0), projectCommand( @@ -404,7 +1925,8 @@ describe("runStartupCommands", () => { "project-second", ]); expect(dependencies.calls[1]?.[2]).toEqual({ - detached: false, + detached: true, + shell: false, stdio: "ignore", windowsHide: true, }); @@ -417,21 +1939,22 @@ describe("runStartupCommands", () => { }); }); - test("deduplicates the current list before checking process state so global still wins", () => { + test("deduplicates the current list before checking process state so global still wins", async () => { const projectRoot = resolve("workspace", "project"); const dependencies = createDependencies([new FakeChild()]); const command = globalCommand("tool", ["--same"]); - runStartupCommands([command], dependencies); - runStartupCommands( + await runStartupCommands([command], dependencies); + await runStartupCommands( [command, projectCommand(projectRoot, "tool", ["--same"])], dependencies, ); expect(dependencies.calls).toHaveLength(1); - expect(dependencies.state.started).toEqual( - new Set(['global:["tool",["--same"]]']), - ); + expect([...dependencies.state.keys()]).toEqual([ + 'global:["tool",["--same"]]', + ]); + expect([...dependencies.state.values()][0]?.records[0]?.owners.size).toBe(2); expect(dependencies.logger.events).toContainEqual({ type: "command.skipped", scope: "project", @@ -452,7 +1975,7 @@ describe("runStartupCommands", () => { }); }); - test("starts the same global command once across repeated initialization", () => { + test("starts the same global command once across repeated initialization", async () => { const dependencies = createDependencies([new FakeChild()]); const command = globalCommand( "launcher", @@ -461,10 +1984,11 @@ describe("runStartupCommands", () => { 4, ); - runStartupCommands([command], dependencies); - runStartupCommands([command], dependencies); + await runStartupCommands([command], dependencies); + await runStartupCommands([command], dependencies); expect(dependencies.calls).toHaveLength(1); + expect([...dependencies.state.values()][0]?.records[0]?.owners.size).toBe(2); expect(dependencies.logger.events).toContainEqual({ type: "command.skipped", scope: "global", @@ -478,21 +2002,23 @@ describe("runStartupCommands", () => { }); }); - test("starts the same project command once for equivalent normalized roots", () => { + test("starts the same project command once for equivalent normalized roots", async () => { const projectRoot = resolve("workspace", "project"); const equivalentRoot = resolve(projectRoot, "nested", ".."); const dependencies = createDependencies([new FakeChild()]); - runStartupCommands( + await runStartupCommands( [projectCommand(projectRoot, "launcher", ["--project"])], dependencies, ); - runStartupCommands( + await runStartupCommands( [projectCommand(equivalentRoot, "launcher", ["--project"])], dependencies, ); expect(dependencies.calls).toHaveLength(1); + expect(dependencies.state.size).toBe(1); + expect([...dependencies.state.values()][0]?.records[0]?.owners.size).toBe(2); expect(dependencies.logger.events).toContainEqual({ type: "command.skipped", scope: "project", @@ -504,15 +2030,15 @@ describe("runStartupCommands", () => { test.skipIf(process.platform !== "win32")( "treats Windows project-root case variants as the same root", - () => { + async () => { const projectRoot = resolve("workspace", "CaseSensitiveName"); const dependencies = createDependencies([new FakeChild()]); - runStartupCommands( + await runStartupCommands( [projectCommand(projectRoot, "launcher")], dependencies, ); - runStartupCommands( + await runStartupCommands( [projectCommand(projectRoot.toUpperCase(), "launcher")], dependencies, ); @@ -521,7 +2047,7 @@ describe("runStartupCommands", () => { }, ); - test("starts identical project commands once in each different project root", () => { + test("starts identical project commands once in each different project root", async () => { const firstRoot = resolve("workspace", "first"); const secondRoot = resolve("workspace", "second"); const dependencies = createDependencies([ @@ -529,11 +2055,11 @@ describe("runStartupCommands", () => { new FakeChild(), ]); - runStartupCommands( + await runStartupCommands( [projectCommand(firstRoot, "launcher", ["--project"])], dependencies, ); - runStartupCommands( + await runStartupCommands( [projectCommand(secondRoot, "launcher", ["--project"])], dependencies, ); @@ -541,19 +2067,21 @@ describe("runStartupCommands", () => { expect(dependencies.calls).toHaveLength(2); expect(dependencies.calls[0]?.[2]).toEqual({ cwd: firstRoot, - detached: false, + detached: true, + shell: false, stdio: "ignore", windowsHide: true, }); expect(dependencies.calls[1]?.[2]).toEqual({ cwd: secondRoot, - detached: false, + detached: true, + shell: false, stdio: "ignore", windowsHide: true, }); }); - test("keeps identical project commands for different roots in the same batch", () => { + test("keeps identical project commands for different roots in the same batch", async () => { const firstRoot = resolve("workspace", "first"); const secondRoot = resolve("workspace", "second"); const dependencies = createDependencies([ @@ -561,7 +2089,7 @@ describe("runStartupCommands", () => { new FakeChild(), ]); - runStartupCommands( + await runStartupCommands( [ projectCommand(firstRoot, "launcher", ["--project"]), projectCommand( @@ -579,65 +2107,220 @@ describe("runStartupCommands", () => { expect(dependencies.calls.map((call) => call[2])).toEqual([ { cwd: firstRoot, - detached: false, + detached: true, + shell: false, stdio: "ignore", windowsHide: true, }, { cwd: secondRoot, - detached: false, + detached: true, + shell: false, stdio: "ignore", windowsHide: true, }, ]); }); - test("inserts the identity before spawn so a concurrent reentrant run is suppressed", () => { - const state: StartupState = { started: new Set() }; + test("serializes concurrent empty skip activations into one record with two owners", async () => { + const state = createStartupState(); const logger = createLogger(); + const processTree = new FakeProcessTree(); const calls: unknown[][] = []; const command = globalCommand("reentrant"); const child = new FakeChild(); + let secondRun: ReturnType | undefined; let dependencies: { spawn: SpawnFunction; state: StartupState; + processTree: ProcessTreeController; logger: Logger; }; const spawn: SpawnFunction = (executable, args, options) => { calls.push([executable, args, options]); - runStartupCommands([command], dependencies); + secondRun = runStartupCommands([command], dependencies); return child; }; - dependencies = { spawn, state, logger }; + dependencies = { spawn, state, processTree, logger }; - runStartupCommands([command], dependencies); + await runStartupCommands([command], dependencies); + await secondRun; expect(calls).toHaveLength(1); expect(child.unrefCalls).toBe(1); + expect(state.size).toBe(1); + expect([...state.values()][0]?.records).toHaveLength(1); + expect([...state.values()][0]?.records[0]?.owners.size).toBe(2); expect(logger.events).toEqual([ { type: "plugin.initialized", commandCount: 1 }, { type: "plugin.initialized", commandCount: 1 }, { - type: "command.skipped", + type: "command.spawned", scope: "global", index: 0, name: "Global command", - reason: "already-started", + pid: undefined, }, - { type: "batch.skipped", reason: "already-started" }, { - type: "command.spawned", + type: "command.skipped", scope: "global", index: 0, name: "Global command", - pid: undefined, + reason: "already-started", }, + { type: "batch.skipped", reason: "already-started" }, + ]); + }); + + test("orders restart before disposal and removes the inherited owner", async () => { + const tree = new DeferredProcessTree(); + const dependencies = createDependencies( + [new FakeChild(801), new FakeChild(802)], + tree, + ); + const command = globalCommand("helper"); + const disposedActivation = await runStartupCommands( + [command], + dependencies, + ); + await runStartupCommands([command], dependencies); + const entry = [...dependencies.state.values()][0]!; + const disposedOwner = [...entry.records[0]!.owners][0]!; + + const restart = runStartupCommands( + [ + globalCommand("helper", [], "Restart", 1, { + onExistingProcess: "restart", + stopOnExit: true, + }), + ], + dependencies, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(tree.calls.map(({ pid }) => pid)).toEqual([801]); + const disposal = disposedActivation.dispose(); + expect(entry.pendingTransitions).toBe(2); + + tree.stops.get(801)!.resolve({ status: "stopped" }); + await Promise.all([restart, disposal]); + + const replacement = entry.records[0]!; + expect(replacement.pid).toBe(802); + expect(replacement.owners.size).toBe(2); + expect(replacement.owners.has(disposedOwner)).toBe(false); + expect(tree.calls.map(({ pid }) => pid)).toEqual([801]); + }); + + test("orders disposal before restart and excludes the disposed owner", async () => { + const tree = new DeferredProcessTree(); + const dependencies = createDependencies( + [new FakeChild(811), new FakeChild(812)], + tree, + ); + const command = globalCommand("helper"); + const disposedActivation = await runStartupCommands( + [command], + dependencies, + ); + await runStartupCommands([command], dependencies); + const entry = [...dependencies.state.values()][0]!; + const disposedOwner = [...entry.records[0]!.owners][0]!; + + const disposal = disposedActivation.dispose(); + const restart = runStartupCommands( + [ + globalCommand("helper", [], "Restart", 1, { + onExistingProcess: "restart", + stopOnExit: true, + }), + ], + dependencies, + ); + await disposal; + await Promise.resolve(); + await Promise.resolve(); + + expect(tree.calls.map(({ pid }) => pid)).toEqual([811]); + expect(entry.records[0]!.owners.has(disposedOwner)).toBe(false); + + tree.stops.get(811)!.resolve({ status: "stopped" }); + await Promise.all([disposal, restart]); + + const replacement = entry.records[0]!; + expect(replacement.pid).toBe(812); + expect(replacement.owners.size).toBe(2); + expect(replacement.owners.has(disposedOwner)).toBe(false); + }); + + test("orders two gated empty skip activations into one spawn", async () => { + const gate = deferred(); + const processKey = 'global:["helper",[]]'; + const dependencies = createDependencies([new FakeChild(821)]); + dependencies.state = createGatedIdentityState(processKey, gate.promise); + const command = globalCommand("helper"); + + const first = runStartupCommands([command], dependencies); + const second = runStartupCommands([command], dependencies); + await Promise.resolve(); + + expect(dependencies.calls).toEqual([]); + gate.resolve(); + await Promise.all([first, second]); + + const entry = dependencies.state.get(processKey)!; + expect(dependencies.calls).toHaveLength(1); + expect(entry.records).toHaveLength(1); + expect(entry.records[0]?.pid).toBe(821); + expect(entry.records[0]?.owners.size).toBe(2); + }); + + test("does not share queue state with a separately initiated key", async () => { + const gate = deferred(); + const blockedKey = 'global:["blocked",[]]'; + const dependencies = createDependencies([ + new FakeChild(832), + new FakeChild(831), + ]); + dependencies.state = createGatedIdentityState(blockedKey, gate.promise); + let blockedSettled = false; + + const blocked = runStartupCommands( + [globalCommand("blocked")], + dependencies, + ).then((activation) => { + blockedSettled = true; + return activation; + }); + const independent = runStartupCommands( + [globalCommand("independent")], + dependencies, + ); + + await independent; + + expect(blockedSettled).toBe(false); + expect(dependencies.calls.map(([executable]) => executable)).toEqual([ + "independent", + ]); + + gate.resolve(); + await blocked; + + expect(dependencies.calls.map(([executable]) => executable)).toEqual([ + "independent", + "blocked", ]); + expect( + [...dependencies.state.values()].map(({ records }) => records[0]?.pid), + ).toEqual([831, 832]); }); - test("does not retry after synchronous spawn failure and continues the batch", () => { + test("does not retry after synchronous spawn failure and continues the batch", async () => { const logger = createLogger(); - const state: StartupState = { started: new Set() }; + const state = createStartupState(); + const processTree = new FakeProcessTree(); const calls: unknown[][] = []; const secondChild = new FakeChild(); const secret = "SYNC_SECRET_VALUE"; @@ -662,13 +2345,20 @@ describe("runStartupCommands", () => { ), globalCommand("second.exe", [], "Second", 4), ]; - const dependencies = { spawn, state, logger }; + const dependencies = { spawn, state, processTree, logger }; - runStartupCommands(commands, dependencies); - runStartupCommands([commands[0]!], dependencies); + await runStartupCommands(commands, dependencies); + await runStartupCommands([commands[0]!], dependencies); expect(calls).toHaveLength(2); expect(secondChild.unrefCalls).toBe(1); + expect(state.size).toBe(2); + expect([...state.values()]).toContainEqual( + expect.objectContaining({ + records: [], + retryTombstone: "spawn-failed", + }), + ); expect(logger.events).toContainEqual({ type: "command.spawn-failed", scope: "global", @@ -679,7 +2369,279 @@ describe("runStartupCommands", () => { expect(JSON.stringify(logger.events)).not.toContain(secret); }); - test("contains logger failures during synchronous and asynchronous lifecycle logging", () => { + test("removes a final pre-PID child error and records a spawn tombstone", async () => { + const child = new FakeChild(); + const dependencies = createDependencies([child]); + + await runStartupCommands([globalCommand("helper")], dependencies); + const entry = [...dependencies.state.values()][0]!; + const attemptedRecord = entry.records[0]; + + child.emitError(Object.assign(new Error("launch failed"), { code: "ENOENT" })); + await entry.transitionTail; + + expect(attemptedRecord).toBeDefined(); + expect(entry.records).toEqual([]); + expect(entry.retryTombstone).toBe("spawn-failed"); + expect(dependencies.logger.events).toContainEqual({ + type: "command.child-error", + scope: "global", + index: 0, + name: "Global command", + code: "ENOENT", + }); + }); + + test("keeps a post-PID child error diagnostic until process cleanup", async () => { + const child = new FakeChild(201); + const dependencies = createDependencies([child]); + + await runStartupCommands([globalCommand("helper")], dependencies); + const entry = [...dependencies.state.values()][0]!; + const record = entry.records[0]; + + child.emitError(new Error("runtime failure")); + await entry.transitionTail; + + expect(entry.records).toEqual([record]); + expect(entry.retryTombstone).toBeUndefined(); + expect(dependencies.logger.events).toContainEqual({ + type: "command.child-error", + scope: "global", + index: 0, + name: "Global command", + code: undefined, + }); + }); + + test("removes only the attempted sibling after a pre-PID child error", async () => { + const survivor = new FakeChild(201); + const failedChild = new FakeChild(); + const dependencies = createDependencies([survivor, failedChild]); + const command = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + + await runStartupCommands([command], dependencies); + await runStartupCommands([command], dependencies); + const entry = [...dependencies.state.values()][0]!; + const survivorRecord = entry.records[0]; + const attemptedRecord = entry.records[1]; + + failedChild.emitError(new Error("launch failed")); + await entry.transitionTail; + + expect(entry.records).toEqual([survivorRecord]); + expect(entry.records).not.toContain(attemptedRecord); + expect(entry.retryTombstone).toBeUndefined(); + }); + + test("cleans residual processes and tombstones a final natural exit", async () => { + const child = new FakeChild(301); + const tree = new DeferredProcessTree(); + const dependencies = createDependencies([child], tree); + + await runStartupCommands( + [ + globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "skip", + stopOnExit: false, + }), + ], + dependencies, + ); + const entry = [...dependencies.state.values()][0]!; + const record = entry.records[0]!; + + child.emitExit(0, null); + + expect(record.rootExited).toBe(true); + expect(dependencies.logger.events).toContainEqual({ + type: "command.exited", + scope: "global", + index: 0, + name: "Helper", + exitCode: 0, + signal: null, + }); + await Promise.resolve(); + await Promise.resolve(); + + const stop = tree.stops.get(301); + expect(tree.calls).toHaveLength(1); + expect(stop).toBeDefined(); + expect(tree.calls[0]?.options.isRootExited()).toBe(true); + expect(dependencies.logger.events).toContainEqual({ + type: "command.stop-requested", + scope: "global", + index: 0, + name: "Helper", + pid: 301, + trigger: "root-exited", + }); + + stop?.resolve({ status: "stopped" }); + await entry.transitionTail; + + expect(entry.records).toEqual([]); + expect(entry.retryTombstone).toBe("natural-exit"); + expect(entry.cleanupUnconfirmed).toBe(false); + }); + + test("never reuses a PID after unconfirmed final natural exit cleanup", async () => { + const child = new FakeChild(302); + const tree = new DeferredProcessTree(); + const dependencies = createDependencies( + [child, new FakeChild(303)], + tree, + ); + const command = globalCommand("helper"); + const activation = await runStartupCommands([command], dependencies); + const entry = [...dependencies.state.values()][0]!; + + child.emitExit(1, null); + await Promise.resolve(); + await Promise.resolve(); + + const stop = tree.stops.get(302); + expect(stop).toBeDefined(); + stop?.resolve({ + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }); + await entry.transitionTail; + + expect(entry.records).toEqual([]); + expect(entry.retryTombstone).toBe("natural-exit"); + expect(entry.cleanupUnconfirmed).toBe(true); + expect(entry.status).toBe("degraded"); + + await activation.dispose(); + await runStartupCommands([command], dependencies); + + expect(tree.calls.map(({ pid }) => pid)).toEqual([302]); + expect(dependencies.calls).toHaveLength(1); + expect(entry.records).toEqual([]); + }); + + test("continues all policies after confirmed sibling natural exit cleanup", async () => { + const exitedChild = new FakeChild(401); + const survivor = new FakeChild(402); + const tree = new FakeProcessTree(); + const dependencies = createDependencies( + [exitedChild, survivor, new FakeChild(403), new FakeChild(404)], + tree, + ); + const start = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + + await runStartupCommands([start], dependencies); + await runStartupCommands([start], dependencies); + const entry = [...dependencies.state.values()][0]!; + const survivorRecord = entry.records[1]!; + + exitedChild.emitExit(0, null); + await entry.transitionTail; + + expect(entry.records).toEqual([survivorRecord]); + expect(entry.retryTombstone).toBeUndefined(); + expect(entry.cleanupUnconfirmed).toBe(false); + + await runStartupCommands([start], dependencies); + await runStartupCommands( + [ + globalCommand("helper", [], "Skip", 1, { + onExistingProcess: "skip", + stopOnExit: false, + }), + ], + dependencies, + ); + + expect(entry.records.map(({ pid }) => pid)).toEqual([402, 403]); + expect(survivorRecord.owners.size).toBe(2); + + await runStartupCommands( + [ + globalCommand("helper", [], "Restart", 2, { + onExistingProcess: "restart", + stopOnExit: true, + }), + ], + dependencies, + ); + + expect(tree.calls.map(({ pid }) => pid)).toEqual([401, 402, 403]); + expect(entry.records.map(({ pid }) => pid)).toEqual([404]); + }); + + test("blocks only restart after unconfirmed sibling natural exit cleanup", async () => { + const exitedChild = new FakeChild(501); + const survivor = new FakeChild(502); + const tree = new DeferredProcessTree(); + const dependencies = createDependencies( + [exitedChild, survivor, new FakeChild(503)], + tree, + ); + const start = globalCommand("helper", [], "Helper", 0, { + onExistingProcess: "start", + stopOnExit: true, + }); + + await runStartupCommands([start], dependencies); + await runStartupCommands([start], dependencies); + const entry = [...dependencies.state.values()][0]!; + const survivorRecord = entry.records[1]!; + + exitedChild.emitExit(0, null); + await Promise.resolve(); + await Promise.resolve(); + + const stop = tree.stops.get(501); + expect(stop).toBeDefined(); + stop?.resolve({ + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }); + await entry.transitionTail; + + expect(entry.records).toEqual([survivorRecord]); + expect(entry.retryTombstone).toBeUndefined(); + expect(entry.cleanupUnconfirmed).toBe(true); + + await runStartupCommands([start], dependencies); + await runStartupCommands( + [ + globalCommand("helper", [], "Skip", 1, { + onExistingProcess: "skip", + stopOnExit: false, + }), + ], + dependencies, + ); + await runStartupCommands( + [ + globalCommand("helper", [], "Blocked restart", 2, { + onExistingProcess: "restart", + stopOnExit: true, + }), + ], + dependencies, + ); + + expect(tree.calls.map(({ pid }) => pid)).toEqual([501]); + expect(dependencies.calls).toHaveLength(3); + expect(entry.records.map(({ pid }) => pid)).toEqual([502, 503]); + expect(survivorRecord.owners.size).toBe(2); + expect(entry.status).toBe("degraded"); + }); + + test("contains logger failures during synchronous and asynchronous lifecycle logging", async () => { const child = new FakeChild(505); const attemptedEvents: LogEvent[] = []; const calls: unknown[][] = []; @@ -691,16 +2653,17 @@ describe("runStartupCommands", () => { }; const dependencies = { spawn: createSpawn([child], calls), - state: { started: new Set() }, + state: createStartupState(), + processTree: new FakeProcessTree(), logger, }; - expect(() => + await expect( runStartupCommands([globalCommand("logger-failure")], dependencies), - ).not.toThrow(); + ).resolves.toBeDefined(); expect(() => child.emitError(new Error("child failure"))).not.toThrow(); expect(() => child.emitExit(0, null)).not.toThrow(); - expect(() => + await expect( runStartupCommands( [ globalCommand("logger-failure"), @@ -708,7 +2671,7 @@ describe("runStartupCommands", () => { ], dependencies, ), - ).not.toThrow(); + ).resolves.toBeDefined(); expect(calls).toHaveLength(1); expect(child.unrefCalls).toBe(1); @@ -719,18 +2682,19 @@ describe("runStartupCommands", () => { "command.exited", "plugin.initialized", "command.skipped", + "command.stop-requested", "command.skipped", "batch.skipped", ]); }); - test("guards listener registration without reporting a successful spawn as failed", () => { + test("guards listener registration without reporting a successful spawn as failed", async () => { const child = new FakeChild(606, { throwOnOnce: true }); const dependencies = createDependencies([child]); - expect(() => + await expect( runStartupCommands([globalCommand("once-failure")], dependencies), - ).not.toThrow(); + ).resolves.toBeDefined(); expect(child.onceCalls).toEqual(["error", "exit"]); expect(child.unrefCalls).toBe(1); @@ -746,13 +2710,13 @@ describe("runStartupCommands", () => { ); }); - test("guards unref without reporting a successful spawn as failed", () => { + test("guards unref without reporting a successful spawn as failed", async () => { const child = new FakeChild(707, { throwOnUnref: true }); const dependencies = createDependencies([child]); - expect(() => + await expect( runStartupCommands([globalCommand("unref-failure")], dependencies), - ).not.toThrow(); + ).resolves.toBeDefined(); expect(child.unrefCalls).toBe(1); expect(dependencies.logger.events).toContainEqual({ @@ -767,12 +2731,12 @@ describe("runStartupCommands", () => { ); }); - test("logs PID, sanitized child errors, and exit lifecycle without unsafe command data", () => { + test("logs PID, sanitized child errors, and exit lifecycle without unsafe command data", async () => { const child = new FakeChild(404); const dependencies = createDependencies([child]); const secret = "ASYNC_SECRET_VALUE"; - runStartupCommands( + await runStartupCommands( [ globalCommand( `C:\\${secret}\\launcher.exe`, diff --git a/tests/fixtures/process-tree-child.mjs b/tests/fixtures/process-tree-child.mjs new file mode 100644 index 0000000..67e0b46 --- /dev/null +++ b/tests/fixtures/process-tree-child.mjs @@ -0,0 +1,5 @@ +if (process.argv[2] === "ignore-term" && process.platform !== "win32") { + process.on("SIGTERM", () => {}); +} + +setInterval(() => {}, 1_000); diff --git a/tests/fixtures/process-tree-parent.mjs b/tests/fixtures/process-tree-parent.mjs new file mode 100644 index 0000000..a5bed3b --- /dev/null +++ b/tests/fixtures/process-tree-parent.mjs @@ -0,0 +1,27 @@ +import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const pidFile = process.argv[2]; +const mode = process.argv[3] ?? "normal"; +const childPath = fileURLToPath( + new URL("./process-tree-child.mjs", import.meta.url), +); +const child = spawn(process.execPath, [childPath, mode], { + detached: false, + shell: false, + stdio: "ignore", + windowsHide: true, +}); + +writeFileSync( + pidFile, + JSON.stringify({ parentPid: process.pid, childPid: child.pid }), + "utf8", +); + +if (mode === "ignore-term" && process.platform !== "win32") { + process.on("SIGTERM", () => {}); +} + +setInterval(() => {}, 1_000); diff --git a/tests/logger.test.ts b/tests/logger.test.ts index a186e5c..9fca359 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -147,6 +147,45 @@ const FILE_EVENT_CASES = [ expected: 'command.skipped scope=global index=6 name="Global helper" reason=duplicate', }, + { + label: "project stop requested", + event: { + type: "command.stop-requested", + scope: "project", + index: 2, + name: "Project helper", + pid: 321, + trigger: "scope-disposed", + }, + expected: + 'command.stop-requested scope=project index=2 name="Project helper" pid=321 trigger=scope-disposed', + }, + { + label: "global restart forced", + event: { + type: "command.stop-forced", + scope: "global", + index: 1, + name: "Global helper", + pid: 654, + trigger: "restart", + }, + expected: + 'command.stop-forced scope=global index=1 name="Global helper" pid=654 trigger=restart', + }, + { + label: "root cleanup failed", + event: { + type: "command.stop-failed", + scope: "project", + index: 3, + name: "Failed helper", + trigger: "root-exited", + reason: "unconfirmed", + }, + expected: + 'command.stop-failed scope=project index=3 name="Failed helper" trigger=root-exited reason=unconfirmed', + }, ] satisfies Array<{ label: string; event: LogEvent; expected: string }>; describe("Logger console output", () => { @@ -171,6 +210,32 @@ describe("Logger console output", () => { ]); }); + test("sanitizes stop lifecycle names without exposing raw errors", () => { + const output = createConsole(); + const directory = createTempDirectory(); + const logger = createLogger({ + console: output, + filePath: join(directory, "plugin.log"), + }); + const secret = "SECRET_RAW_STOP_ERROR"; + + logger.write({ + type: "command.stop-failed", + scope: "project", + index: 8, + name: "Failed\r\nhelper\u0000", + pid: 987, + trigger: "root-exited", + reason: "permission-denied", + rawError: new Error(secret), + } as LogEvent & { rawError: Error }); + + expect(output.lines).toEqual([ + 'startup-commands: stop failed scope=project index=8 name="Failed helper " pid=987 trigger=root-exited reason=permission-denied', + ]); + expect(output.lines.join(" ")).not.toContain(secret); + }); + test("writes fixed scope-aware configuration diagnostics", () => { const output = createConsole(); const directory = createTempDirectory(); diff --git a/tests/logger.type-test.ts b/tests/logger.type-test.ts index befab4f..a355c15 100644 --- a/tests/logger.type-test.ts +++ b/tests/logger.type-test.ts @@ -1,4 +1,4 @@ -import type { LogEvent } from "../src/logger.js"; +import type { CommandStopTrigger, LogEvent } from "../src/logger.js"; const validSpawn = { type: "command.spawned", @@ -9,6 +9,153 @@ const validSpawn = { } satisfies LogEvent; void validSpawn; +const validStopTriggers = [ + "scope-disposed", + "root-exited", + "restart", +] satisfies CommandStopTrigger[]; +void validStopTriggers; + +const validStopRequested = { + type: "command.stop-requested", + scope: "project", + index: 1, + name: "Project helper", + pid: 321, + trigger: "scope-disposed", +} satisfies LogEvent; +void validStopRequested; + +const validStopForced = { + type: "command.stop-forced", + scope: "global", + index: 2, + name: "Global helper", + trigger: "restart", +} satisfies LogEvent; +void validStopForced; + +const validStopFailed = { + type: "command.stop-failed", + scope: "project", + index: 3, + name: "Failed helper", + trigger: "root-exited", + reason: "unconfirmed", +} satisfies LogEvent; +void validStopFailed; + +type CommandStopFailureReason = Extract< + LogEvent, + { type: "command.stop-failed" } +>["reason"]; + +const validStopFailureReasons = [ + "missing-pid", + "permission-denied", + "facility-unavailable", + "unconfirmed", +] satisfies CommandStopFailureReason[]; +void validStopFailureReasons; + +const invalidStopTrigger: LogEvent = { + type: "command.stop-requested", + scope: "global", + index: 0, + name: "Helper", + // @ts-expect-error stop triggers use a closed set of lifecycle causes + trigger: "shutdown", +}; +void invalidStopTrigger; + +const invalidStopFailureReason: LogEvent = { + type: "command.stop-failed", + scope: "project", + index: 0, + name: "Helper", + trigger: "scope-disposed", + // @ts-expect-error stop failures use process-tree failure reasons + reason: "unknown", +}; +void invalidStopFailureReason; + +const unsafeStopExecutable: LogEvent = { + ...validStopRequested, + // @ts-expect-error executable paths are not valid stop fields + executable: "C:\\secret\\helper.exe", +}; +void unsafeStopExecutable; + +const unsafeStopArgs: LogEvent = { + ...validStopRequested, + // @ts-expect-error command arguments are not valid stop fields + args: ["--token", "secret"], +}; +void unsafeStopArgs; + +const unsafeStopEnv: LogEvent = { + ...validStopRequested, + // @ts-expect-error environment values are not valid stop fields + env: { SECRET: "value" }, +}; +void unsafeStopEnv; + +const unsafeStopConfiguration: LogEvent = { + ...validStopRequested, + // @ts-expect-error configuration is not a valid stop field + configuration: { commands: [] }, +}; +void unsafeStopConfiguration; + +const unsafeStopStdout: LogEvent = { + ...validStopRequested, + // @ts-expect-error stdout is not a valid stop field + stdout: "secret output", +}; +void unsafeStopStdout; + +const unsafeStopStderr: LogEvent = { + ...validStopRequested, + // @ts-expect-error stderr is not a valid stop field + stderr: "secret output", +}; +void unsafeStopStderr; + +const unsafeStopOutput: LogEvent = { + ...validStopRequested, + // @ts-expect-error utility output is not a valid stop field + output: "secret output", +}; +void unsafeStopOutput; + +const unsafeStopRawError: LogEvent = { + ...validStopRequested, + // @ts-expect-error raw errors are not valid stop fields + rawError: new Error("secret"), +}; +void unsafeStopRawError; + +const unsafeStopMessage: LogEvent = { + ...validStopRequested, + // @ts-expect-error error messages are not valid stop fields + message: "secret", +}; +void unsafeStopMessage; + +const unsafeStopStack: LogEvent = { + ...validStopRequested, + // @ts-expect-error error stacks are not valid stop fields + stack: "secret", +}; +void unsafeStopStack; + +const unsafeStopPath: LogEvent = { + ...validStopRequested, + // @ts-expect-error paths are not valid stop fields + path: "C:\\secret", +}; +void unsafeStopPath; + const unsafeCommand: LogEvent = { type: "command.spawned", scope: "global", @@ -87,6 +234,11 @@ type UnsafeLogField = | "content" | "executable" | "args" + | "env" + | "configuration" + | "stdout" + | "stderr" + | "output" | "error" | "rawError" | "message" @@ -129,3 +281,12 @@ type CommandChildErrorIsSafe = AssertNoUnsafeFields< type CommandExitedIsSafe = AssertNoUnsafeFields< UnsafeFieldsOf<"command.exited"> >; +type CommandStopRequestedIsSafe = AssertNoUnsafeFields< + UnsafeFieldsOf<"command.stop-requested"> +>; +type CommandStopForcedIsSafe = AssertNoUnsafeFields< + UnsafeFieldsOf<"command.stop-forced"> +>; +type CommandStopFailedIsSafe = AssertNoUnsafeFields< + UnsafeFieldsOf<"command.stop-failed"> +>; diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index f780de0..729722e 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.0.0"); + expect(manifest.version).toBe("1.1.0"); expect(manifest).not.toHaveProperty("private"); expect(manifest.description).toBe( "Launch trusted background commands when OpenCode initializes a project or directory.", diff --git a/tests/process-tree.test.ts b/tests/process-tree.test.ts new file mode 100644 index 0000000..c406219 --- /dev/null +++ b/tests/process-tree.test.ts @@ -0,0 +1,1021 @@ +import { describe, expect, test } from "bun:test"; +import { spawn as spawnFixtureProcess } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createProcessTreeController, + type ProcessTreeController, + type ProcessTreeStopResult, + type ProcessUtilityChild, + type ProcessUtilitySpawn, +} from "../src/process-tree.js"; + +const stopped = { status: "stopped" } satisfies ProcessTreeStopResult; +const lostFailure = { + status: "failed", + reason: "unconfirmed", + addressability: "lost", +} satisfies ProcessTreeStopResult; +void stopped; +void lostFailure; + +function acceptsController(controller: ProcessTreeController): void { + void controller; +} + +acceptsController(createProcessTreeController()); + +describe("process-tree controller contract", () => { + test("exports utility child and spawn types", () => { + const acceptsUtilityChild = (child: ProcessUtilityChild): void => { + void child; + }; + const acceptsUtilitySpawn = (spawn: ProcessUtilitySpawn): void => { + void spawn; + }; + + expect(typeof acceptsUtilityChild).toBe("function"); + expect(typeof acceptsUtilitySpawn).toBe("function"); + }); +}); + +describe("PID validation", () => { + test.each([ + undefined, + 0, + -1, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + ])("rejects an unsafe process identifier: %p", async (pid) => { + let killCalls = 0; + const controller = createProcessTreeController({ + platform: "linux", + kill(): boolean { + killCalls += 1; + return true; + }, + }); + + await expect( + controller.stop(pid, { + isRootExited: () => false, + onForce(): void {}, + }), + ).resolves.toEqual({ + status: "failed", + reason: "missing-pid", + addressability: "lost", + }); + expect(killCalls).toBe(0); + }); +}); + +type SignalCall = [number, NodeJS.Signals | 0 | undefined]; + +function systemError(code: string): Error & { code: string } { + return Object.assign(new Error("Controlled process error"), { code }); +} + +type UtilityOutcome = + | { event: "error"; error: Error } + | { + event: "exit"; + code: number | null; + signal: NodeJS.Signals | null; + }; + +class FakeUtilityChild implements ProcessUtilityChild { + public readonly killCalls: Array = []; + public unrefCalls = 0; + private readonly errorListeners: Array<(error: Error) => void> = []; + private readonly exitListeners: Array< + (code: number | null, signal: NodeJS.Signals | null) => void + > = []; + private scheduled = false; + + public constructor( + private readonly outcome?: UtilityOutcome, + private readonly behavior: { + throwOnKill?: 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 { + 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, + ); + } + this.scheduleOutcome(); + return this; + } + + public kill(signal?: NodeJS.Signals | number): boolean { + this.killCalls.push(signal); + if (this.behavior.throwOnKill) { + throw new Error("Controlled utility kill failure"); + } + return true; + } + + public unref(): void { + this.unrefCalls += 1; + if (this.behavior.throwOnUnref) { + throw new Error("Controlled utility unref failure"); + } + } + + private scheduleOutcome(): void { + if (!this.outcome || this.scheduled) { + return; + } + this.scheduled = true; + + queueMicrotask(() => { + if (this.outcome?.event === "error") { + for (const listener of this.errorListeners.splice(0)) { + listener(this.outcome.error); + } + } else if (this.outcome?.event === "exit") { + for (const listener of this.exitListeners.splice(0)) { + listener(this.outcome.code, this.outcome.signal); + } + } + }); + } +} + +interface UtilityCall { + executable: string; + args: string[]; + options: Parameters[2]; +} + +function utilityExit(code: number | null): FakeUtilityChild { + return new FakeUtilityChild({ event: "exit", code, signal: null }); +} + +function utilityError(code: string): FakeUtilityChild { + return new FakeUtilityChild({ event: "error", error: systemError(code) }); +} + +function createUtilitySpawn( + children: ProcessUtilityChild[], + calls: UtilityCall[], +): ProcessUtilitySpawn { + 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 utility child"); + } + return child; + }; +} + +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); + } + } +} + +interface FixturePids { + parentPid: number; + childPid: number; +} + +function isValidFixturePid(pid: unknown): pid is number { + return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0; +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !(getErrorCodeForTest(error) === "ESRCH"); + } +} + +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 delayForTest(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function waitForFixturePids( + pidFile: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + try { + const parsed = JSON.parse(readFileSync(pidFile, "utf8")) as Partial; + if ( + isValidFixturePid(parsed.parentPid) && + isValidFixturePid(parsed.childPid) + ) { + return { parentPid: parsed.parentPid, childPid: parsed.childPid }; + } + } catch {} + + await delayForTest(10); + } + + throw new Error("Fixture PID file did not become ready"); +} + +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)); +} + +function killExactFixturePid(pid: number): void { + if (!isValidFixturePid(pid) || !processExists(pid)) { + return; + } + + try { + process.kill(pid, "SIGKILL"); + } catch {} +} + +describe("POSIX process groups", () => { + test("stops a group gracefully when the second probe confirms absence", async () => { + const calls: SignalCall[] = []; + const delays: number[] = []; + let probeCount = 0; + let forceCount = 0; + const controller = createProcessTreeController({ + platform: "linux", + gracePeriodMs: 100, + pollIntervalMs: 10, + async delay(milliseconds): Promise { + delays.push(milliseconds); + }, + kill(pid, signal): boolean { + calls.push([pid, signal]); + if (signal === 0 && ++probeCount === 2) { + throw systemError("ESRCH"); + } + return true; + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => false, + onForce(): void { + forceCount += 1; + }, + }), + ).resolves.toEqual({ status: "stopped" }); + expect(calls.every(([pid]) => pid === -42)).toBe(true); + expect(calls).toContainEqual([-42, "SIGTERM"]); + expect(calls).not.toContainEqual([-42, "SIGKILL"]); + expect(delays).toEqual([10]); + expect(forceCount).toBe(0); + }); + + test("bounds grace probes before forcing and confirms final absence", async () => { + const calls: SignalCall[] = []; + const actions: string[] = []; + const delays: number[] = []; + let forced = false; + const controller = createProcessTreeController({ + platform: "darwin", + gracePeriodMs: 25, + pollIntervalMs: 10, + async delay(milliseconds): Promise { + delays.push(milliseconds); + }, + kill(pid, signal): boolean { + calls.push([pid, signal]); + actions.push(String(signal)); + if (signal === "SIGKILL") { + forced = true; + } else if (signal === 0 && forced) { + throw systemError("ESRCH"); + } + return true; + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => false, + onForce(): void { + actions.push("onForce"); + }, + }), + ).resolves.toEqual({ status: "stopped" }); + + const forceIndex = calls.findIndex(([, signal]) => signal === "SIGKILL"); + expect([ + calls[0], + calls[1], + calls[forceIndex], + calls[forceIndex + 1], + ]).toEqual([ + [-42, 0], + [-42, "SIGTERM"], + [-42, "SIGKILL"], + [-42, 0], + ]); + expect(calls.slice(2, forceIndex)).toHaveLength(3); + expect(calls.slice(2, forceIndex).every(([, signal]) => signal === 0)).toBe( + true, + ); + expect(delays).toEqual([10, 10, 5, 10]); + expect(actions.filter((action) => action === "onForce")).toHaveLength(1); + expect(actions.indexOf("onForce")).toBe(actions.indexOf("SIGKILL") - 1); + }); + + test("performs one immediate grace probe for a zero grace period", async () => { + const calls: SignalCall[] = []; + const delays: number[] = []; + let forced = false; + const controller = createProcessTreeController({ + platform: "linux", + gracePeriodMs: 0, + pollIntervalMs: 10, + async delay(milliseconds): Promise { + delays.push(milliseconds); + }, + kill(pid, signal): boolean { + calls.push([pid, signal]); + if (signal === "SIGKILL") { + forced = true; + } else if (signal === 0 && forced) { + throw systemError("ESRCH"); + } + return true; + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => false, + onForce(): void {}, + }), + ).resolves.toEqual({ status: "stopped" }); + + const forceIndex = calls.findIndex(([, signal]) => signal === "SIGKILL"); + expect(calls.slice(2, forceIndex)).toEqual([[-42, 0]]); + expect(delays).toEqual([0, 10]); + }); + + test("treats an initially absent group as stopped", async () => { + const calls: SignalCall[] = []; + let forceCount = 0; + const controller = createProcessTreeController({ + platform: "linux", + kill(pid, signal): boolean { + calls.push([pid, signal]); + throw systemError("ESRCH"); + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => true, + onForce(): void { + forceCount += 1; + }, + }), + ).resolves.toEqual({ status: "stopped" }); + expect(calls).toEqual([[-42, 0]]); + expect(forceCount).toBe(0); + }); + + test.each([ + ["EPERM", "permission-denied"], + ["EACCES", "permission-denied"], + ["UNKNOWN", "unconfirmed"], + ] as const)( + "maps %s signal failures to %s while the root is addressable", + async (code, reason) => { + const controller = createProcessTreeController({ + platform: "linux", + kill(): boolean { + throw systemError(code); + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => false, + onForce(): void {}, + }), + ).resolves.toEqual({ + status: "failed", + reason, + addressability: "safe", + }); + }, + ); + + test("evaluates root exit dynamically when a POSIX operation fails", async () => { + let rootExited = false; + const controller = createProcessTreeController({ + platform: "linux", + kill(): boolean { + rootExited = true; + throw systemError("UNKNOWN"); + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => rootExited, + onForce(): void {}, + }), + ).resolves.toEqual({ + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }); + }); +}); + +describe("Windows process trees", () => { + test("fails closed before targeting an initially exited root", async () => { + let utilityCalls = 0; + let rootProbeCalls = 0; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + spawn(): ProcessUtilityChild { + utilityCalls += 1; + return utilityExit(0); + }, + kill(): boolean { + rootProbeCalls += 1; + return true; + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => true, + onForce(): void {}, + }), + ).resolves.toEqual({ + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }); + expect(utilityCalls).toBe(0); + expect(rootProbeCalls).toBe(0); + }); + + test("invokes the absolute system taskkill utility directly", async () => { + const calls: UtilityCall[] = []; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + spawn: createUtilitySpawn([utilityExit(0)], calls), + kill(): boolean { + throw new Error("A successful utility call must not probe the root"); + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => false, + onForce(): void {}, + }), + ).resolves.toEqual({ status: "stopped" }); + expect(calls).toEqual([ + { + executable: "C:\\Windows\\System32\\taskkill.exe", + args: ["/PID", "42", "/T"], + options: { + detached: false, + shell: false, + stdio: "ignore", + windowsHide: true, + }, + }, + ]); + }); + + test("waits the production grace period before a forced utility call", async () => { + const calls: UtilityCall[] = []; + const delays: number[] = []; + const rootProbes: SignalCall[] = []; + let forceCount = 0; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + spawn: createUtilitySpawn([utilityExit(1), utilityExit(0)], calls), + async delay(milliseconds): Promise { + delays.push(milliseconds); + }, + kill(pid, signal): boolean { + rootProbes.push([pid, signal]); + return true; + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => false, + onForce(): void { + forceCount += 1; + }, + }), + ).resolves.toEqual({ status: "stopped" }); + expect(delays).toEqual([5_000]); + expect(rootProbes).toEqual([ + [42, 0], + [42, 0], + ]); + expect(forceCount).toBe(1); + expect(calls.map(({ args }) => args)).toEqual([ + ["/PID", "42", "/T"], + ["/PID", "42", "/T", "/F"], + ]); + }); + + test.each([ + {}, + { SystemRoot: " " }, + { SystemRoot: "Windows" }, + ])("rejects an unavailable SystemRoot: %p", async (env) => { + let spawnCalls = 0; + let rootProbeCalls = 0; + const controller = createProcessTreeController({ + platform: "win32", + env, + spawn(): ProcessUtilityChild { + spawnCalls += 1; + return utilityExit(0); + }, + kill(): boolean { + rootProbeCalls += 1; + return true; + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => false, + onForce(): void {}, + }), + ).resolves.toEqual({ + status: "failed", + reason: "facility-unavailable", + addressability: "safe", + }); + expect(spawnCalls).toBe(0); + expect(rootProbeCalls).toBe(0); + }); + + test("maps utility spawn permission failures without rejecting", async () => { + let spawnCalls = 0; + let forceCount = 0; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + gracePeriodMs: 1, + async delay(): Promise {}, + spawn(): ProcessUtilityChild { + spawnCalls += 1; + throw systemError("EACCES"); + }, + kill(): boolean { + return true; + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => false, + onForce(): void { + forceCount += 1; + }, + }), + ).resolves.toEqual({ + status: "failed", + reason: "permission-denied", + addressability: "safe", + }); + expect(spawnCalls).toBe(2); + expect(forceCount).toBe(1); + }); + + test.each([ + ["EPERM", "permission-denied", 2], + ["ENOENT", "facility-unavailable", 1], + ["UNKNOWN", "unconfirmed", 2], + ] as const)( + "maps utility child %s errors to %s", + async (code, reason, expectedCalls) => { + const calls: UtilityCall[] = []; + let forceCount = 0; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + gracePeriodMs: 1, + async delay(): Promise {}, + spawn: createUtilitySpawn( + [utilityError(code), utilityError(code)], + calls, + ), + kill(): boolean { + return true; + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => false, + onForce(): void { + forceCount += 1; + }, + }), + ).resolves.toEqual({ + status: "failed", + reason, + addressability: "safe", + }); + expect(calls).toHaveLength(expectedCalls); + expect(forceCount).toBe(code === "ENOENT" ? 0 : 1); + }, + ); + + test("maps nonzero utility exits to an unconfirmed final state", async () => { + const calls: UtilityCall[] = []; + let forceCount = 0; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + gracePeriodMs: 1, + async delay(): Promise {}, + spawn: createUtilitySpawn([utilityExit(5), utilityExit(1)], calls), + kill(): boolean { + return true; + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => false, + onForce(): void { + forceCount += 1; + }, + }), + ).resolves.toEqual({ + status: "failed", + reason: "unconfirmed", + addressability: "safe", + }); + expect(calls).toHaveLength(2); + expect(forceCount).toBe(1); + }); + + test("does not force after the root exits during the grace period", async () => { + const calls: UtilityCall[] = []; + let rootExited = false; + let rootProbeCalls = 0; + let forceCount = 0; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + gracePeriodMs: 10, + spawn: createUtilitySpawn([utilityExit(1)], calls), + async delay(): Promise { + rootExited = true; + }, + kill(): boolean { + rootProbeCalls += 1; + return true; + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => rootExited, + onForce(): void { + forceCount += 1; + }, + }), + ).resolves.toEqual({ + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }); + expect(calls).toHaveLength(1); + expect(rootProbeCalls).toBe(1); + expect(forceCount).toBe(0); + }); + + test("rechecks root exit after onForce before spawning the forced utility", async () => { + const calls: UtilityCall[] = []; + let rootExited = false; + let rootProbeCalls = 0; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + gracePeriodMs: 1, + spawn: createUtilitySpawn([utilityExit(1), utilityExit(0)], calls), + async delay(): Promise {}, + kill(): boolean { + rootProbeCalls += 1; + return true; + }, + }); + + const result = await controller.stop(42, { + isRootExited: () => rootExited, + onForce(): void { + rootExited = true; + }, + }); + + expect(calls.map(({ args }) => args)).toEqual([["/PID", "42", "/T"]]); + expect(rootProbeCalls).toBe(2); + expect(result).toEqual({ + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }); + }); + + test("maps a denied Windows root probe to a safe failure", async () => { + let forceCount = 0; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + spawn: createUtilitySpawn([utilityExit(1)], []), + kill(): boolean { + throw systemError("EPERM"); + }, + }); + + await expect( + controller.stop(42, { + isRootExited: () => false, + onForce(): void { + forceCount += 1; + }, + }), + ).resolves.toEqual({ + status: "failed", + reason: "permission-denied", + addressability: "safe", + }); + expect(forceCount).toBe(0); + }); + + test( + "bounds a hung graceful utility and proceeds directly to force", + async () => { + const calls: UtilityCall[] = []; + const hungChild = new FakeUtilityChild(undefined, { throwOnKill: true }); + let delayCalls = 0; + let forceCount = 0; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + utilityTimeoutMs: 1, + spawn: createUtilitySpawn([hungChild, utilityExit(1)], calls), + async delay(): Promise { + delayCalls += 1; + }, + kill(): boolean { + return true; + }, + }); + + const result = await settleWithin( + controller.stop(42, { + isRootExited: () => false, + onForce(): void { + forceCount += 1; + }, + }), + 100, + ); + + expect(result).toEqual({ + status: "failed", + reason: "unconfirmed", + addressability: "safe", + }); + expect(calls.map(({ args }) => args)).toEqual([ + ["/PID", "42", "/T"], + ["/PID", "42", "/T", "/F"], + ]); + expect(delayCalls).toBe(0); + expect(forceCount).toBe(1); + expect(hungChild.killCalls).toEqual(["SIGKILL"]); + expect(hungChild.unrefCalls).toBe(1); + }, + ); + + test( + "bounds a hung forced utility and returns an unconfirmed result", + async () => { + const hungChild = new FakeUtilityChild(undefined, { throwOnUnref: true }); + let forceCount = 0; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + gracePeriodMs: 1, + utilityTimeoutMs: 1, + spawn: createUtilitySpawn([utilityExit(1), hungChild], []), + async delay(): Promise {}, + kill(): boolean { + return true; + }, + }); + + const result = await settleWithin( + controller.stop(42, { + isRootExited: () => false, + onForce(): void { + forceCount += 1; + }, + }), + 100, + ); + + expect(result).toEqual({ + status: "failed", + reason: "unconfirmed", + addressability: "safe", + }); + expect(forceCount).toBe(1); + expect(hungChild.killCalls).toEqual(["SIGKILL"]); + expect(hungChild.unrefCalls).toBe(1); + }, + ); + + test("rechecks root exit after a graceful utility timeout", async () => { + const calls: UtilityCall[] = []; + const hungChild = new FakeUtilityChild(); + let rootExited = false; + let rootProbeCalls = 0; + let forceCount = 0; + const controller = createProcessTreeController({ + platform: "win32", + env: { SystemRoot: "C:\\Windows" }, + utilityTimeoutMs: 1, + spawn: createUtilitySpawn([hungChild, utilityExit(0)], calls), + kill(): boolean { + rootProbeCalls += 1; + rootExited = true; + return true; + }, + }); + + const result = await settleWithin( + controller.stop(42, { + isRootExited: () => rootExited, + onForce(): void { + forceCount += 1; + }, + }), + 100, + ); + + expect(result).toEqual({ + status: "failed", + reason: "unconfirmed", + addressability: "lost", + }); + expect(calls).toHaveLength(1); + expect(rootProbeCalls).toBe(1); + expect(forceCount).toBe(0); + expect(hungChild.killCalls).toEqual(["SIGKILL"]); + expect(hungChild.unrefCalls).toBe(1); + }); +}); + +describe("host-platform process trees", () => { + test( + "stops a real fixture parent and descendant", + async () => { + const directory = mkdtempSync(join(tmpdir(), "startup-process-tree-")); + const pidFile = join(directory, "fixture-pids.json"); + const parentPath = fileURLToPath( + new URL("./fixtures/process-tree-parent.mjs", import.meta.url), + ); + const knownPids = new Set(); + + try { + const fixture = spawnFixtureProcess( + process.execPath, + [ + parentPath, + pidFile, + process.platform === "win32" ? "normal" : "ignore-term", + ], + { + detached: true, + shell: false, + stdio: "ignore", + windowsHide: true, + }, + ); + if (isValidFixturePid(fixture.pid)) { + knownPids.add(fixture.pid); + } + fixture.unref(); + + const fixturePids = await waitForFixturePids(pidFile, 3_000); + knownPids.add(fixturePids.parentPid); + knownPids.add(fixturePids.childPid); + let forceCount = 0; + const controller = createProcessTreeController({ + gracePeriodMs: 100, + pollIntervalMs: 10, + }); + + const result = await settleWithin( + controller.stop(fixturePids.parentPid, { + isRootExited: () => !processExists(fixturePids.parentPid), + onForce(): void { + forceCount += 1; + }, + }), + 12_000, + ); + + expect(result).toEqual({ status: "stopped" }); + expect( + await waitForProcessesAbsent( + [fixturePids.parentPid, fixturePids.childPid], + 5_000, + ), + ).toBe(true); + if (process.platform !== "win32") { + expect(forceCount).toBe(1); + } + } finally { + const exactPids = [...knownPids]; + for (const pid of exactPids) { + killExactFixturePid(pid); + } + await waitForProcessesAbsent(exactPids, 2_000); + rmSync(directory, { recursive: true, force: true }); + } + }, + 20_000, + ); +}); diff --git a/tests/readme.test.ts b/tests/readme.test.ts index 606f0ba..38a2f7e 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.0.0`; +const npmRegistration = `${packageName}@1.1.0`; const gitRegistration = `${packageName}@git+https://github.com/PixelWinner/` + - `${packageName}.git#v1.0.0`; + `${packageName}.git#v1.1.0`; function pluginRegistrations(markdown: string): string[] { const registrations: string[] = []; @@ -91,7 +91,7 @@ test("keeps the concise approved public structure", () => { const lineCount = readme.trimEnd().split(/\r?\n/).length; expect(lineCount).toBeGreaterThanOrEqual(100); - expect(lineCount).toBeLessThanOrEqual(120); + expect(lineCount).toBeLessThanOrEqual(180); expect(readme).toMatch(/unofficial[^\n]*not affiliated with[^\n]*OpenCode/i); }); @@ -105,15 +105,87 @@ test("documents strict JSON configuration in global and project locations", () = expect(readme).toMatch(/comments[^\n]*trailing commas[^\n]*(?:not|aren't)/i); expect(readme).toContain('"executable": "/absolute/path/to/helper"'); expect(readme).toContain('"args": ["--watch"]'); + expect(readme).toContain('"onExistingProcess": "skip"'); + expect(readme).toContain('"stopOnExit": true'); + expect(readme).toMatch(/`onExistingProcess`[^\n]*defaults? to `skip`/i); + expect(readme).toMatch(/`stopOnExit`[^\n]*defaults? to `true`/i); + expect(readme).toMatch(/`onExistingProcess`[^\n]*only[^\n]*`start`[^\n]*`skip`[^\n]*`restart`/i); + expect(readme).toMatch(/`stopOnExit`[^\n]*boolean/i); + expect(readme).toMatch(/invalid entr(?:y|ies)[^\n]*later valid entr(?:y|ies)/i); }); -test("documents global-first lifecycle and process-root deduplication", () => { +test("documents existing-process policy behavior and replacement ordering", () => { + expect(readme).toMatch(/`start`[^\n]*additional[^\n]*separately owned[^\n]*record/i); + expect(readme).toMatch(/`skip`[^\n]*current owner[^\n]*oldest[^\n]*record/i); + expect(readme).toMatch( + /`skip`[^\n]*without changing[^\n]*creation-time[^\n]*`stopOnExit`/i, + ); + expect(readme).toMatch(/`restart`[^\n]*all[^\n]*one replacement/i); + expect(readme).toMatch(/restart[^\n]*all plugin-owned[^\n]*records/i); + expect(readme).toMatch(/restart[^\n]*regardless of[^\n]*stopOnExit/i); + expect(readme).toMatch( + /replacement[^\n]*only after[^\n]*confirmed[^\n]*cleanup/i, + ); +}); + +test("documents identity, duplicate precedence, and lifecycle-policy selection", () => { expect(readme).toMatch(/global[^\n]*before project/i); - expect(readme).toMatch(/global[^\n]*once per OpenCode process/i); - expect(readme).toMatch(/project[^\n]*once per normalized project root/i); expect(readme).toMatch(/identity[^\n]*executable[^\n]*ordered[^\n]*args/i); + expect(readme).toMatch(/project identit(?:y|ies)[^\n]*normalized root/i); + expect(readme).toMatch(/global identit(?:y|ies)[^\n]*shared[^\n]*OpenCode instances/i); + expect(readme).toMatch( + /`name`[^\n]*`onExistingProcess`[^\n]*`stopOnExit`[^\n]*not part of identity/i, + ); + expect(readme).toMatch(/first within (?:each )?scope/i); + expect(readme).toMatch( + /global[^\n]*first[^\n]*duplicate[^\n]*both policies[^\n]*before policy evaluation/i, + ); +}); + +test("documents normalized owners, final-owner cleanup, and same-process reopen", () => { + expect(readme).toMatch(/project[^\n]*normalized root[^\n]*final owner/i); + expect(readme).toMatch(/global[^\n]*final[^\n]*OpenCode[^\n]*owner/i); + expect(readme).toMatch(/stopOnExit: false[^\n]*remain[^\n]*tracked/i); + expect(readme).toMatch(/stopOnExit: true[^\n]*final owner[^\n]*stop/i); + expect(readme).toMatch(/same-process reopen/i); + expect(readme).toMatch( + /confirmed successful[^\n]*cleanup[^\n]*releases the identity[^\n]*reopening[^\n]*same OpenCode process[^\n]*starts a new process/i, + ); +}); + +test("documents degraded restart blockers and process visibility", () => { + expect(readme).toMatch(/partial[^\n]*restart[^\n]*degraded[^\n]*no replacement/i); + expect(readme).toMatch(/survivor[^\n]*orphaned owners[^\n]*oldest survivor/i); + expect(readme).toMatch(/tombstones[^\n]*blockers[^\n]*block/i); expect(readme).toMatch(/failed[^\n]*(?:not retried|no retry)/i); - expect(readme).toMatch(/restart OpenCode/i); + expect(readme).toMatch( + /launch failures[^\n]*final natural exits[^\n]*unconfirmed stale cleanup[^\n]*same-process retry/i, + ); + expect(readme).toMatch(/plugin[^\n]*launched[^\n]*current OpenCode process/i); + expect(readme).toMatch(/does not scan[^\n]*OS[^\n]*(?:discover|adopt)/i); + expect(readme).toMatch(/full OpenCode restart[^\n]*cannot rediscover/i); + expect(readme).toMatch(/full OpenCode restart[^\n]*updating plugin code/i); +}); + +test("documents platform stop escalation, limitations, and sanitized events", () => { + expect(readme).toMatch(/POSIX[^\n]*SIGTERM[^\n]*5 seconds[^\n]*SIGKILL/i); + expect(readme).toMatch( + /Windows[^\n]*direct[^\n]*trusted[^\n]*taskkill\.exe[^\n]*\/T[^\n]*waits? 5 seconds[^\n]*only if the tree still remains[^\n]*taskkill\.exe[^\n]*\/T[^\n]*\/F/i, + ); + expect(readme).toMatch( + /best-effort[^\n]*detached[^\n]*escaped descendants[^\n]*forced OpenCode termination[^\n]*OS crash[^\n]*power loss/i, + ); + expect(readme).toMatch(/sanitized[^\n]*requested[^\n]*forced[^\n]*failed stop events/i); + expect(readme).toMatch(/stop events[^\n]*commands[^\n]*raw errors/i); +}); + +test("documents fail-closed Windows cleanup after the tracked root exits", () => { + expect(readme).toMatch( + /Windows[^\n]*tracked root exits before cleanup[^\n]*descendants cannot be addressed safely/i, + ); + expect(readme).toMatch(/fails closed[^\n]*may leave descendants running/i); + expect(readme).toMatch(/blocks restart[^\n]*identity/i); + expect(readme).toMatch(/recovery[^\n]*full OpenCode restart/i); }); test("distinguishes the original project cwd from the normalized deduplication key", () => { diff --git a/tests/release-draft.test.ts b/tests/release-draft.test.ts index 8646115..9448cdb 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.0.1", + RELEASE_TAG: "v1.1.0", }; expect(validateReleaseEnvironment(environment)).toEqual({ repository, - tag: "v1.0.1", + tag: "v1.1.0", commitSha, candidateDirectory: candidate.directory, - tarballName: "opencode-startup-commands-1.0.1.tgz", + tarballName: "opencode-startup-commands-1.1.0.tgz", tarballPath: join( candidate.directory, - "opencode-startup-commands-1.0.1.tgz", + "opencode-startup-commands-1.1.0.tgz", ), checksumPath: join(candidate.directory, checksumName), - title: "v1.0.1", + title: "v1.1.0", }); }); }); diff --git a/tests/release-package.test.ts b/tests/release-package.test.ts index 4c2cb4c..f370341 100644 --- a/tests/release-package.test.ts +++ b/tests/release-package.test.ts @@ -40,6 +40,8 @@ const allowedPaths = [ "dist/core.d.ts", "dist/logger.js", "dist/logger.d.ts", + "dist/process-tree.d.ts", + "dist/process-tree.js", "dist/server.js", "dist/server.d.ts", "dist/server-internal.js", @@ -48,7 +50,7 @@ const allowedPaths = [ const releaseManifest = { name: "opencode-startup-commands", - version: "1.0.0", + version: "1.1.0", main: "./dist/server.js", types: "./dist/server.d.ts", exports: { @@ -74,10 +76,10 @@ const releaseManifest = { }; const packResult = { - id: "opencode-startup-commands@1.0.0", + id: "opencode-startup-commands@1.1.0", name: "opencode-startup-commands", - version: "1.0.0", - filename: "opencode-startup-commands-1.0.0.tgz", + version: "1.1.0", + filename: "opencode-startup-commands-1.1.0.tgz", size: 1024, unpackedSize: 4096, shasum: "0123456789abcdef0123456789abcdef01234567", @@ -124,7 +126,7 @@ describe("package file contract", () => { } }); - test("requires every path in the exact 13-file package", () => { + test("requires every path in the exact 15-file package", () => { for (const requiredPath of allowedPaths) { expect(() => validatePackageFilePaths( @@ -187,8 +189,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.0.0", - filename: "opencode-startup-commands-1.0.0.tgz", + version: "1.1.0", + filename: "opencode-startup-commands-1.1.0.tgz", }); for (const changed of [ @@ -651,10 +653,10 @@ test("formats a deterministic SHA-256 checksum line", () => { expect( formatChecksumLine( digest, - "opencode-startup-commands-1.0.0.tgz", + "opencode-startup-commands-1.1.0.tgz", ), ).toBe( - `${digest} opencode-startup-commands-1.0.0.tgz\n`, + `${digest} opencode-startup-commands-1.1.0.tgz\n`, ); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 64e53da..aa796ab 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1,8 +1,19 @@ import { expect, test } from "bun:test"; import type { PluginInput } from "@opencode-ai/plugin"; -import type { ConfigLoadResult } from "../src/config.js"; -import type { SpawnedChild, StartupSpawnOptions } from "../src/core.js"; +import type { + ConfigLoadResult, + ConfiguredCommand, +} from "../src/config.js"; +import { + createStartupState, + type SpawnedChild, + type StartupSpawnOptions, +} from "../src/core.js"; import type { LogEvent, Logger } from "../src/logger.js"; +import type { + ProcessTreeController, + ProcessTreeStopResult, +} from "../src/process-tree.js"; import { createStartupCommandsServer, type StartupCommandsServerDependencies, @@ -13,14 +24,31 @@ const input = { worktree: "project-root", } as PluginInput; -function createFixture(logger?: Logger) { +interface FixtureOptions { + logger?: Logger; + pids?: readonly number[]; + stop?: ProcessTreeController["stop"]; +} + +function createFixture(options?: FixtureOptions) { const events: LogEvent[] = []; const configCalls: unknown[][] = []; + const stopCalls: Array = []; const spawnCalls: Array<{ executable: string; args: string[]; options: StartupSpawnOptions; }> = []; + const state = createStartupState(); + const processTree: ProcessTreeController = { + async stop(pid, stopOptions): Promise { + stopCalls.push(pid); + if (options?.stop) { + return options.stop(pid, stopOptions); + } + return { status: "stopped" }; + }, + }; let globalConfig: ConfigLoadResult = { commands: [], diagnostics: [] }; let projectConfig: ConfigLoadResult = { commands: [], diagnostics: [] }; @@ -55,12 +83,13 @@ function createFixture(logger?: Logger) { spawn( executable: string, args: string[], - options: StartupSpawnOptions, + spawnOptions: StartupSpawnOptions, ): SpawnedChild { - spawnCalls.push({ executable, args, options }); + const pid = options?.pids?.[spawnCalls.length] ?? 1234; + spawnCalls.push({ executable, args, options: spawnOptions }); const child: SpawnedChild = { - pid: 1234, + pid, once(): SpawnedChild { return child; }, @@ -69,9 +98,10 @@ function createFixture(logger?: Logger) { return child; }, - state: { started: new Set() }, + state, + processTree, logger: - logger ?? + options?.logger ?? { write(event: LogEvent): void { events.push(event); @@ -82,7 +112,10 @@ function createFixture(logger?: Logger) { return { configCalls, events, + processTree, spawnCalls, + state, + stopCalls, server: createStartupCommandsServer(dependencies), setGlobalConfig(config: ConfigLoadResult): void { globalConfig = config; @@ -93,6 +126,34 @@ function createFixture(logger?: Logger) { }; } +function globalCommand(executable = "global-helper"): ConfiguredCommand { + return { + name: "Global helper", + executable, + args: [], + onExistingProcess: "skip", + stopOnExit: true, + scope: "global", + index: 0, + }; +} + +function projectCommand( + projectRoot: string, + executable = "project-helper", +): ConfiguredCommand { + return { + name: "Project helper", + executable, + args: [], + onExistingProcess: "skip", + stopOnExit: true, + scope: "project", + projectRoot, + index: 0, + }; +} + test("package adapter exposes only a compatible default export", async () => { const importedModule = await import("../src/server.js"); @@ -102,6 +163,23 @@ test("package adapter exposes only a compatible default export", async () => { expect("setup" in serverModule).toBe(false); }); +test("production adapter owns one process-tree controller and no host exit listeners", async () => { + const [serverSource, internalSource] = await Promise.all([ + Bun.file(new URL("../src/server.ts", import.meta.url)).text(), + Bun.file(new URL("../src/server-internal.ts", import.meta.url)).text(), + ]); + const hostExitListener = + /process\.(?:on|once|addListener)\(\s*["'](?:SIGINT|SIGTERM|beforeExit|exit)["']/; + + expect( + serverSource.match(/processTree:\s*createProcessTreeController\(\)/g), + ).toHaveLength(1); + expect(serverSource.match(/state:\s*processState/g)).toHaveLength(1); + expect(serverSource.match(/logger:\s*createLogger\(\)/g)).toHaveLength(1); + expect(serverSource).not.toMatch(hostExitListener); + expect(internalSource).not.toMatch(hostExitListener); +}); + test("preserves scope-local config indexes through adapter lifecycle events", async () => { const fixture = createFixture(); fixture.setGlobalConfig({ @@ -110,6 +188,8 @@ test("preserves scope-local config indexes through adapter lifecycle events", as name: "Global helper", executable: "global-helper", args: ["--global"], + onExistingProcess: "skip", + stopOnExit: true, scope: "global", index: 1, }, @@ -117,6 +197,8 @@ test("preserves scope-local config indexes through adapter lifecycle events", as name: "Second global helper", executable: "second-global-helper", args: [], + onExistingProcess: "skip", + stopOnExit: true, scope: "global", index: 2, }, @@ -136,6 +218,8 @@ test("preserves scope-local config indexes through adapter lifecycle events", as name: "Project helper", executable: "project-helper", args: ["--project"], + onExistingProcess: "skip", + stopOnExit: true, scope: "project", projectRoot: input.worktree, index: 1, @@ -199,25 +283,103 @@ test("preserves scope-local config indexes through adapter lifecycle events", as { executable: "global-helper", args: ["--global"], - options: { detached: false, stdio: "ignore", windowsHide: true }, + options: { + detached: true, + shell: false, + stdio: "ignore", + windowsHide: true, + }, }, { executable: "second-global-helper", args: [], - options: { detached: false, stdio: "ignore", windowsHide: true }, + options: { + detached: true, + shell: false, + stdio: "ignore", + windowsHide: true, + }, }, { executable: "project-helper", args: ["--project"], options: { cwd: input.worktree, - detached: false, + detached: true, + shell: false, stdio: "ignore", windowsHide: true, }, }, ]); - expect(hooks).toEqual({}); + expect(Object.keys(hooks)).toEqual(["dispose"]); + expect(typeof hooks.dispose).toBe("function"); +}); + +test("stops one global and project command once across repeated disposal", async () => { + const fixture = createFixture({ pids: [2001, 2002] }); + fixture.setGlobalConfig({ + commands: [globalCommand()], + diagnostics: [], + }); + fixture.setProjectConfig({ + commands: [projectCommand(input.worktree)], + diagnostics: [], + }); + + const hooks = await fixture.server.server(input); + + expect(Object.keys(hooks)).toEqual(["dispose"]); + expect(typeof hooks.dispose).toBe("function"); + await hooks.dispose?.(); + await hooks.dispose?.(); + + expect(fixture.stopCalls).toEqual([2001, 2002]); + expect(fixture.state.size).toBe(0); +}); + +test("stops a shared default-skip global command after its final owner disposes", async () => { + const fixture = createFixture({ pids: [2101] }); + fixture.setGlobalConfig({ + commands: [globalCommand()], + diagnostics: [], + }); + + const firstHooks = await fixture.server.server(input); + const secondHooks = await fixture.server.server(input); + + expect(fixture.spawnCalls).toHaveLength(1); + await firstHooks.dispose?.(); + expect(fixture.stopCalls).toEqual([]); + await secondHooks.dispose?.(); + expect(fixture.stopCalls).toEqual([2101]); +}); + +test("shares same-root project ownership while different roots dispose independently", async () => { + const fixture = createFixture({ pids: [2201, 2202] }); + const firstInput = { worktree: "first-root" } as PluginInput; + const secondInput = { worktree: "second-root" } as PluginInput; + fixture.setProjectConfig({ + commands: [projectCommand(firstInput.worktree)], + diagnostics: [], + }); + + const firstHooks = await fixture.server.server(firstInput); + const sameRootHooks = await fixture.server.server(firstInput); + + fixture.setProjectConfig({ + commands: [projectCommand(secondInput.worktree)], + diagnostics: [], + }); + const differentRootHooks = await fixture.server.server(secondInput); + + expect(fixture.spawnCalls).toHaveLength(2); + await firstHooks.dispose?.(); + expect(fixture.stopCalls).toEqual([]); + await differentRootHooks.dispose?.(); + expect(fixture.stopCalls).toEqual([2202]); + await sameRootHooks.dispose?.(); + expect(fixture.stopCalls).toEqual([2202, 2201]); }); test("runs valid global config when project config is missing", async () => { @@ -228,6 +390,8 @@ test("runs valid global config when project config is missing", async () => { name: "Global helper", executable: "global-helper", args: [], + onExistingProcess: "skip", + stopOnExit: true, scope: "global", index: 0, }, @@ -254,6 +418,8 @@ test("runs valid project config when global config is invalid", async () => { name: "Project helper", executable: "project-helper", args: [], + onExistingProcess: "skip", + stopOnExit: true, scope: "project", projectRoot: input.worktree, index: 0, @@ -274,10 +440,14 @@ test("runs valid project config when global config is invalid", async () => { ]); }); -test("continues to core when config diagnostic logging throws", async () => { +test("continues to activation and cleanup when config diagnostic logging throws", async () => { const fixture = createFixture({ - write(): void { - throw new Error("logger unavailable"); + logger: { + write(event): void { + if (event.type === "configuration.invalid") { + throw new Error("logger unavailable"); + } + }, }, }); fixture.setGlobalConfig({ @@ -286,6 +456,8 @@ test("continues to core when config diagnostic logging throws", async () => { name: "Global helper", executable: "global-helper", args: [], + onExistingProcess: "skip", + stopOnExit: true, scope: "global", index: 0, }, @@ -298,7 +470,63 @@ test("continues to core when config diagnostic logging throws", async () => { expect(fixture.spawnCalls.map(({ executable }) => executable)).toEqual([ "global-helper", ]); - expect(hooks).toEqual({}); + expect(Object.keys(hooks)).toEqual(["dispose"]); + expect(typeof hooks.dispose).toBe("function"); + await hooks.dispose?.(); + expect(fixture.stopCalls).toEqual([1234]); +}); + +test("contains process-tree adapter failures during disposal", async () => { + const fixture = createFixture({ + pids: [2301], + stop(): Promise { + return Promise.reject(new Error("adapter unavailable")); + }, + }); + fixture.setGlobalConfig({ + commands: [globalCommand()], + diagnostics: [], + }); + const hooks = await fixture.server.server(input); + + await hooks.dispose?.(); + + expect(fixture.stopCalls).toEqual([2301]); +}); + +test("contains stop-event logger failures during disposal", async () => { + const fixture = createFixture({ + logger: { + write(event): void { + if (event.type === "command.stop-requested") { + throw new Error("logger unavailable"); + } + }, + }, + pids: [2401], + }); + fixture.setGlobalConfig({ + commands: [globalCommand()], + diagnostics: [], + }); + const hooks = await fixture.server.server(input); + + await hooks.dispose?.(); + + expect(fixture.stopCalls).toEqual([2401]); +}); + +test("returns an idempotent async disposer for an empty command list", async () => { + const fixture = createFixture(); + + const hooks = await fixture.server.server(input); + + expect(Object.keys(hooks)).toEqual(["dispose"]); + expect(typeof hooks.dispose).toBe("function"); + await hooks.dispose?.(); + await hooks.dispose?.(); + expect(fixture.stopCalls).toEqual([]); + expect(fixture.state.size).toBe(0); }); test("ignores legacy tuple options", async () => { diff --git a/tests/workflows.test.ts b/tests/workflows.test.ts index 6da5453..a3d2063 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.0.0.tgz"); + expect(tagPackage).not.toContain("opencode-startup-commands-1.1.0.tgz"); expect(tagPackage).not.toContain("contents: write"); });