diff --git a/AGENTS.md b/AGENTS.md index b9333ac2ba..2e88e0d678 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,12 +20,12 @@ This file is the single source of truth for agents entering this repository. Rea - Top-level content directories: `skills/` (functional skills the agent invokes mid-task — utilities, briefs, packagers; see `skills/AGENTS.md`), `design-templates/` (rendering catalogue: decks, prototypes, image/video/audio templates; see `design-templates/AGENTS.md` and `specs/current/skills-and-design-templates.md`), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`), `mocks/` (replay-based mock CLIs for `opencode`/`claude`/`codex`/`gemini`/`cursor-agent`/`deepseek`/`qwen`/`grok`, the ACP family `devin`/`hermes`/`kilo`/`kimi`/`kiro`/`vibe`, and the AMR `vela` CLI (login + models + ACP), built from anonymized Langfuse traces — PATH-overlay drop-in for tests and self-validation; see `mocks/README.md`). - `apps/web` is the Next.js 16 App Router + React 18 web runtime; do not restore `apps/nextjs`. - `apps/daemon` is the local privileged daemon and `od` bin. It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving. -- `apps/desktop` is the Electron shell; it discovers the web URL through sidecar IPC. +- `apps/desktop` is the Electron shell; it consumes web/daemon status through the sidecar client boundary. - `apps/packaged` is the thin packaged Electron runtime entry; it starts packaged sidecars and owns the `od://` entry glue only. - `apps/closure` owns the independently distributable OpenDesign Closure content. It does not own acquisition, generation state, or shell policy. - `apps/landing-page` is the standalone static Astro marketing and public catalog site. It reads repository content at build time and is not part of the daemon/web product runtime. - `packages/contracts` is the pure TypeScript web/daemon app contract layer. -- `packages/sidecar-proto` owns the OpenDesign sidecar business protocol; `packages/sidecar` owns the generic sidecar runtime; `packages/platform` owns generic OS process primitives. +- `packages/sidecar-proto` owns business DTOs and action names; `packages/sidecar` owns the complete business-agnostic sidecar client boundary and protocol implementation; `packages/platform` owns generic OS process primitives. - `packages/standalone` owns the shell-neutral exact metadata, verification, materialization, generation, and launcher contract. - `shells/terminal` owns the official Node carrier and terminal-facing lifecycle commands. Shells consume standalone contracts and must not import Closure app source. - `tools/dev` is the local development lifecycle control plane. @@ -96,7 +96,7 @@ The daemon has one active data-root truth source: Development propagation: -- `tools-dev` owns sidecar runtime/log/ipc namespacing. +- `tools-dev` consumes sidecar launch/discovery/lifecycle atomics and owns only developer orchestration policy. - `tools-dev --namespace ` does not, by itself, define daemon data isolation. - A development run that needs an isolated daemon data root must pass @@ -238,10 +238,11 @@ confidence methodology in `specs/current/ci.md`. - New `.js`, `.mjs`, or `.cjs` files need an explicit generated/vendor/compatibility reason and must pass `pnpm guard`. - App business logic must not know about sidecar/control-plane concepts. Keep sidecar awareness in `apps//sidecar` or the desktop sidecar entry wrapper. - Shared web/daemon app contracts belong in `packages/contracts`; that package must not depend on Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, or the sidecar control-plane protocol. -- Sidecar process stamps must have exactly five fields: `app`, `mode`, `namespace`, `ipc`, and `source`. -- Orchestration layers (`tools-dev`, `tools-pack`, packaged launchers) must call package primitives; do not hand-build `--od-stamp-*` args or process-scan regexes. +- Sidecar process stamps must have exactly five fields: `channel`, `namespace`, `source`, `mode`, and `app`. IPC is private implementation detail and is never a stamp field. +- Sidecar identity is argv-only. Do not create identity/state files derived from a stamp. +- Orchestration layers (`tools-dev`, `tools-pack`, packaged launchers) must call `@open-design/sidecar` client/atomic primitives; do not expose argv assembly, IPC paths, or process scans. - Packaged runtime paths must be namespace-scoped and independent from daemon/web ports; ports are transient transport details only. -- Default runtime files live under `/.tmp///...`; POSIX IPC sockets are fixed at `/tmp/open-design/ipc//.sock`. +- Default runtime files live under `/.tmp///...`; private IPC endpoints are derived by `@open-design/sidecar` from the five-field stamp and the current OS principal. POSIX endpoints use a principal-scoped, hashed directory under the OS temporary directory; callers must treat the concrete path as opaque. ## Capability exposure (UI/CLI dual-track) @@ -439,11 +440,11 @@ The current web runtime is `apps/web`. The historical `apps/nextjs` layout has b ## How does desktop discover the web URL? -Desktop queries runtime status through sidecar IPC. The web URL comes from `tools-dev` launch status, not from desktop guessing ports or reading web internals. +Desktop queries runtime status through its sidecar client. The web URL comes from client status, not from desktop guessing ports, IPC paths, or reading web internals. ## How are sidecar-proto, sidecar, and platform split? -`@open-design/sidecar-proto` owns OpenDesign app/mode/source constants, namespace validation, stamp fields/flags, IPC message schema, status shapes, and error semantics. `@open-design/sidecar` provides only generic bootstrap, IPC transport, path/runtime resolution, launch env, and JSON runtime files. `@open-design/platform` provides only generic OS process stamp serialization, command parsing, and process matching/search primitives, consuming the proto descriptor. +`@open-design/sidecar-proto` owns OpenDesign business action names and DTO/status shapes. `@open-design/sidecar` is the unique truth source for five-field argv stamps, private IPC, OS resources, process discovery, launch, invocation, and terminal lifecycle. `@open-design/platform` provides generic OS process primitives beneath sidecar and must not leak those implementation details into apps or orchestrators. ## When is `pnpm install` required? diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 3464414717..19a8cfad80 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -15,8 +15,8 @@ import { parseDesignSystemRenameArgs } from './design-systems/rename-args.js'; import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js'; import { splitResearchSubcommand } from './research/cli-args.js'; import { resolveDaemonUrl } from './daemon-url.js'; -import { requestJsonIpc } from '@open-design/sidecar'; -import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto'; +import { SidecarFactory } from '@open-design/sidecar'; +import { APP_KEYS, SIDECAR_MESSAGES } from '@open-design/sidecar-proto'; import { EXPORT_FORMATS, EXPORT_IMAGE_FORMATS } from '@open-design/contracts'; import type { ArtifactLintFinding, LintArtifactCliResultEnvelope, LintArtifactResponse, LintFailOn } from '@open-design/contracts'; import { buildExportCliRequestBody, buildExportCliResultEnvelope, resolveExportCliDeckMode } from './export-cli-request.js'; @@ -1727,7 +1727,7 @@ Output is JSON only on stdout: Flags: --query Required search query. --max-sources Optional source cap. Defaults to 5, clamped to Tavily's max. - --daemon-url Local daemon URL. Defaults to OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456.`); + --daemon-url Local daemon URL. Defaults to OD_DAEMON_URL, inherited sidecar discovery, or http://127.0.0.1:7456.`); } // --------------------------------------------------------------------------- @@ -2363,7 +2363,7 @@ every iteration. Options: --daemon-url OpenDesign daemon HTTP base URL. Resolution - order: this flag, OD_DAEMON_URL, OD_SIDECAR_IPC_PATH, + order: this flag, OD_DAEMON_URL, inherited sidecar status, then http://127.0.0.1:7456. Each new MCP spawn discovers the live daemon URL at startup, so MCP client configs stay valid across daemon @@ -3238,7 +3238,7 @@ async function runMarketplace(args) { Update the marketplace trust tier. Common options: - --daemon-url OpenDesign daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456). + --daemon-url OpenDesign daemon HTTP base (default OD_DAEMON_URL, inherited sidecar discovery, or http://127.0.0.1:7456). --json Emit raw JSON (suitable for scripts).`); process.exit(args.length === 0 ? 2 : 0); } @@ -6134,7 +6134,7 @@ function printUiHelp() { Pre-answer a surface so the run never broadcasts it. Common options: - --daemon-url OpenDesign daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456). + --daemon-url OpenDesign daemon HTTP base (default OD_DAEMON_URL, inherited sidecar discovery, or http://127.0.0.1:7456). --workspace Explicit Workspace id for a bound project or run. --workspace-member Explicit Workspace member id for a bound project or run. @@ -6192,7 +6192,7 @@ function printPluginHelp() { od plugin whoami [--host github.com] Show the gh account used for publishing. Common options: - --daemon-url OpenDesign daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456). + --daemon-url OpenDesign daemon HTTP base (default OD_DAEMON_URL, inherited sidecar discovery, or http://127.0.0.1:7456). --json Emit raw JSON (suitable for scripts) instead of human-readable output. Installs support local folders, github:owner/repo refs, HTTPS .tgz archives, @@ -8437,15 +8437,11 @@ async function readStdinUtf8() { } async function mintCliImportToken(baseDir) { - const socketPath = process.env[SIDECAR_ENV.IPC_PATH]; - if (typeof socketPath !== 'string' || socketPath.length === 0) return null; + const client = SidecarFactory.connectInherited(); + if (client == null) return null; let result; try { - result = await requestJsonIpc( - socketPath, - { type: SIDECAR_MESSAGES.MINT_IMPORT_TOKEN, input: { baseDir } }, - { timeoutMs: 800 }, - ); + result = await client.invoke(APP_KEYS.DAEMON, SIDECAR_MESSAGES.MINT_IMPORT_TOKEN, { baseDir }, { timeoutMs: 800 }); } catch { return null; } diff --git a/apps/daemon/src/codex-cli.ts b/apps/daemon/src/codex-cli.ts index 29222b144b..561cdff4ca 100644 --- a/apps/daemon/src/codex-cli.ts +++ b/apps/daemon/src/codex-cli.ts @@ -9,6 +9,8 @@ // is a thin spawn() wrapper with a 30s timeout. import { spawn } from 'node:child_process'; +import { createCommandInvocation, type CommandInvocation } from '@open-design/platform'; +import { resolveAgentBin } from './runtimes/resolution.js'; export interface CodexRunnerResult { exitCode: number; @@ -20,12 +22,36 @@ export interface CodexRunner { run(args: string[], opts?: { env?: Record }): Promise; } +type CodexBinaryResolver = ( + id: string, + configuredEnv?: Record, +) => string | null; + +// Resolve the same Codex executable used by the agent runtime before handing +// it to the shared platform invocation builder. This is required on Windows: +// npm installs Codex as `codex.cmd`, and Node's spawn() does not apply the +// shell's PATH/PATHEXT shim handling to a bare `codex` command. Keeping the +// fallback preserves the existing ENOENT signal when Codex is not installed. +export function createCodexCliInvocation( + args: string[], + env: NodeJS.ProcessEnv, + configuredEnv: Record = {}, + resolveBin: CodexBinaryResolver = resolveAgentBin, +): CommandInvocation { + const command = resolveBin('codex', configuredEnv) ?? 'codex'; + return createCommandInvocation({ command, args, env }); +} + const defaultCodexRunner: CodexRunner = { run(args, opts) { return new Promise((resolve, reject) => { - const child = spawn('codex', args, { - env: { ...process.env, ...(opts?.env ?? {}) }, + const configuredEnv = opts?.env ?? {}; + const env = { ...process.env, ...configuredEnv }; + const invocation = createCodexCliInvocation(args, env, configuredEnv); + const child = spawn(invocation.command, invocation.args, { + env, stdio: ['ignore', 'pipe', 'pipe'], + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); let stdout = ''; let stderr = ''; diff --git a/apps/daemon/src/daemon-url.ts b/apps/daemon/src/daemon-url.ts index 5a16c4c892..57d4f25dcc 100644 --- a/apps/daemon/src/daemon-url.ts +++ b/apps/daemon/src/daemon-url.ts @@ -2,16 +2,16 @@ import { spawn } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { - SIDECAR_ENV, - SIDECAR_MESSAGES, + APP_KEYS, type DaemonStatusSnapshot, } from "@open-design/sidecar-proto"; -import { requestJsonIpc } from "@open-design/sidecar"; +import { SidecarFactory } from "@open-design/sidecar"; export const DEFAULT_DAEMON_URL = "http://127.0.0.1:7456"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); export interface ResolveDaemonUrlOptions { + connectInherited?: typeof SidecarFactory.connectInherited; /** Value passed via `--daemon-url`. Empty string is treated as unset. */ flagUrl?: string | null; /** Defaults to `process.env`; injected for tests. */ @@ -24,8 +24,7 @@ export interface ResolveDaemonUrlOptions { * Resolve the daemon HTTP base URL for `od` client commands. * * Spawn order: explicit `--daemon-url` flag, `OD_DAEMON_URL` env, then - * a STATUS roundtrip to the concrete sidecar IPC endpoint supplied by - * the lifecycle owner in `OD_SIDECAR_IPC_PATH`, then the default + * inherited sidecar client status, then the default * `tools-dev status --json` runtime. Falls back to the legacy default * for direct `od` launches that do not run as a sidecar. */ @@ -37,25 +36,26 @@ export async function resolveDaemonUrl( if (flagUrl != null && flagUrl.length > 0) return flagUrl; const envUrl = env.OD_DAEMON_URL; if (envUrl != null && envUrl.length > 0) return envUrl; - const discovered = await discoverDaemonUrlFromIpc(env, options.timeoutMs ?? 800); + const discovered = await discoverDaemonUrlFromInheritedClient( + env, + options.timeoutMs ?? 800, + options.connectInherited ?? SidecarFactory.connectInherited, + ); if (discovered != null) return discovered; const toolsDevUrl = await discoverDaemonUrlFromToolsDev(env, options.timeoutMs ?? 800); if (toolsDevUrl != null) return toolsDevUrl; return DEFAULT_DAEMON_URL; } -async function discoverDaemonUrlFromIpc( +async function discoverDaemonUrlFromInheritedClient( env: NodeJS.ProcessEnv, timeoutMs: number, + connectInherited: typeof SidecarFactory.connectInherited, ): Promise { - const socketPath = env[SIDECAR_ENV.IPC_PATH]; - if (socketPath == null || socketPath.length === 0) return null; + const client = connectInherited(env); + if (client == null) return null; try { - const status = await requestJsonIpc( - socketPath, - { type: SIDECAR_MESSAGES.STATUS }, - { timeoutMs }, - ); + const status = await client.status(APP_KEYS.DAEMON, { timeoutMs }); return status?.url ?? null; } catch { return null; diff --git a/apps/daemon/src/diagnostics-export.ts b/apps/daemon/src/diagnostics-export.ts index 55b568cf99..d8e2ad398b 100644 --- a/apps/daemon/src/diagnostics-export.ts +++ b/apps/daemon/src/diagnostics-export.ts @@ -17,7 +17,7 @@ import { APP_KEYS, OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_MODES, - type SidecarStamp, + type LegacySidecarRuntimeLayout, } from '@open-design/sidecar-proto'; import { resolveLogFilePath, @@ -85,7 +85,7 @@ async function resolveDiagnosticsAgentEnvironment( export interface DiagnosticsHandlerOptions { /** Sidecar runtime context, present when daemon is launched via tools-dev or packaged sidecar. */ - runtime: SidecarRuntimeContext | null; + runtime: SidecarRuntimeContext | null; /** Project root used to derive crash-report match strings. */ projectRoot: string; /** Directory containing per-run event logs at //events.jsonl. */ @@ -137,7 +137,7 @@ async function shouldListOptionalSource(path: string): Promise { } async function buildSidecarLogSources( - runtime: SidecarRuntimeContext | null, + runtime: SidecarRuntimeContext | null, ): Promise { if (runtime == null) return []; // In packaged builds `runtime.base` is `/runtime`, so the log @@ -209,7 +209,7 @@ async function buildSidecarLogSources( // The desktop relocates Electron's crashDumps to `/crashes` (see // apps/desktop/src/main/crash-diagnostics.ts) so the minidumps live inside the // same log tree this export already collects. Derive that dir the same way. -function resolveDesktopCrashDumpsDir(runtime: SidecarRuntimeContext | null): string | null { +function resolveDesktopCrashDumpsDir(runtime: SidecarRuntimeContext | null): string | null { if (runtime == null) return null; const namespaceRoot = resolveRuntimeNamespaceRoot({ contract: OPEN_DESIGN_SIDECAR_CONTRACT, diff --git a/apps/daemon/src/import-export-routes.ts b/apps/daemon/src/import-export-routes.ts index cc8dcd4e94..87e912e987 100644 --- a/apps/daemon/src/import-export-routes.ts +++ b/apps/daemon/src/import-export-routes.ts @@ -1043,7 +1043,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx const tStart = Date.now(); const { input, title: resolvedTitle, defaultFilename } = await buildDeckRenderInput(renderOptions); - // The renderer call is a cross-process IPC (requestJsonIpc, 600s). A + // The renderer call crosses the sidecar client boundary (600s). A // missing desktop process, broken socket, or timeout is an upstream // renderer outage — surface it as 502 UPSTREAM_UNAVAILABLE (matching the // `!rendered.ok` branch below), not the outer 400 BAD_REQUEST which is for diff --git a/apps/daemon/src/mcp-bootstrap.ts b/apps/daemon/src/mcp-bootstrap.ts index 8474517e66..6d8803bd15 100644 --- a/apps/daemon/src/mcp-bootstrap.ts +++ b/apps/daemon/src/mcp-bootstrap.ts @@ -1,10 +1,9 @@ import { spawn } from "node:child_process"; import { isAbsolute } from "node:path"; -import { requestJsonIpc } from "@open-design/sidecar"; +import { SidecarFactory } from "@open-design/sidecar"; import { - SIDECAR_ENV, - SIDECAR_MESSAGES, + APP_KEYS, type DaemonStatusSnapshot, } from "@open-design/sidecar-proto"; @@ -66,6 +65,7 @@ export function planMcpDaemonBootstrap( } interface EnsureMcpDaemonUrlOptions { + connectInherited?: typeof SidecarFactory.connectInherited; discoverTargetDaemonUrl?: ( env: NodeJS.ProcessEnv, timeoutMs: number, @@ -93,7 +93,8 @@ export async function ensureMcpDaemonUrl( const flagUrl = options.flagUrl ?? null; const resolveDaemonUrl = options.resolveDaemonUrl ?? resolveDaemonUrlDefault; const discoverTargetDaemonUrl = - options.discoverTargetDaemonUrl ?? discoverDaemonUrlFromRegisteredIpc; + options.discoverTargetDaemonUrl ?? discoverDaemonUrlFromInheritedClient; + const connectInherited = options.connectInherited ?? SidecarFactory.connectInherited; const probeDaemon = options.probeDaemon ?? probeDaemonHealth; const sleep = options.sleep ?? delay; const spawnBootstrap = options.spawnBootstrap ?? spawnBootstrapDetached; @@ -103,8 +104,7 @@ export async function ensureMcpDaemonUrl( || (env.OD_DAEMON_URL != null && env.OD_DAEMON_URL.length > 0); const registeredBootstrapTarget = !explicitDaemonUrl - && env[SIDECAR_ENV.IPC_PATH] != null - && env[SIDECAR_ENV.IPC_PATH]!.length > 0 + && connectInherited(env) != null && env.OD_MCP_BOOTSTRAP_COMMAND != null && env.OD_MCP_BOOTSTRAP_COMMAND.length > 0 && env.OD_MCP_BOOTSTRAP_ARGS != null @@ -149,18 +149,14 @@ export async function ensureMcpDaemonUrl( ); } -async function discoverDaemonUrlFromRegisteredIpc( +async function discoverDaemonUrlFromInheritedClient( env: NodeJS.ProcessEnv, timeoutMs: number, ): Promise { - const socketPath = env[SIDECAR_ENV.IPC_PATH]; - if (socketPath == null || socketPath.length === 0) return null; + const client = SidecarFactory.connectInherited(env); + if (client == null) return null; try { - const status = await requestJsonIpc( - socketPath, - { type: SIDECAR_MESSAGES.STATUS }, - { timeoutMs }, - ); + const status = await client.status(APP_KEYS.DAEMON, { timeoutMs }); return status.url; } catch { return null; diff --git a/apps/daemon/src/mcp-install-info.ts b/apps/daemon/src/mcp-install-info.ts index b83e46684d..c7b6fa6d42 100644 --- a/apps/daemon/src/mcp-install-info.ts +++ b/apps/daemon/src/mcp-install-info.ts @@ -3,10 +3,10 @@ // share the exact env/argv/buildHint shape; a divergence here is the // difference between an MCP snippet that works and one that EPERMs out // when pasted into Antigravity / Cursor / VS Code (issue #848), or -// silently misses the sidecar transport endpoint. +// silently misses the inherited sidecar client capability. // // Side effects (the fs.existsSync probes, process.execPath, the -// ELECTRON_RUN_AS_NODE env read, OD_DATA_DIR resolution, sidecar IPC +// ELECTRON_RUN_AS_NODE env read, OD_DATA_DIR resolution, sidecar client // detection) all stay in the caller. This module is intentionally pure // and free of @open-design/sidecar-proto so it can be unit-tested // without booting the daemon. @@ -21,10 +21,10 @@ export interface BuildMcpInstallPayloadInputs { dataDir: string; electronAsNode: boolean; /** True when the daemon was bootstrapped as a sidecar and the - * spawned `od mcp` should discover the live URL via the IPC - * status socket instead of a baked --daemon-url. */ + * spawned `od mcp` should discover the live URL through its inherited + * sidecar client instead of a baked --daemon-url. */ isSidecarMode: boolean; - /** Already-filtered sidecar transport env entries the + /** Opaque sidecar client env entries the * caller wants propagated into the snippet. The caller decides * what's worth propagating; this builder just merges. */ sidecarEnv: Record; @@ -80,7 +80,7 @@ export function buildMcpInstallPayload( env.ELECTRON_RUN_AS_NODE = '1'; } // Sidecar mode: omit --daemon-url so the spawned `od mcp` discovers - // the live URL via the IPC status socket on every spawn, surviving + // the live URL through its inherited client on every spawn, surviving // ephemeral-port restarts. Direct `od --port X` launches have no // socket and need the URL baked. const args = inputs.isSidecarMode diff --git a/apps/daemon/src/mcp-routes.ts b/apps/daemon/src/mcp-routes.ts index 921b1e3203..3c5e71d599 100644 --- a/apps/daemon/src/mcp-routes.ts +++ b/apps/daemon/src/mcp-routes.ts @@ -13,7 +13,7 @@ export interface RegisterMcpRoutesDeps extends RouteDeps<'http' | 'paths' | 'mcp export function registerMcpRoutes(app: Express, ctx: RegisterMcpRoutesDeps) { const { isLocalSameOrigin, resolvedPortRef, sendApiError } = ctx.http; const { OD_BIN, RUNTIME_DATA_DIR, PROJECTS_DIR } = ctx.paths; - const { pendingAuth, daemonUrlRef } = ctx.mcp; + const { pendingAuth, daemonUrlRef, inheritedEnvironment } = ctx.mcp; const getResolvedPort = () => resolvedPortRef.current; const getDaemonUrl = () => daemonUrlRef.current; // Surfaces the absolute paths to the daemon's Node-compatible runtime and @@ -41,20 +41,10 @@ export function registerMcpRoutes(app: Express, ctx: RegisterMcpRoutesDeps) { // differently depending on which install path the user took. function computeInstallPayload(): McpInstallPayload { const cliPath = OD_BIN; - // The daemon was bootstrapped as a sidecar (tools-dev, packaged) iff - // bootstrapSidecarRuntime stamped OD_SIDECAR_IPC_PATH into the env. - // In sidecar mode the snippet omits --daemon-url and the spawned - // `od mcp` discovers the live URL via the concrete IPC endpoint on - // every spawn, so the client config survives ephemeral-port - // restarts. For direct `od` / `od --port X` launches there is no - // IPC socket; the helper bakes --daemon-url so custom ports keep - // working. - const sidecarIpcPath = process.env[SIDECAR_ENV.IPC_PATH]; - const isSidecarMode = sidecarIpcPath != null && sidecarIpcPath.length > 0; - const sidecarEnv: Record = {}; - if (isSidecarMode) { - sidecarEnv[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath; - } + // Forward only the opaque inherited client capability. IPC endpoint + // naming and transport stay private to @open-design/sidecar. + const sidecarEnv = inheritedEnvironment(); + const isSidecarMode = Object.keys(sidecarEnv).length > 0; const mcpBootstrapCommand = process.env.OD_MCP_BOOTSTRAP_COMMAND; if ( mcpBootstrapCommand != null diff --git a/apps/daemon/src/routes/telemetry.ts b/apps/daemon/src/routes/telemetry.ts index 04e6aa7a0d..d18af0b0b9 100644 --- a/apps/daemon/src/routes/telemetry.ts +++ b/apps/daemon/src/routes/telemetry.ts @@ -1,5 +1,5 @@ import express, { type Express } from 'express'; -import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto'; +import { SIDECAR_DEFAULTS } from '@open-design/sidecar-proto'; import { randomUUID } from 'node:crypto'; import { type McpAnalyticsEventRequest, @@ -37,10 +37,15 @@ export interface DaemonTelemetry { export interface RegisterTelemetryRoutesDeps { dataDir: string; + namespace?: string; readAppConfig: typeof readAppConfig; writeAppConfig: typeof writeAppConfig; } +export function resolveInstallerObservationNamespace(namespace: string | undefined): string { + return namespace ?? SIDECAR_DEFAULTS.namespace; +} + export async function resolveMcpAnalyticsContext( deps: RegisterTelemetryRoutesDeps, ): Promise { @@ -258,7 +263,7 @@ export function registerTelemetryRoutes(app: Express, deps: RegisterTelemetryRou currentVersion: cachedAppVersion.version, dataRoot: dataDir, logger: console, - namespace: process.env[SIDECAR_ENV.NAMESPACE] ?? SIDECAR_DEFAULTS.namespace, + namespace: resolveInstallerObservationNamespace(deps.namespace), }).catch(() => { // Update-apply telemetry must not delay daemon version readiness. }); diff --git a/apps/daemon/src/server-context.ts b/apps/daemon/src/server-context.ts index 19931d8356..b436a1d8cf 100644 --- a/apps/daemon/src/server-context.ts +++ b/apps/daemon/src/server-context.ts @@ -186,7 +186,11 @@ export interface ServerContext { orbit: any; nativeDialogs: any; research: any; - mcp: any; + mcp: { + pendingAuth: any; + daemonUrlRef: { current: string }; + inheritedEnvironment: (baseEnv?: NodeJS.ProcessEnv) => Record; + }; plugins: any; resources: ResourceDeps; routines: RoutineDeps; diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index db64e56e1b..3118f0e663 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -1709,6 +1709,7 @@ export function createAgentRuntimeEnv( daemonUrl: string, toolTokenGrant: { token?: string } | null = null, nodeBin: string = OD_NODE_BIN, + inheritedEnvironment: (baseEnv?: NodeJS.ProcessEnv) => Record = () => ({}), ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = applySandboxRuntimeEnv( { @@ -1719,6 +1720,7 @@ export function createAgentRuntimeEnv( }, SANDBOX_RUNTIME, ); + Object.assign(env, inheritedEnvironment(baseEnv)); // The daemon API token authorizes the whole non-loopback API surface. Agent // children receive only their run-scoped tool capability, never that broad // credential inherited from the daemon process (including Windows casing). @@ -1737,10 +1739,6 @@ export function createAgentRuntimeEnv( env[pathextKey] = '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC'; } } - const sidecarIpcPath = baseEnv[SIDECAR_ENV.IPC_PATH]; - if (typeof sidecarIpcPath === 'string' && sidecarIpcPath.length > 0) { - env[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath; - } if (SANDBOX_RUNTIME.enabled) { const noProxy = mergeNoProxyWithLoopbackDefaults(env.NO_PROXY ?? env.no_proxy); if (noProxy) { @@ -2834,6 +2832,8 @@ export interface StartServerOptions { returnServer?: boolean; runtime?: DaemonRuntimeContext | null; staticDir?: string; + /** Opaque child-process environment supplied by the runtime integration seam. */ + inheritedEnvironment?: (baseEnv?: NodeJS.ProcessEnv) => Record; /** Daemon-owned host capability facts. HTTP/model output cannot populate it. */ odNextExecutionPreflightResolver?: OdNextExecutionPreflightResolver | null; /** @@ -2870,6 +2870,7 @@ export async function startServer({ desktopArtifactExporter = null, runtime = null, staticDir = STATIC_DIR, + inheritedEnvironment = () => ({}), odNextExecutionPreflightResolver = null, odNextComplexProductionResolver = null, }: StartServerOptions = {}) { @@ -7470,6 +7471,7 @@ export async function startServer({ const telemetry = registerTelemetryRoutes(app, { dataDir: RUNTIME_DATA_DIR, + namespace: runtime?.namespace, readAppConfig, writeAppConfig, }); @@ -8151,7 +8153,7 @@ export async function startServer({ registerMcpRoutes(app, { http: httpDeps, paths: pathDeps, - mcp: { pendingAuth: mcpPendingAuth, daemonUrlRef }, + mcp: { pendingAuth: mcpPendingAuth, daemonUrlRef, inheritedEnvironment }, }); registerXaiRoutes(app, { http: httpDeps, @@ -12507,7 +12509,7 @@ export async function startServer({ spawnEnvForAgent( def.id, { - ...createAgentRuntimeEnv(process.env, daemonUrl, toolTokenGrant), + ...createAgentRuntimeEnv(process.env, daemonUrl, toolTokenGrant, OD_NODE_BIN, inheritedEnvironment), ...(def.env || {}), }, configuredAgentEnv, @@ -13348,7 +13350,7 @@ export async function startServer({ const agentSpawnEnv = spawnEnvForAgent( def.id, { - ...createAgentRuntimeEnv(process.env, daemonUrl, toolTokenGrant), + ...createAgentRuntimeEnv(process.env, daemonUrl, toolTokenGrant, OD_NODE_BIN, inheritedEnvironment), ...(def.env || {}), ...browserUseRuntimeEnv, }, diff --git a/apps/daemon/src/sidecar/index.ts b/apps/daemon/src/sidecar/index.ts index 1710dcca13..9f048a18d3 100644 --- a/apps/daemon/src/sidecar/index.ts +++ b/apps/daemon/src/sidecar/index.ts @@ -1,45 +1,85 @@ -import { APP_KEYS, OPEN_DESIGN_SIDECAR_CONTRACT } from "@open-design/sidecar-proto"; -import { bootstrapSidecarRuntime } from "@open-design/sidecar"; -import { readProcessStamp } from "@open-design/platform"; +import { APP_KEYS, SIDECAR_MESSAGES, isSidecarSource } from "@open-design/sidecar-proto"; +import { SidecarFactory, type SidecarClient } from "@open-design/sidecar"; -import { startDaemonSidecar } from "./server.js"; +import { startDaemonSidecar, type DaemonSidecarHandle } from "./server.js"; import { executeLegacyPayloadDesktopHandoff, prepareLegacyPayloadDesktopHandoff, } from "./payload-desktop-handoff.js"; +import { waitForParentMonitorRelease } from "./parent-monitor-gate.js"; async function main(): Promise { - const stamp = readProcessStamp(process.argv.slice(2), OPEN_DESIGN_SIDECAR_CONTRACT); - if (stamp == null) throw new Error("sidecar stamp is required"); - - const runtime = bootstrapSidecarRuntime(stamp, process.env, { - app: APP_KEYS.DAEMON, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, + let runtimeHandle: DaemonSidecarHandle | null = null; + const invoke = async (action: string, input: unknown) => { + if (runtimeHandle == null) throw new Error("daemon sidecar is not running"); + return await runtimeHandle.invoke(action, input); + }; + let client!: SidecarClient; + client = SidecarFactory.create({ + handlers: { + [SIDECAR_MESSAGES.MINT_IMPORT_TOKEN]: (input) => invoke(SIDECAR_MESSAGES.MINT_IMPORT_TOKEN, input), + [SIDECAR_MESSAGES.REGISTER_DESKTOP_AUTH]: (input) => invoke(SIDECAR_MESSAGES.REGISTER_DESKTOP_AUTH, input), + [SIDECAR_MESSAGES.REGISTER_WEB_URL]: (input) => invoke(SIDECAR_MESSAGES.REGISTER_WEB_URL, input), + }, + lifecycle: { + async start(resources) { + if (client.stamp.app !== APP_KEYS.DAEMON) throw new Error(`daemon sidecar cannot run stamp app ${client.stamp.app}`); + if (!isSidecarSource(client.stamp.source)) throw new Error(`unsupported daemon sidecar source: ${client.stamp.source}`); + if (resources.dataRoot == null) delete process.env.OD_DATA_DIR; + else process.env.OD_DATA_DIR = resources.dataRoot; + const started = await startDaemonSidecar({ + base: resources.runtimeRoot, + mode: client.stamp.mode, + namespace: client.stamp.namespace, + source: client.stamp.source, + }, { + inheritedEnvironment: (baseEnv) => SidecarFactory.inheritedEnvironment(baseEnv), + invokeDesktop: async (action: string, input: unknown, timeoutMs: number) => + await client.invoke(APP_KEYS.DESKTOP, action, input, { timeoutMs }), + port: resources.port, + statusDesktop: async (timeoutMs: number) => + await client.status(APP_KEYS.DESKTOP, { timeoutMs }), + }); + runtimeHandle = started; + return started; + }, + async status(runtime) { + return await runtime.status(); + }, + async stop(runtime) { + await waitForParentMonitorRelease(); + await runtime.stop(); + runtimeHandle = null; + }, + }, }); - const desktopHandoff = await prepareLegacyPayloadDesktopHandoff({ - namespace: runtime.namespace, - runtimeRoot: runtime.base, - source: runtime.source, + await client.start(); + if (!isSidecarSource(client.stamp.source)) throw new Error(`unsupported daemon sidecar source: ${client.stamp.source}`); + const desktopHandoff = client.resources.dataRoot == null ? null : await prepareLegacyPayloadDesktopHandoff({ + dataRoot: client.resources.dataRoot, + namespace: client.stamp.namespace, + outerPid: client.resources.ownerPid, + requestDesktopStatus: async () => await client.status(APP_KEYS.DESKTOP, { timeoutMs: 800 }), + runtimeRoot: client.resources.runtimeRoot, + source: client.stamp.source, }).catch((error: unknown) => { console.warn("[packaged desktop handoff] prepare failed", error); return null; }); - const server = await startDaemonSidecar(runtime); - - process.stdout.write(`${JSON.stringify(await server.status(), null, 2)}\n`); if (desktopHandoff?.kind === "none") { console.info("[packaged desktop handoff] skipped", { reason: desktopHandoff.reason }); + } else if (desktopHandoff?.kind === "prepared") { + void executeLegacyPayloadDesktopHandoff(desktopHandoff, { + requestDesktop: async (message) => message === "status" + ? await client.status(APP_KEYS.DESKTOP, { timeoutMs: 800 }) + : await client.requestStop(APP_KEYS.DESKTOP, { timeoutMs: 800 }), + }).then((result) => { + console.info("[packaged desktop handoff]", result); + }).catch((error: unknown) => { + console.warn("[packaged desktop handoff] execute failed", error); + }); } - if (desktopHandoff?.kind === "prepared") { - void executeLegacyPayloadDesktopHandoff(desktopHandoff) - .then((result) => { - console.info("[packaged desktop handoff]", result); - }) - .catch((error: unknown) => { - console.warn("[packaged desktop handoff] execute failed", error); - }); - } - await server.waitUntilStopped(); + await client.waitUntilStopped(); } void main().catch((error: unknown) => { diff --git a/apps/daemon/src/sidecar/parent-monitor-gate.ts b/apps/daemon/src/sidecar/parent-monitor-gate.ts index f3f2f81cc1..e1e0411604 100644 --- a/apps/daemon/src/sidecar/parent-monitor-gate.ts +++ b/apps/daemon/src/sidecar/parent-monitor-gate.ts @@ -1,5 +1,3 @@ -import { SIDECAR_ENV } from "@open-design/sidecar-proto"; - let parentMonitorExitHolds = 0; const releaseWaiters: Array<() => void> = []; @@ -35,55 +33,3 @@ export function resetParentMonitorExitHoldForTests(): void { parentMonitorExitHolds = 0; notifyReleaseWaiters(); } - -export function scheduleHeldDaemonExit( - stop: () => Promise, - exit: (code?: number) => void = (code) => process.exit(code), -): boolean { - const deferred = isParentMonitorExitHeld(); - setImmediate(() => { - void waitForParentMonitorRelease() - .then(() => stop()) - .finally(() => exit(0)); - }); - return deferred; -} - -function defaultIsProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -export function attachParentMonitor( - stop: () => Promise, - options: { - env?: NodeJS.ProcessEnv; - exit?: (code?: number) => void; - intervalMs?: number; - isProcessAlive?: (pid: number) => boolean; - } = {}, -): () => void { - const env = options.env ?? process.env; - const parentPid = Number(env[SIDECAR_ENV.TOOLS_DEV_PARENT_PID]); - if (!Number.isInteger(parentPid) || parentPid <= 0) return () => undefined; - - const isAlive = options.isProcessAlive ?? defaultIsProcessAlive; - const exit = options.exit ?? ((code) => process.exit(code)); - const intervalMs = options.intervalMs ?? 1000; - let exiting = false; - - const timer = setInterval(() => { - if (isAlive(parentPid) || isParentMonitorExitHeld() || exiting) return; - exiting = true; - clearInterval(timer); - void stop().finally(() => exit(0)); - }, intervalMs); - timer.unref(); - return () => { - clearInterval(timer); - }; -} diff --git a/apps/daemon/src/sidecar/payload-desktop-handoff.ts b/apps/daemon/src/sidecar/payload-desktop-handoff.ts index 81e4022350..89cf1cdc65 100644 --- a/apps/daemon/src/sidecar/payload-desktop-handoff.ts +++ b/apps/daemon/src/sidecar/payload-desktop-handoff.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; -import { spawn } from "node:child_process"; -import { lstat, rm } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { @@ -20,24 +19,15 @@ import { type LauncherRuntimeDescriptor, type LauncherVersionPointer, } from "@open-design/launcher-proto"; -import { createProcessStampArgs } from "@open-design/platform"; import { releaseChannelFromNamespace, releaseChannelFromVersion } from "@open-design/release"; -import { - readJsonFile, - requestJsonIpc, - resolveAppIpcPath, - writeJsonFile, -} from "@open-design/sidecar"; +import { spawnSidecar } from "@open-design/sidecar"; import { APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_ENV, - SIDECAR_MESSAGES, SIDECAR_MODES, SIDECAR_SOURCES, type DesktopStatusSnapshot, type SidecarSource, - type SidecarStamp, } from "@open-design/sidecar-proto"; import { holdParentMonitorExit } from "./parent-monitor-gate.js"; @@ -52,19 +42,9 @@ const SIDECAR_ONLY_ENV_KEYS = [ "OD_SIDECAR_IPC_PATH", "OD_SIDECAR_NAMESPACE", "OD_SIDECAR_SOURCE", - // Packaged daemon/web now inherit OD_TOOLS_DEV_PARENT_PID so they exit with - // the outer Electron. The replacement payload desktop waits for that same - // PID to die, then must not treat it as its own lifecycle owner. SIDECAR_ENV.TOOLS_DEV_PARENT_PID, ] as const; -type DesktopRootIdentity = { - executablePath: string; - pid: number; - stamp: SidecarStamp; - version: number; -}; - type LauncherPayloadManifest = { channel: string; entry: { @@ -83,11 +63,29 @@ type LauncherInstallDescriptor = { schemaVersion: typeof LAUNCHER_SCHEMA_VERSION; }; +async function readJsonFile(filePath: string): Promise { + try { + return JSON.parse(await readFile(filePath, "utf8")) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) return null; + throw error; + } +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + await mkdir(dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await rename(temporaryPath, filePath); +} + export type PreparedLegacyPayloadDesktopHandoff = { + dataRoot: string; descriptor: LauncherDesktopHandoffDescriptor; kind: "prepared"; launcherPaths: LauncherPaths; runtimeRoot: string; + source: SidecarSource; }; export type LegacyPayloadDesktopHandoffPreparation = @@ -121,9 +119,15 @@ function samePointer( left.version === right.version; } -function samePath(left: string, right: string, platform: NodeJS.Platform): boolean { - const normalizedLeft = resolve(left); - const normalizedRight = resolve(right); +async function canonicalPath(value: string): Promise { + return await realpath(value).catch(() => resolve(value)); +} + +async function samePath(left: string, right: string, platform: NodeJS.Platform): Promise { + const [normalizedLeft, normalizedRight] = await Promise.all([ + canonicalPath(left), + canonicalPath(right), + ]); return platform === "win32" ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() : normalizedLeft === normalizedRight; @@ -174,28 +178,6 @@ async function resolvePayloadExecutable(options: { : null; } -async function readDesktopIdentity( - runtimeRoot: string, - namespace: string, -): Promise { - const identity = await readJsonFile(join(runtimeRoot, "desktop-root.json")); - if ( - identity == null || - identity.version !== 1 || - !Number.isSafeInteger(identity.pid) || - identity.pid <= 0 || - typeof identity.executablePath !== "string" || - !isAbsolute(identity.executablePath) || - identity.stamp?.app !== APP_KEYS.DESKTOP || - identity.stamp.namespace !== namespace || - ( - identity.stamp.source !== SIDECAR_SOURCES.PACKAGED && - identity.stamp.source !== SIDECAR_SOURCES.TOOLS_PACK - ) - ) return null; - return identity; -} - async function readRuntime( launcherPaths: LauncherPaths, ): Promise { @@ -222,10 +204,10 @@ async function readAttempt( async function resolveInstalledOuterIdentity(options: { launcherPaths: LauncherPaths; - parentPid: number; + outerPid: number; platform: NodeJS.Platform; }): Promise { - if (!Number.isSafeInteger(options.parentPid) || options.parentPid <= 0) return null; + if (!Number.isSafeInteger(options.outerPid) || options.outerPid <= 0) return null; const install = await readJsonFile(options.launcherPaths.installPath); if ( install == null || @@ -236,25 +218,31 @@ async function resolveInstalledOuterIdentity(options: { !isAbsolute(install.launchPath) ) return null; const executablePath = options.platform === "darwin" && install.launchPath.endsWith(".app") - ? join( - install.launchPath, - "Contents", - "MacOS", - basename(install.launchPath, ".app"), - ) + ? await resolveMacBundleExecutable(install.launchPath) : install.launchPath; + if (executablePath == null) return null; const entry = await lstat(executablePath).catch(() => null); if (entry == null || !entry.isFile() || entry.isSymbolicLink()) return null; - return { executablePath, pid: options.parentPid }; + return { executablePath, pid: options.outerPid }; +} + +async function resolveMacBundleExecutable(bundlePath: string): Promise { + const executableRoot = join(bundlePath, "Contents", "MacOS"); + const entries = await readdir(executableRoot, { withFileTypes: true }).catch(() => []); + const executables = entries.filter((entry) => entry.isFile() && !entry.isSymbolicLink()); + const executable = executables.length === 1 ? executables[0] : null; + return executable == null ? null : join(executableRoot, executable.name); } export async function prepareLegacyPayloadDesktopHandoff(options: { + dataRoot: string; env?: NodeJS.ProcessEnv; namespace: string; now?: () => Date; - parentPid?: number; + outerPid: number | null; platform?: NodeJS.Platform; randomId?: () => string; + requestDesktopStatus?: () => Promise; runtimeRoot: string; source: SidecarSource; }): Promise { @@ -291,13 +279,12 @@ export async function prepareLegacyPayloadDesktopHandoff(options: { namespace: options.namespace, root: installationRoot, }); - const [runtime, attempt, identity, outer, payloadExecutablePath] = await Promise.all([ + const [runtime, attempt, outer, payloadExecutablePath, desktopStatus] = await Promise.all([ readRuntime(launcherPaths), readAttempt(launcherPaths), - readDesktopIdentity(options.runtimeRoot, options.namespace), resolveInstalledOuterIdentity({ launcherPaths, - parentPid: options.parentPid ?? process.ppid, + outerPid: options.outerPid ?? 0, platform, }), resolvePayloadExecutable({ @@ -306,16 +293,20 @@ export async function prepareLegacyPayloadDesktopHandoff(options: { namespace: options.namespace, platform, }), + options.requestDesktopStatus?.().catch(() => null) ?? null, ]); if (runtime == null) return { kind: "none", reason: "invalid-runtime" }; if (outer == null) return { kind: "none", reason: "invalid-install-anchor" }; if (payloadExecutablePath == null) return { kind: "none", reason: "invalid-payload" }; - if (identity != null && samePath(identity.executablePath, payloadExecutablePath, platform)) { + if (desktopStatus != null && desktopStatus.pid === outer.pid && + typeof desktopStatus.executablePath === "string" && + await samePath(desktopStatus.executablePath, payloadExecutablePath, platform)) { return { kind: "none", reason: "payload-desktop-active" }; } - if (identity != null && ( - identity.pid !== outer.pid || - !samePath(identity.executablePath, outer.executablePath, platform) + if (desktopStatus != null && ( + desktopStatus.pid !== outer.pid || + typeof desktopStatus.executablePath !== "string" || + !(await samePath(desktopStatus.executablePath, outer.executablePath, platform)) )) return { kind: "none", reason: "desktop-identity-mismatch" }; const existingRaw = await readJsonFile(launcherPaths.handoffPath); @@ -381,10 +372,12 @@ export async function prepareLegacyPayloadDesktopHandoff(options: { }; await writeJsonFile(launcherPaths.handoffPath, descriptor); return { + dataRoot: options.dataRoot, descriptor, kind: "prepared", launcherPaths, runtimeRoot: options.runtimeRoot, + source: options.source, }; } @@ -398,25 +391,25 @@ async function waitForOuterConfirm( ): Promise<"confirmed" | "outer-not-confirmed" | "payload-desktop-active"> { const deadline = Date.now() + options.confirmTimeoutMs; while (Date.now() < deadline) { - const [runtime, attempt, identity, status] = await Promise.all([ + const [runtime, attempt, status] = await Promise.all([ readRuntime(prepared.launcherPaths), readAttempt(prepared.launcherPaths), - readDesktopIdentity(prepared.runtimeRoot, prepared.descriptor.namespace), options.requestDesktop("status").catch(() => null) as Promise, ]); if ( - identity?.pid === prepared.descriptor.outer.pid && - samePath(identity.executablePath, prepared.descriptor.payloadExecutablePath, process.platform) + status?.pid === prepared.descriptor.outer.pid && + typeof status.executablePath === "string" && + await samePath(status.executablePath, prepared.descriptor.payloadExecutablePath, process.platform) ) return "payload-desktop-active"; if ( runtime != null && attempt == null && samePointer(runtime.active, prepared.descriptor.source) && samePointer(runtime.lastSuccessful, prepared.descriptor.source) && - identity?.pid === prepared.descriptor.outer.pid && - samePath(identity.executablePath, prepared.descriptor.outer.executablePath, process.platform) && status?.state === "running" && - status.pid === prepared.descriptor.outer.pid + status.pid === prepared.descriptor.outer.pid && + typeof status.executablePath === "string" && + await samePath(status.executablePath, prepared.descriptor.outer.executablePath, process.platform) ) return "confirmed"; await options.sleep(HANDOFF_POLL_INTERVAL_MS); } @@ -431,20 +424,12 @@ export async function executeLegacyPayloadDesktopHandoff( now?: () => Date; requestDesktop?: (message: "shutdown" | "status") => Promise; sleep?: (durationMs: number) => Promise; - spawn?: typeof spawn; + spawn?: typeof spawnSidecar; writeJsonFile?: typeof writeJsonFile; } = {}, ): Promise { - const desktopIpcPath = resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: prepared.descriptor.namespace, - }); - const requestDesktop = options.requestDesktop ?? (async (message) => await requestJsonIpc( - desktopIpcPath, - { type: message === "status" ? SIDECAR_MESSAGES.STATUS : SIDECAR_MESSAGES.SHUTDOWN }, - { timeoutMs: 800 }, - )); + if (options.requestDesktop == null) throw new Error("desktop sidecar client is required"); + const requestDesktop = options.requestDesktop; const confirmation = await waitForOuterConfirm(prepared, { confirmTimeoutMs: options.confirmTimeoutMs ?? HANDOFF_CONFIRM_TIMEOUT_MS, requestDesktop, @@ -487,12 +472,12 @@ export async function executeLegacyPayloadDesktopHandoff( updatedAt: now, }; - const desktopStamp: SidecarStamp = { + const desktopStamp = { app: APP_KEYS.DESKTOP, - ipc: desktopIpcPath, + channel: prepared.launcherPaths.channel, mode: SIDECAR_MODES.RUNTIME, namespace: prepared.descriptor.namespace, - source: SIDECAR_SOURCES.PACKAGED, + source: prepared.source, }; const args = [ ...buildLauncherAfterQuitArgs({ @@ -500,50 +485,47 @@ export async function executeLegacyPayloadDesktopHandoff( timeoutMs: HANDOFF_PAYLOAD_WAIT_TIMEOUT_MS, }), ...buildLauncherHandoffResumeArgs({ handoffId: prepared.descriptor.handoffId }), - ...createProcessStampArgs(desktopStamp, OPEN_DESIGN_SIDECAR_CONTRACT), ]; - // Packaged daemons now monitor OD_TOOLS_DEV_PARENT_PID and exit when the - // outer Electron dies. Hold before spawn: a crash after the child emits - // `spawn` but before the hold would let the parent monitor stop the daemon - // while the journal is still `prepared`, and the detached replacement then - // rejects resume. Desktop SHUTDOWN also acks before asynchronously exiting, - // then beforeShutdown -> sidecars.close() sends daemon SHUTDOWN. Keep the - // hold through the three commits so the replacement can still resume. const persist = options.writeJsonFile ?? writeJsonFile; const releaseParentMonitor = holdParentMonitorExit(); try { - let child: ReturnType; + let generation: Awaited>; try { - child = (options.spawn ?? spawn)(prepared.descriptor.payloadExecutablePath, args, { + generation = await (options.spawn ?? spawnSidecar)({ + args, + command: prepared.descriptor.payloadExecutablePath, cwd: dirname(prepared.descriptor.payloadExecutablePath), - detached: true, env: desktopProcessEnv(options.env ?? process.env, prepared.runtimeRoot), - stdio: "ignore", - windowsHide: true, + logFd: null, + resources: { + dataRoot: prepared.dataRoot, + ownerPid: null, + port: 0, + runtimeRoot: prepared.runtimeRoot, + }, + stamp: desktopStamp, }); - await new Promise((resolveSpawn, rejectSpawn) => { - child.once("spawn", () => resolveSpawn()); - child.once("error", rejectSpawn); - }); - child.unref(); } catch { return { kind: "aborted", reason: "spawn-failed" }; } - try { await requestDesktop("shutdown"); } catch { + const cleanup = await generation.stop({ termGraceMs: 0 }); + if (cleanup.remainingPids.length > 0) { + throw new Error(`payload desktop handoff rollback left generation: ${cleanup.remainingPids.join(", ")}`); + } return { kind: "aborted", reason: "shutdown-failed" }; } - // Commit the armed journal and rewritten runtime/attempt state only after both - // the payload child has actually spawned and the old desktop has accepted the - // shutdown. Writing earlier would strand an "armed" journal on disk when - // `spawn()` throws or the shutdown request fails: the next cold start bails out - // of prepareLegacyPayloadDesktopHandoff() with reason "already-armed" and the - // install stays pinned to the old desktop generation. The old desktop is still - // alive while it acks the shutdown, and the payload waits for its pid to exit - // before resuming, so these writes still land before the payload reads them. + // Commit the armed journal and rewritten runtime/attempt state only after both + // the payload child has actually spawned and the old desktop has accepted the + // shutdown. Writing earlier would strand an "armed" journal on disk when + // `spawn()` throws or the shutdown request fails: the next cold start bails out + // of prepareLegacyPayloadDesktopHandoff() with reason "already-armed" and the + // install stays pinned to the old desktop generation. The old desktop is still + // alive while it acks the shutdown, and the payload waits for its pid to exit + // before resuming, so these writes still land before the payload reads them. await persist(prepared.launcherPaths.handoffPath, armed); await persist(prepared.launcherPaths.attemptsPath, attempt); await persist(prepared.launcherPaths.runtimePath, runtime); diff --git a/apps/daemon/src/sidecar/server.ts b/apps/daemon/src/sidecar/server.ts index e89df699ea..48ebc981da 100644 --- a/apps/daemon/src/sidecar/server.ts +++ b/apps/daemon/src/sidecar/server.ts @@ -1,8 +1,6 @@ import { randomBytes } from "node:crypto"; import { - APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_ENV, SIDECAR_MESSAGES, normalizeDaemonSidecarMessage, @@ -17,15 +15,7 @@ import { type DesktopRenderSlidesResult, type DesktopStatusSnapshot, type MintImportTokenResult, - type SidecarStamp, } from "@open-design/sidecar-proto"; -import { - createJsonIpcServer, - requestJsonIpc, - resolveAppIpcPath, - type JsonIpcServerHandle, - type SidecarRuntimeContext, -} from "@open-design/sidecar"; import { startDaemonRuntime, type StartedDaemonRuntime } from "../daemon-startup.js"; import { @@ -35,7 +25,6 @@ import { setDesktopAuthSecret, signDesktopImportToken, } from "../desktop-auth.js"; -import { attachParentMonitor, scheduleHeldDaemonExit } from "./parent-monitor-gate.js"; /** * PR #974 round 6 (mrcfps): pure wrapper that overlays the live @@ -55,11 +44,20 @@ const WEB_PORT_ENV = SIDECAR_ENV.WEB_PORT; const DESKTOP_IMPORT_TOKEN_TTL_MS = 60_000; export type DaemonSidecarHandle = { + invoke(action: string, input: unknown): Promise; status(): Promise; stop(): Promise; waitUntilStopped(): Promise; }; +export type DaemonSidecarRuntime = { + base: string; + mode?: string; + namespace: string; + source?: string; + [field: string]: unknown; +}; + function parsePort(value: string | undefined): number { if (value == null || value.trim().length === 0) return 0; const port = Number(value); @@ -102,35 +100,28 @@ export function mintImportTokenForCli(baseDir: string): MintImportTokenResult { } export async function startDaemonSidecar( - runtime: SidecarRuntimeContext, - options: { exit?: (code?: number) => void } = {}, + runtime: DaemonSidecarRuntime, + options: { + inheritedEnvironment?: (baseEnv?: NodeJS.ProcessEnv) => Record; + invokeDesktop?: (action: string, input: unknown, timeoutMs: number) => Promise; + port?: number; + statusDesktop?: (timeoutMs: number) => Promise; + } = {}, ): Promise { + const invokeDesktop = options.invokeDesktop ?? (async () => { + throw new Error("desktop sidecar is unavailable"); + }); + const statusDesktop = options.statusDesktop ?? (async () => { + throw new Error("desktop sidecar is unavailable"); + }); const serverHandle: StartedDaemonRuntime = await startDaemonRuntime({ desktopPdfExporter: async (input: DesktopExportPdfInput): Promise => { - const desktopIpc = resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: runtime.namespace, - }); - return await requestJsonIpc( - desktopIpc, - { input, type: SIDECAR_MESSAGES.EXPORT_PDF }, - { timeoutMs: 600_000 }, - ); + return await invokeDesktop(SIDECAR_MESSAGES.EXPORT_PDF, input, 600_000); }, desktopFrameRenderer: async (input: DesktopRenderFramesInput): Promise => { - const desktopIpc = resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: runtime.namespace, - }); let status: DesktopStatusSnapshot; try { - status = await requestJsonIpc( - desktopIpc, - { type: SIDECAR_MESSAGES.STATUS }, - { timeoutMs: 5_000 }, - ); + status = await statusDesktop(5_000); } catch { return { ok: false, @@ -145,37 +136,16 @@ export async function startDaemonSidecar( errorCode: "FRAME_RENDERER_NOT_READY", }; } - return await requestJsonIpc( - desktopIpc, - { input, type: SIDECAR_MESSAGES.RENDER_FRAMES }, - { timeoutMs: 600_000 }, - ); + return await invokeDesktop(SIDECAR_MESSAGES.RENDER_FRAMES, input, 600_000); }, desktopSlideRenderer: async (input: DesktopRenderSlidesInput): Promise => { - const desktopIpc = resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: runtime.namespace, - }); - return await requestJsonIpc( - desktopIpc, - { input, type: SIDECAR_MESSAGES.RENDER_SLIDES }, - { timeoutMs: 600_000 }, - ); + return await invokeDesktop(SIDECAR_MESSAGES.RENDER_SLIDES, input, 600_000); }, desktopArtifactExporter: async (input: DesktopExportArtifactInput): Promise => { - const desktopIpc = resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: runtime.namespace, - }); - return await requestJsonIpc( - desktopIpc, - { input, type: SIDECAR_MESSAGES.EXPORT_ARTIFACT }, - { timeoutMs: 600_000 }, - ); + return await invokeDesktop(SIDECAR_MESSAGES.EXPORT_ARTIFACT, input, 600_000); }, - port: parsePort(process.env[DAEMON_PORT_ENV]), + ...(options.inheritedEnvironment == null ? {} : { inheritedEnvironment: options.inheritedEnvironment }), + port: options.port ?? parsePort(process.env[DAEMON_PORT_ENV]), runtime, }); @@ -193,7 +163,6 @@ export async function startDaemonSidecar( updatedAt: new Date().toISOString(), url: serverHandle.url, }; - let ipcServer: JsonIpcServerHandle | null = null; let stopped = false; let resolveStopped!: () => void; const stoppedPromise = new Promise((resolveStop) => { @@ -205,27 +174,20 @@ export async function startDaemonSidecar( stopped = true; state.state = "stopped"; state.updatedAt = new Date().toISOString(); - await ipcServer?.close().catch(() => undefined); await serverHandle.stop().catch(() => undefined); resolveStopped(); } - attachParentMonitor(stop); - - ipcServer = await createJsonIpcServer({ - socketPath: runtime.ipc, - handler: async (message: unknown) => { - const request = normalizeDaemonSidecarMessage(message); + async function invoke(action: string, input: unknown): Promise { + const request = normalizeDaemonSidecarMessage({ input, type: action }); switch (request.type) { case SIDECAR_MESSAGES.STATUS: // PR #974 round 6 (mrcfps): recompute the gate flag per // request so `tools-dev start desktop` sees the live value // (the flag flips after REGISTER_DESKTOP_AUTH and stays sticky). return withCurrentDesktopAuthGate(state); - case SIDECAR_MESSAGES.SHUTDOWN: { - const deferred = scheduleHeldDaemonExit(stop, options.exit); - return deferred ? { accepted: true, deferred: true } : { accepted: true }; - } + case SIDECAR_MESSAGES.SHUTDOWN: + throw new Error("sidecar lifecycle messages are private"); case SIDECAR_MESSAGES.REGISTER_DESKTOP_AUTH: // PR #974: the desktop main process registers its per-process // auth secret here at startup. From this point on the HTTP @@ -250,20 +212,10 @@ export async function startDaemonSidecar( return { accepted: true }; } } - }, - }); - - for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.on(signal, () => { - // Packaged beforeShutdown sends SHUTDOWN. When the handoff hold is - // active the SHUTDOWN ack includes deferred:true so closeManagedChild - // waits a longer bounded grace before stopProcesses(). SIGTERM from - // that escalation still uses this hold; SIGKILL remains the ceiling. - scheduleHeldDaemonExit(stop, options.exit); - }); } return { + invoke, async status() { return withCurrentDesktopAuthGate(state); }, diff --git a/apps/daemon/tests/cli-phase2c.test.ts b/apps/daemon/tests/cli-phase2c.test.ts index 566080f024..eb3d3df24f 100644 --- a/apps/daemon/tests/cli-phase2c.test.ts +++ b/apps/daemon/tests/cli-phase2c.test.ts @@ -7,8 +7,12 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import url from 'node:url'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { createJsonIpcServer } from '@open-design/sidecar'; -import { SIDECAR_ENV, SIDECAR_MESSAGES, normalizeDaemonSidecarMessage } from '@open-design/sidecar-proto'; +import { + SidecarFactory, + type SidecarClient, + type SidecarStamp, +} from '@open-design/sidecar'; +import { APP_KEYS, SIDECAR_MESSAGES } from '@open-design/sidecar-proto'; import { createAgentRuntimeEnv, startServer } from '../src/server.js'; import { resetDesktopAuthForTests, setDesktopAuthSecret } from '../src/desktop-auth.js'; @@ -24,7 +28,7 @@ describe('Phase 2C CLI wrappers', () => { let baseUrl: string; let shutdown: (() => Promise | void) | undefined; const tempDirs: string[] = []; - const sidecarServers: { close(): Promise }[] = []; + const sidecarClients: SidecarClient[] = []; beforeAll(async () => { const started = (await startServer({ port: 0, returnServer: true })) as { @@ -38,8 +42,8 @@ describe('Phase 2C CLI wrappers', () => { }); afterEach(async () => { - for (const sidecar of sidecarServers.splice(0)) { - await sidecar.close(); + for (const sidecar of sidecarClients.splice(0)) { + await sidecar.stop(); } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); @@ -195,26 +199,46 @@ describe('Phase 2C CLI wrappers', () => { setDesktopAuthSecret(secret); try { - const ipcRoot = makeFolder(); - const ipcPath = path.join(ipcRoot, 'daemon.sock'); - const sidecar = await createJsonIpcServer({ - socketPath: ipcPath, - handler: async (message) => { - const request = normalizeDaemonSidecarMessage(message); - switch (request.type) { - case SIDECAR_MESSAGES.STATUS: - return { desktopAuthGateActive: true, state: 'running', url: baseUrl }; - case SIDECAR_MESSAGES.MINT_IMPORT_TOKEN: - return mintImportTokenForCli(request.input.baseDir); - default: - throw new Error(`unexpected test IPC message: ${request.type}`); - } + const originalSupervisedContext = process.env.OD_SIDECAR_SUPERVISED_CONTEXT; + const stamp: SidecarStamp = { + app: APP_KEYS.DAEMON, + channel: 'local', + mode: 'test', + namespace: `cli-phase2c-${process.pid}-${Date.now()}`, + source: 'daemon-test', + }; + const resources = { + dataRoot: makeFolder(), + ownerPid: null, + port: 0, + runtimeRoot: makeFolder(), + }; + process.env.OD_SIDECAR_SUPERVISED_CONTEXT = JSON.stringify({ + generationPid: process.pid, + resources, + stamp, + }); + const sidecar = SidecarFactory.create({ + handlers: { + [SIDECAR_MESSAGES.MINT_IMPORT_TOKEN]: (input) => { + return mintImportTokenForCli((input as { baseDir: string }).baseDir); + }, + }, + lifecycle: { + async start() { + return { desktopAuthGateActive: true, state: 'running', url: baseUrl }; + }, + status(runtime) { return runtime; }, + async stop() {}, }, }); - sidecarServers.push(sidecar); + if (originalSupervisedContext == null) delete process.env.OD_SIDECAR_SUPERVISED_CONTEXT; + else process.env.OD_SIDECAR_SUPERVISED_CONTEXT = originalSupervisedContext; + await sidecar.start(); + sidecarClients.push(sidecar); const wrapperEnv = createAgentRuntimeEnv( - { PATH: process.env.PATH, [SIDECAR_ENV.IPC_PATH]: ipcPath }, + { PATH: process.env.PATH, ...SidecarFactory.inheritedEnvironment() }, baseUrl, null, process.execPath, @@ -275,7 +299,7 @@ describe('Phase 2C CLI wrappers', () => { } }); - it('preserves desktop-auth-pending when CLI token minting cannot reach sidecar IPC', async () => { + it('preserves desktop-auth-pending without an inherited sidecar connection', async () => { const folder = makeFolder(); await writeFile(path.join(folder, 'index.html'), ''); setDesktopAuthSecret(randomBytes(32)); @@ -283,7 +307,6 @@ describe('Phase 2C CLI wrappers', () => { const failed = await runCliExpectFailure( ['project', 'import', folder, '--name', 'Pending CLI Import', '--json'], - { env: { [SIDECAR_ENV.IPC_PATH]: path.join(folder, 'missing-daemon.sock') } }, ); expect(failed.code).toBe(74); @@ -302,7 +325,6 @@ describe('Phase 2C CLI wrappers', () => { const failed = await runCliExpectFailure( ['project', 'import', folder, '--name', 'Rejected CLI Import', '--json'], - { env: { [SIDECAR_ENV.IPC_PATH]: path.join(folder, 'missing-daemon.sock') } }, ); expect(failed.code).toBe(75); diff --git a/apps/daemon/tests/daemon-url.test.ts b/apps/daemon/tests/daemon-url.test.ts index 36764054ff..406d20885d 100644 --- a/apps/daemon/tests/daemon-url.test.ts +++ b/apps/daemon/tests/daemon-url.test.ts @@ -2,8 +2,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { createJsonIpcServer, type JsonIpcServerHandle } from "@open-design/sidecar"; -import { SIDECAR_ENV, SIDECAR_MESSAGES } from "@open-design/sidecar-proto"; import { resolveDaemonUrl, DEFAULT_DAEMON_URL } from "../src/daemon-url.js"; // Verifies the resolution chain: --daemon-url > OD_DAEMON_URL > sidecar @@ -11,18 +9,15 @@ import { resolveDaemonUrl, DEFAULT_DAEMON_URL } from "../src/daemon-url.js"; // so `od` clients follow the live daemon across ephemeral-port restarts. describe("resolveDaemonUrl", () => { - let ipcBaseDir: string; let fakeBinDir: string; let emptyBinDir: string; beforeAll(() => { - ipcBaseDir = fs.mkdtempSync(path.join(os.tmpdir(), "od-mcp-resolve-")); fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), "od-tools-dev-resolve-")); emptyBinDir = fs.mkdtempSync(path.join(os.tmpdir(), "od-tools-dev-empty-")); }); afterAll(() => { - fs.rmSync(ipcBaseDir, { recursive: true, force: true }); fs.rmSync(fakeBinDir, { recursive: true, force: true }); fs.rmSync(emptyBinDir, { recursive: true, force: true }); }); @@ -32,7 +27,6 @@ describe("resolveDaemonUrl", () => { flagUrl: "http://flag.example:1111", env: { OD_DAEMON_URL: "http://env.example:2222", - [SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "daemon.sock"), }, }); expect(url).toBe("http://flag.example:1111"); @@ -42,7 +36,6 @@ describe("resolveDaemonUrl", () => { const url = await resolveDaemonUrl({ env: { OD_DAEMON_URL: "http://env.example:2222", - [SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "daemon.sock"), }, }); expect(url).toBe("http://env.example:2222"); @@ -52,7 +45,6 @@ describe("resolveDaemonUrl", () => { const url = await resolveDaemonUrl({ env: { PATH: emptyBinDir, - [SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "missing.sock"), }, timeoutMs: 200, }); @@ -84,40 +76,20 @@ describe("resolveDaemonUrl", () => { expect(url).toBe("http://127.0.0.1:60123"); }); - it("discovers the live daemon URL via the concrete sidecar IPC status endpoint", async () => { - const socketPath = process.platform === "win32" - ? `\\\\.\\pipe\\open-design-daemon-url-${process.pid}-${Date.now()}` - : path.join(ipcBaseDir, "daemon.sock"); - let ipc: JsonIpcServerHandle | null = null; - try { - ipc = await createJsonIpcServer({ - socketPath, - handler: (message) => { - if ( - message != null && - typeof message === "object" && - (message as { type?: unknown }).type === SIDECAR_MESSAGES.STATUS - ) { - return { - pid: 4242, - state: "running", - updatedAt: new Date().toISOString(), - url: "http://127.0.0.1:54321", - }; - } - throw new Error("unexpected message"); - }, - }); - - const url = await resolveDaemonUrl({ - env: { - [SIDECAR_ENV.IPC_PATH]: socketPath, - }, - timeoutMs: 1000, - }); - expect(url).toBe("http://127.0.0.1:54321"); - } finally { - await ipc?.close(); - } + it("discovers the live daemon URL through an inherited sidecar client", async () => { + const url = await resolveDaemonUrl({ + connectInherited: (() => ({ + invoke: async () => { throw new Error("unexpected invoke"); }, + status: async () => ({ + pid: 4242, + state: "running", + updatedAt: new Date().toISOString(), + url: "http://127.0.0.1:54321", + }), + })) as never, + env: {}, + timeoutMs: 1000, + }); + expect(url).toBe("http://127.0.0.1:54321"); }); }); diff --git a/apps/daemon/tests/diagnostics-export.test.ts b/apps/daemon/tests/diagnostics-export.test.ts index 80be2260b6..3b1cbf857e 100644 --- a/apps/daemon/tests/diagnostics-export.test.ts +++ b/apps/daemon/tests/diagnostics-export.test.ts @@ -10,7 +10,7 @@ import { APP_KEYS, SIDECAR_MODES, SIDECAR_SOURCES, - type SidecarStamp, + type LegacySidecarRuntimeLayout, } from '@open-design/sidecar-proto'; import type { SidecarRuntimeContext } from '@open-design/sidecar'; @@ -160,11 +160,10 @@ describe('diagnostics export handler — packaged (runtime) layout', () => { await mkdir(dirname(daemonLogPath), { recursive: true }); await writeFile(daemonLogPath, `${marker}\n`, 'utf8'); - const runtime: SidecarRuntimeContext = { + const runtime: SidecarRuntimeContext = { app: APP_KEYS.DAEMON, // packaged launches children with base == /runtime base: join(namespaceRoot, 'runtime'), - ipc: '/tmp/od-diag-test-daemon.sock', mode: SIDECAR_MODES.RUNTIME, namespace: 'release-stable', source: SIDECAR_SOURCES.PACKAGED, @@ -210,10 +209,9 @@ describe('diagnostics export handler — packaged (runtime) layout', () => { await writeFile(join(daemonLogDir, 'latest.log'), 'fresh session line\n', 'utf8'); await writeFile(join(daemonLogDir, 'previous.log'), `${previousMarker}\n`, 'utf8'); - const runtime: SidecarRuntimeContext = { + const runtime: SidecarRuntimeContext = { app: APP_KEYS.DAEMON, base: join(namespaceRoot, 'runtime'), - ipc: '/tmp/od-diag-prev.sock', mode: SIDECAR_MODES.RUNTIME, namespace: 'release-stable', source: SIDECAR_SOURCES.PACKAGED, @@ -254,10 +252,9 @@ describe('diagnostics export handler — packaged (runtime) layout', () => { await mkdir(daemonLogDir, { recursive: true }); await writeFile(join(daemonLogDir, 'latest.log'), 'first-launch session\n', 'utf8'); - const runtime: SidecarRuntimeContext = { + const runtime: SidecarRuntimeContext = { app: APP_KEYS.DAEMON, base: join(namespaceRoot, 'runtime'), - ipc: '/tmp/od-diag-noprev.sock', mode: SIDECAR_MODES.RUNTIME, namespace: 'release-stable', source: SIDECAR_SOURCES.PACKAGED, @@ -302,10 +299,9 @@ describe('diagnostics export handler — packaged (runtime) layout', () => { // any read of it now fail with EACCES rather than ENOENT. await chmod(daemonLogDir, 0o000); - const runtime: SidecarRuntimeContext = { + const runtime: SidecarRuntimeContext = { app: APP_KEYS.DAEMON, base: join(namespaceRoot, 'runtime'), - ipc: '/tmp/od-diag-prevdenied.sock', mode: SIDECAR_MODES.RUNTIME, namespace: 'release-stable', source: SIDECAR_SOURCES.PACKAGED, @@ -342,10 +338,9 @@ describe('diagnostics export handler — packaged (runtime) layout', () => { await mkdir(dirname(daemonLogPath), { recursive: true }); await writeFile(daemonLogPath, 'daemon ok\n', 'utf8'); - const runtime: SidecarRuntimeContext = { + const runtime: SidecarRuntimeContext = { app: APP_KEYS.DAEMON, base: join(namespaceRoot, 'runtime'), - ipc: '/tmp/od-diag-missing.sock', mode: SIDECAR_MODES.RUNTIME, namespace: 'release-beta', source: SIDECAR_SOURCES.PACKAGED, diff --git a/apps/daemon/tests/mcp-bootstrap.test.ts b/apps/daemon/tests/mcp-bootstrap.test.ts index aec193c140..d5522825ac 100644 --- a/apps/daemon/tests/mcp-bootstrap.test.ts +++ b/apps/daemon/tests/mcp-bootstrap.test.ts @@ -29,8 +29,6 @@ describe("planMcpDaemonBootstrap", () => { ELECTRON_RUN_AS_NODE: "1", OD_DAEMON_URL: "http://127.0.0.1:1", OD_DATA_DIR: "/tmp/open-design-data", - OD_SIDECAR_IPC_PATH: - "/tmp/open-design/ipc/stable/daemon.sock", OD_MCP_BOOTSTRAP_COMMAND: "/usr/bin/open", OD_MCP_BOOTSTRAP_ARGS: '["-g","-j","/Applications/Open Design.app","--args","--headless"]', @@ -51,7 +49,6 @@ describe("planMcpDaemonBootstrap", () => { if (plan.action !== "spawn") throw new Error("expected spawn plan"); expect(plan.env.ELECTRON_RUN_AS_NODE).toBeUndefined(); expect(plan.env.OD_DAEMON_URL).toBeUndefined(); - expect(plan.env.OD_SIDECAR_IPC_PATH).toBeUndefined(); expect(plan.env.OD_DATA_DIR).toBe("/tmp/open-design-data"); }); @@ -95,12 +92,11 @@ describe("ensureMcpDaemonUrl", () => { await expect(ensureMcpDaemonUrl({ env: { - OD_SIDECAR_IPC_PATH: - "/tmp/open-design/ipc/stable/daemon.sock", OD_MCP_BOOTSTRAP_COMMAND: "/usr/bin/open", OD_MCP_BOOTSTRAP_ARGS: '["-g","-j","/Applications/Open Design.app","--args","--headless"]', }, + connectInherited: (() => ({ invoke: vi.fn(), status: vi.fn() })) as never, discoverTargetDaemonUrl, probeDaemon, resolveDaemonUrl, diff --git a/apps/daemon/tests/mcp-install-codex.test.ts b/apps/daemon/tests/mcp-install-codex.test.ts index 383e75f07a..f73dd17860 100644 --- a/apps/daemon/tests/mcp-install-codex.test.ts +++ b/apps/daemon/tests/mcp-install-codex.test.ts @@ -1,6 +1,11 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + createCodexCliInvocation, installCodexMcp, probeCodexInstall, setCodexRunner, @@ -34,9 +39,61 @@ function makeStubRunner(impl: (call: RecordedCall) => Promise<{ exitCode: number afterEach(() => { setCodexRunner(null); + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); +describe('codex-cli default runner', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }); + }); + + it('resolves a Windows npm .cmd shim through the shared safe invocation builder', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }); + + const invocation = createCodexCliInvocation( + ['mcp', 'get', 'open-design'], + { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }, + {}, + () => 'C:\\Users\\Amy\\AppData\\Roaming\\npm\\codex.cmd', + ); + + expect(invocation).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: [ + '/d', + '/s', + '/c', + '"C:\\Users\\Amy\\AppData\\Roaming\\npm\\codex.cmd mcp get open-design"', + ], + windowsVerbatimArguments: true, + }); + }); + + it.runIf(process.platform === 'win32')('executes a real npm-style codex.cmd found on PATH', async () => { + const shimDir = mkdtempSync(path.join(tmpdir(), 'od-codex-shim-')); + try { + writeFileSync( + path.join(shimDir, 'codex.cmd'), + '@echo off\r\nif "%~1"=="mcp" if "%~2"=="get" exit /b 0\r\nexit /b 9\r\n', + 'utf8', + ); + vi.stubEnv('PATH', shimDir); + vi.stubEnv('PATHEXT', '.CMD'); + vi.stubEnv('OD_AGENT_HOME', shimDir); + + await expect(probeCodexInstall('open-design')).resolves.toEqual({ + available: true, + installed: true, + }); + } finally { + rmSync(shimDir, { recursive: true, force: true }); + } + }); +}); + describe('codex-cli probe', () => { it('reports available:false when the codex binary is missing (ENOENT)', async () => { const runner = makeStubRunner(async () => { @@ -86,7 +143,7 @@ describe('codex-cli install', () => { name: 'open-design', command: '/path/to/node', args: ['/path/to/cli.js', 'mcp'], - env: { OD_DATA_DIR: '/tmp/od', OD_SIDECAR_IPC_PATH: '/tmp/sock' }, + env: { OD_DATA_DIR: '/tmp/od', OD_TEST_CLIENT_CAPABILITY: 'opaque' }, }); expect(runner.calls).toHaveLength(1); @@ -97,7 +154,7 @@ describe('codex-cli install', () => { '--env', 'OD_DATA_DIR=/tmp/od', '--env', - 'OD_SIDECAR_IPC_PATH=/tmp/sock', + 'OD_TEST_CLIENT_CAPABILITY=opaque', '--', '/path/to/node', '/path/to/cli.js', diff --git a/apps/daemon/tests/mcp-install-info.test.ts b/apps/daemon/tests/mcp-install-info.test.ts index 3231dae05b..bff5d53b19 100644 --- a/apps/daemon/tests/mcp-install-info.test.ts +++ b/apps/daemon/tests/mcp-install-info.test.ts @@ -23,6 +23,9 @@ interface InstallInfoOpts { * non-sidecar daemon launches and custom transport endpoints without * mutating the real process env. */ env?: NodeJS.ProcessEnv; + /** Opaque inherited sidecar client entries already selected by + * SidecarFactory. Tests keep the transport key deliberately generic. */ + sidecarEnv?: Record; /** Stand-in for the daemon's resolved RUNTIME_DATA_DIR (issue #848). * Pinned in the snippet env so IDE-spawned MCP processes write to * the same directory the daemon already uses. */ @@ -49,7 +52,7 @@ async function readInstallInfo(res: Response): Promise { return (await res.json()) as InstallInfoPayload; } -function makeInstallInfoApp({ cliPath, port, env = {}, dataDir }: InstallInfoOpts): InstallInfoApp { +function makeInstallInfoApp({ cliPath, port, env = {}, sidecarEnv = {}, dataDir }: InstallInfoOpts): InstallInfoApp { const app = express(); const TTL_MS = 5000; @@ -78,18 +81,14 @@ function makeInstallInfoApp({ cliPath, port, env = {}, dataDir }: InstallInfoOpt // Mirror the production handler's sidecar detection so this test // exercises the same path; the helper below is the same one // server.ts calls. - const sidecarIpcPath = env[SIDECAR_ENV.IPC_PATH]; - const isSidecarMode = sidecarIpcPath != null && sidecarIpcPath.length > 0; - const sidecarEnv: Record = {}; - if (isSidecarMode) { - sidecarEnv[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath; - } + const inheritedSidecarEnv = { ...sidecarEnv }; + const isSidecarMode = Object.keys(inheritedSidecarEnv).length > 0; for (const key of [ 'OD_MCP_BOOTSTRAP_COMMAND', 'OD_MCP_BOOTSTRAP_ARGS', ] as const) { const value = env[key]; - if (value != null && value.length > 0) sidecarEnv[key] = value; + if (value != null && value.length > 0) inheritedSidecarEnv[key] = value; } const webPortNum = webPort == null ? Number.NaN : Number(webPort); const webBaseUrl = Number.isFinite(webPortNum) && webPortNum > 0 @@ -105,7 +104,7 @@ function makeInstallInfoApp({ cliPath, port, env = {}, dataDir }: InstallInfoOpt dataDir, electronAsNode: env.ELECTRON_RUN_AS_NODE === '1', isSidecarMode, - sidecarEnv, + sidecarEnv: inheritedSidecarEnv, webBaseUrl, }); cache = { t: now, payload, webPort }; @@ -129,6 +128,7 @@ async function startHarness( cliPath: string, env: NodeJS.ProcessEnv, dataDir: string, + sidecarEnv: Record = {}, ): Promise { // Pick a free port first so the handler can compare against it for // isLocalSameOrigin. @@ -139,7 +139,7 @@ async function startHarness( tmp.close(() => resolveListen(p)); }); }); - const app = makeInstallInfoApp({ cliPath, port, env, dataDir }); + const app = makeInstallInfoApp({ cliPath, port, env, sidecarEnv, dataDir }); const server: http.Server = await new Promise((resolveStart) => { const handle = app.listen(port, '127.0.0.1', () => resolveStart(handle)); }); @@ -147,6 +147,7 @@ async function startHarness( } describe('GET /api/mcp/install-info', () => { + const inheritedSidecarEnv = { OD_TEST_SIDECAR_CAPABILITY: 'opaque-client-capability' }; let tmpDir: string; let cliPath: string; let dataDir: string; @@ -197,7 +198,7 @@ describe('GET /api/mcp/install-info', () => { expect(res.status).toBe(200); const body = await readInstallInfo(res); expect(body.command).toBe(process.execPath); - // Direct `od` launches have no IPC socket; the snippet bakes the + // Direct `od` launches have no inherited sidecar client; the snippet bakes the // URL so the spawned `od mcp` reaches the right port without any // discovery. expect(body.args).toEqual([cliPath, 'mcp', '--daemon-url', `http://127.0.0.1:${port}`]); @@ -264,21 +265,15 @@ describe('GET /api/mcp/install-info', () => { expect(after - before).toBeLessThanOrEqual(1); }); - it('sidecar launch omits --daemon-url and emits the concrete IPC path with OD_DATA_DIR', async () => { - const { port, server } = await startHarness( - cliPath, - { - [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/default/daemon.sock', - }, - dataDir, - ); + it('sidecar launch omits --daemon-url and forwards only the opaque client capability', async () => { + const { port, server } = await startHarness(cliPath, {}, dataDir, inheritedSidecarEnv); try { const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`); const body = await readInstallInfo(res); expect(body.args).toEqual([cliPath, 'mcp']); expect(body.env).toEqual({ OD_DATA_DIR: dataDir, - [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/default/daemon.sock', + ...inheritedSidecarEnv, }); } finally { await new Promise((done) => server?.close(() => done())); @@ -286,10 +281,8 @@ describe('GET /api/mcp/install-info', () => { }); it('returns the live packaged web URL immediately when the registered dynamic port changes', async () => { - const env: NodeJS.ProcessEnv = { - [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/live-web/daemon.sock', - }; - const { port, server } = await startHarness(cliPath, env, dataDir); + const env: NodeJS.ProcessEnv = {}; + const { port, server } = await startHarness(cliPath, env, dataDir, inheritedSidecarEnv); try { const before = await readInstallInfo( await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`), @@ -320,12 +313,11 @@ describe('GET /api/mcp/install-info', () => { const { port, server } = await startHarness( cliPath, { - [SIDECAR_ENV.IPC_PATH]: - '/tmp/open-design/ipc/default/daemon.sock', OD_MCP_BOOTSTRAP_COMMAND: '/usr/bin/open', OD_MCP_BOOTSTRAP_ARGS: bootstrapArgs, }, dataDir, + inheritedSidecarEnv, ); try { const res = await fetch( @@ -334,8 +326,7 @@ describe('GET /api/mcp/install-info', () => { const body = await readInstallInfo(res); expect(body.env).toEqual({ OD_DATA_DIR: dataDir, - [SIDECAR_ENV.IPC_PATH]: - '/tmp/open-design/ipc/default/daemon.sock', + ...inheritedSidecarEnv, OD_MCP_BOOTSTRAP_COMMAND: '/usr/bin/open', OD_MCP_BOOTSTRAP_ARGS: bootstrapArgs, }); @@ -344,13 +335,13 @@ describe('GET /api/mcp/install-info', () => { } }); - it('sidecar non-default endpoint still propagates only the concrete IPC path', async () => { + it('sidecar capability values are forwarded without interpretation', async () => { + const customCapability = { OD_TEST_SIDECAR_CAPABILITY: 'opaque-non-default-value' }; const { port, server } = await startHarness( cliPath, - { - [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/foo/daemon.sock', - }, + {}, dataDir, + customCapability, ); try { const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`); @@ -358,20 +349,19 @@ describe('GET /api/mcp/install-info', () => { expect(body.args).toEqual([cliPath, 'mcp']); expect(body.env).toEqual({ OD_DATA_DIR: dataDir, - [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/foo/daemon.sock', + ...customCapability, }); } finally { await new Promise((done) => server?.close(() => done())); } }); - it('sidecar with custom IPC base does not propagate namespace or base hints', async () => { + it('does not infer sidecar mode from unrelated legacy hints', async () => { const { port, server } = await startHarness( cliPath, { - [SIDECAR_ENV.IPC_PATH]: '/var/run/open-design/foo/daemon.sock', [SIDECAR_ENV.NAMESPACE]: 'foo', - [SIDECAR_ENV.IPC_BASE]: '/var/run/open-design', + OD_UNRELATED_RUNTIME_HINT: '/var/run/open-design', }, dataDir, ); @@ -380,8 +370,8 @@ describe('GET /api/mcp/install-info', () => { const body = await readInstallInfo(res); expect(body.env).toEqual({ OD_DATA_DIR: dataDir, - [SIDECAR_ENV.IPC_PATH]: '/var/run/open-design/foo/daemon.sock', }); + expect(body.args).toContain('--daemon-url'); } finally { await new Promise((done) => server?.close(() => done())); } diff --git a/apps/daemon/tests/runtimes/agent-runtime-env.test.ts b/apps/daemon/tests/runtimes/agent-runtime-env.test.ts index e1cd8d7d12..083ac4d5e4 100644 --- a/apps/daemon/tests/runtimes/agent-runtime-env.test.ts +++ b/apps/daemon/tests/runtimes/agent-runtime-env.test.ts @@ -2,7 +2,6 @@ import os from 'node:os'; import path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { SIDECAR_ENV } from '@open-design/sidecar-proto'; import { createAgentRuntimeEnv, @@ -51,6 +50,21 @@ describe('agent runtime tool environment', () => { }); }); + it('merges an opaque environment supplied by the runtime integration seam', () => { + const inheritedEnvironment = vi.fn(() => ({ OD_OPAQUE_CLIENT_CAPABILITY: 'capability' })); + const baseEnv = { PATH: '/bin' }; + const env = createAgentRuntimeEnv( + baseEnv, + 'http://127.0.0.1:7456', + null, + '/opt/open-design/bin/node', + inheritedEnvironment, + ); + + expect(inheritedEnvironment).toHaveBeenCalledWith(baseEnv); + expect(env.OD_OPAQUE_CLIENT_CAPABILITY).toBe('capability'); + }); + it('prepends node binary directory to PATH when not already present', () => { const env = createAgentRuntimeEnv( { PATH: '/bin' }, @@ -255,33 +269,6 @@ describe('agent runtime tool environment', () => { expect(env.no_proxy).toBeUndefined(); }); - it('passes the daemon sidecar IPC path from the explicit base env into agent wrapper sessions', () => { - const env = createAgentRuntimeEnv( - { PATH: '/bin', [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/daemon.sock' }, - 'http://127.0.0.1:7456', - null, - '/opt/open-design/bin/node', - ); - - expect(env[SIDECAR_ENV.IPC_PATH]).toBe('/tmp/open-design/ipc/daemon.sock'); - }); - - it('does not pull the daemon sidecar IPC path from ambient process state', () => { - vi.stubEnv(SIDECAR_ENV.IPC_PATH, '/tmp/open-design/ipc/stale.sock'); - try { - const env = createAgentRuntimeEnv( - { PATH: '/bin' }, - 'http://127.0.0.1:7456', - null, - '/opt/open-design/bin/node', - ); - - expect(env[SIDECAR_ENV.IPC_PATH]).toBeUndefined(); - } finally { - vi.unstubAllEnvs(); - } - }); - it('describes daemon URL and token availability without exposing the token', () => { const prompt = createAgentRuntimeToolPrompt('http://127.0.0.1:7456', { token: 'secret-run-token', diff --git a/apps/daemon/tests/sidecar-startup.test.ts b/apps/daemon/tests/sidecar-startup.test.ts index 14dfe19ed1..b713bafe56 100644 --- a/apps/daemon/tests/sidecar-startup.test.ts +++ b/apps/daemon/tests/sidecar-startup.test.ts @@ -6,18 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_MESSAGES, SIDECAR_MODES, SIDECAR_SOURCES, type DesktopRenderFramesInput, type DesktopRenderFramesResult, } from '@open-design/sidecar-proto'; -import { - createJsonIpcServer, - requestJsonIpc, - resolveAppIpcPath, -} from '@open-design/sidecar'; const stopRuntime = vi.fn(async () => undefined); const startDaemonRuntime = vi.fn(async (_options?: unknown) => ({ @@ -39,9 +33,7 @@ describe('daemon sidecar startup', () => { afterEach(async () => { const { resetDesktopAuthForTests } = await import('../src/desktop-auth.js'); - const { resetParentMonitorExitHoldForTests } = await import('../src/sidecar/parent-monitor-gate.js'); resetDesktopAuthForTests(); - resetParentMonitorExitHoldForTests(); delete process.env.OD_WEB_PORT; }); @@ -79,14 +71,32 @@ describe('daemon sidecar startup', () => { expect(stopRuntime).toHaveBeenCalled(); }); + it('passes the supervised child environment through the sidecar integration seam', async () => { + const { startDaemonSidecar } = await import('../src/sidecar/server.js'); + const inheritedEnvironment = vi.fn(() => ({ OD_OPAQUE_CLIENT_CAPABILITY: 'capability' })); + const handle = await startDaemonSidecar({ + app: APP_KEYS.DAEMON, + base: tmpdir(), + ipc: join(tmpdir(), 'daemon-environment.sock'), + mode: SIDECAR_MODES.DEV, + namespace: 'environment', + source: SIDECAR_SOURCES.TOOLS_DEV, + }, { inheritedEnvironment }); + + try { + expect(startDaemonRuntime).toHaveBeenCalledWith(expect.objectContaining({ inheritedEnvironment })); + } finally { + await handle.stop(); + } + }); + it('registers the live packaged web URL after daemon startup and replaces it on restart', async () => { const { startDaemonSidecar } = await import('../src/sidecar/server.js'); const root = await mkdtemp(join(tmpdir(), 'od-daemon-sidecar-web-url-')); - const ipc = join(root, 'daemon.sock'); const handle = await startDaemonSidecar({ app: APP_KEYS.DAEMON, base: root, - ipc, + ipc: join(root, 'daemon.sock'), mode: SIDECAR_MODES.RUNTIME, namespace: 'packaged-web-url', source: SIDECAR_SOURCES.PACKAGED, @@ -95,25 +105,11 @@ describe('daemon sidecar startup', () => { try { expect((await handle.status()).trustedWebOriginPort).toBeNull(); - await requestJsonIpc( - ipc, - { - input: { url: 'http://127.0.0.1:64248' }, - type: SIDECAR_MESSAGES.REGISTER_WEB_URL, - }, - { timeoutMs: 1_000 }, - ); + await handle.invoke(SIDECAR_MESSAGES.REGISTER_WEB_URL, { url: 'http://127.0.0.1:64248' }); expect(process.env.OD_WEB_PORT).toBe('64248'); expect((await handle.status()).trustedWebOriginPort).toBe(64248); - await requestJsonIpc( - ipc, - { - input: { url: 'http://127.0.0.1:53421' }, - type: SIDECAR_MESSAGES.REGISTER_WEB_URL, - }, - { timeoutMs: 1_000 }, - ); + await handle.invoke(SIDECAR_MESSAGES.REGISTER_WEB_URL, { url: 'http://127.0.0.1:53421' }); expect(process.env.OD_WEB_PORT).toBe('53421'); expect((await handle.status()).trustedWebOriginPort).toBe(53421); } finally { @@ -122,41 +118,25 @@ describe('daemon sidecar startup', () => { await rm(root, { recursive: true, force: true }); } }); - - it('does not send frame-render IPC to an older desktop without the advertised capability', async () => { + it('does not invoke frame rendering on an older desktop without the advertised capability', async () => { const { startDaemonSidecar } = await import('../src/sidecar/server.js'); const root = await mkdtemp(join(tmpdir(), 'od-daemon-sidecar-frame-gate-')); - const namespace = `frame-gate-${randomBytes(4).toString('hex')}`; - const desktopRequests: string[] = []; - const desktop = await createJsonIpcServer({ - socketPath: resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace, - }), - handler: async (message) => { - const request = message as { type?: string }; - desktopRequests.push(request.type ?? 'unknown'); - if (request.type === SIDECAR_MESSAGES.STATUS) { - return { - pid: process.pid, - state: 'running', - updatedAt: new Date().toISOString(), - url: null, - windowVisible: false, - }; - } - throw new Error(`unexpected desktop request: ${request.type}`); - }, - }); + const invokeDesktop = vi.fn(); + const statusDesktop = vi.fn(async () => ({ + pid: process.pid, + state: 'running' as const, + updatedAt: new Date().toISOString(), + url: null, + windowVisible: false, + })); const handle = await startDaemonSidecar({ app: APP_KEYS.DAEMON, base: root, ipc: join(root, 'daemon.sock'), mode: SIDECAR_MODES.RUNTIME, - namespace, + namespace: `frame-gate-${randomBytes(4).toString('hex')}`, source: SIDECAR_SOURCES.PACKAGED, - }); + }, { invokeDesktop, statusDesktop }); try { const runtimeOptions = startDaemonRuntime.mock.lastCall?.[0] as { @@ -174,105 +154,32 @@ describe('daemon sidecar startup', () => { errorCode: 'FRAME_RENDERER_NOT_READY', ok: false, }); - expect(desktopRequests).toEqual([SIDECAR_MESSAGES.STATUS]); + expect(statusDesktop).toHaveBeenCalledWith(5_000); + expect(invokeDesktop).not.toHaveBeenCalled(); } finally { - await handle.stop(); - await handle.waitUntilStopped(); - await desktop.close(); - await rm(root, { recursive: true, force: true }); - } - }); - - it('defers explicit SHUTDOWN exit while a handoff journal hold is active', async () => { - const { holdParentMonitorExit } = await import('../src/sidecar/parent-monitor-gate.js'); - const { startDaemonSidecar } = await import('../src/sidecar/server.js'); - const root = await mkdtemp(join(tmpdir(), 'od-daemon-sidecar-shutdown-hold-')); - const ipc = join(root, 'daemon.sock'); - const exit = vi.fn(); - const release = holdParentMonitorExit(); - const handle = await startDaemonSidecar({ - app: APP_KEYS.DAEMON, - base: root, - ipc, - mode: SIDECAR_MODES.RUNTIME, - namespace: 'packaged-shutdown-hold', - source: SIDECAR_SOURCES.PACKAGED, - }, { exit }); - - try { - const shutdown = await requestJsonIpc( - ipc, - { type: SIDECAR_MESSAGES.SHUTDOWN }, - { timeoutMs: 1_000 }, - ); - expect(shutdown).toEqual({ accepted: true, deferred: true }); - await new Promise((resolve) => setTimeout(resolve, 30)); - expect(exit).not.toHaveBeenCalled(); - expect(stopRuntime).not.toHaveBeenCalled(); - - release(); - await vi.waitFor(() => { - expect(exit).toHaveBeenCalledWith(0); - }); - expect(stopRuntime).toHaveBeenCalled(); - } finally { - release(); await handle.stop(); await handle.waitUntilStopped(); await rm(root, { recursive: true, force: true }); } }); - it('defers SIGTERM exit while a handoff journal hold is active', async () => { - const { holdParentMonitorExit } = await import('../src/sidecar/parent-monitor-gate.js'); - const { startDaemonSidecar } = await import('../src/sidecar/server.js'); - const root = await mkdtemp(join(tmpdir(), 'od-daemon-sidecar-sigterm-hold-')); - const exit = vi.fn(); + it('defers lifecycle stop while a handoff journal hold is active', async () => { + const { + holdParentMonitorExit, + waitForParentMonitorRelease, + } = await import('../src/sidecar/parent-monitor-gate.js'); const release = holdParentMonitorExit(); - const signalListeners = new Map void>>(); - const originalOn = process.on.bind(process); - const onSpy = vi.spyOn(process, 'on'); - onSpy.mockImplementation(((event: string | symbol, listener: (...args: unknown[]) => void) => { - if (event === 'SIGINT' || event === 'SIGTERM') { - const listeners = signalListeners.get(event) ?? []; - listeners.push(listener); - signalListeners.set(event, listeners); - return process; - } - return originalOn(event, listener); - }) as typeof process.on); + const stop = vi.fn(async () => undefined); + const lifecycleStop = waitForParentMonitorRelease().then(stop); try { - const handle = await startDaemonSidecar({ - app: APP_KEYS.DAEMON, - base: root, - ipc: join(root, 'daemon.sock'), - mode: SIDECAR_MODES.RUNTIME, - namespace: 'packaged-sigterm-hold', - source: SIDECAR_SOURCES.PACKAGED, - }, { exit }); - - try { - const sigterm = signalListeners.get('SIGTERM')?.[0]; - expect(sigterm).toEqual(expect.any(Function)); - sigterm?.(); - await new Promise((resolve) => setTimeout(resolve, 30)); - expect(exit).not.toHaveBeenCalled(); - expect(stopRuntime).not.toHaveBeenCalled(); - - release(); - await vi.waitFor(() => { - expect(exit).toHaveBeenCalledWith(0); - }); - expect(stopRuntime).toHaveBeenCalled(); - } finally { - release(); - await handle.stop(); - await handle.waitUntilStopped(); - } + await Promise.resolve(); + expect(stop).not.toHaveBeenCalled(); + release(); + await lifecycleStop; + expect(stop).toHaveBeenCalledOnce(); } finally { - onSpy.mockRestore(); - await rm(root, { recursive: true, force: true }); + release(); } }); }); diff --git a/apps/daemon/tests/sidecar/payload-desktop-handoff.test.ts b/apps/daemon/tests/sidecar/payload-desktop-handoff.test.ts index 6a9a5c4dd8..ff889e4e07 100644 --- a/apps/daemon/tests/sidecar/payload-desktop-handoff.test.ts +++ b/apps/daemon/tests/sidecar/payload-desktop-handoff.test.ts @@ -1,940 +1,285 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { LAUNCHER_SCHEMA_VERSION, - parseLauncherAfterQuitArgs, - parseLauncherHandoffResumeArgs, resolveLauncherPaths, resolveLauncherVersionPaths, - selectLauncherRuntimeTarget, } from "@open-design/launcher-proto"; -import { readProcessStamp } from "@open-design/platform"; -import { writeJsonFile } from "@open-design/sidecar"; -import { - APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, - SIDECAR_ENV, - SIDECAR_SOURCES, -} from "@open-design/sidecar-proto"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { SIDECAR_SOURCES } from "@open-design/sidecar-proto"; +import { describe, expect, it, vi } from "vitest"; import { executeLegacyPayloadDesktopHandoff, prepareLegacyPayloadDesktopHandoff, } from "../../src/sidecar/payload-desktop-handoff.js"; -import { - attachParentMonitor, - resetParentMonitorExitHoldForTests, - scheduleHeldDaemonExit, -} from "../../src/sidecar/parent-monitor-gate.js"; +import { isParentMonitorExitHeld } from "../../src/sidecar/parent-monitor-gate.js"; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "od-daemon-payload-handoff-")); + const namespace = "release-beta"; + const version = "1.2.3-beta.5"; + const runtimeRoot = join(root, "namespaces", namespace, "runtime"); + const launcherPaths = resolveLauncherPaths({ channel: "beta", namespace, root }); + const versionPaths = resolveLauncherVersionPaths({ channel: "beta", namespace, root, version }); + const outerBundlePath = join(root, "installed", "Open Design Beta.local.app"); + const outerExecutablePath = join(outerBundlePath, "Contents", "MacOS", "Open Design Beta"); + const payloadExecutablePath = join(versionPaths.payloadRoot, "Open Design Beta.app", "Contents", "MacOS", "Open Design Beta"); + await mkdir(join(outerExecutablePath, ".."), { recursive: true }); + await mkdir(join(payloadExecutablePath, ".."), { recursive: true }); + await mkdir(runtimeRoot, { recursive: true }); + await mkdir(launcherPaths.stateRoot, { recursive: true }); + await writeFile(outerExecutablePath, ""); + await writeFile(payloadExecutablePath, ""); + await writeFile(versionPaths.manifestPath, JSON.stringify({ + channel: "beta", + entry: { executable: "payload/Open Design Beta.app/Contents/MacOS/Open Design Beta" }, + namespace, + platform: "darwin", + schemaVersion: LAUNCHER_SCHEMA_VERSION, + version, + })); + await writeFile(launcherPaths.runtimePath, JSON.stringify({ + active: { generation: 1, version }, + channel: "beta", + lastSuccessful: { generation: 0, version: "1.2.3-beta.4" }, + namespace, + schemaVersion: LAUNCHER_SCHEMA_VERSION, + })); + await writeFile(launcherPaths.attemptsPath, JSON.stringify({ + channel: "beta", + generation: 1, + namespace, + schemaVersion: LAUNCHER_SCHEMA_VERSION, + version, + })); + await writeFile(launcherPaths.installPath, JSON.stringify({ + channel: "beta", + launchPath: outerBundlePath, + namespace, + schemaVersion: LAUNCHER_SCHEMA_VERSION, + })); + return { launcherPaths, namespace, outerExecutablePath, payloadExecutablePath, root, runtimeRoot, version }; +} describe("legacy payload desktop handoff", () => { - afterEach(() => { - resetParentMonitorExitHoldForTests(); - }); - - it("captures the real previous pointer before old outer confirm, then arms and launches payload desktop", async () => { - const root = await mkdtemp(join(tmpdir(), "od-daemon-payload-handoff-")); + it("uses the explicit outer owner identity rather than inferring it from process ancestry", async () => { + const value = await fixture(); try { - const namespace = "release-beta"; - const runtimeRoot = join(root, "namespaces", namespace, "runtime"); - const launcherPaths = resolveLauncherPaths({ channel: "beta", namespace, root }); - const versionPaths = resolveLauncherVersionPaths({ - channel: "beta", - namespace, - root, - version: "1.2.3-beta.5", - }); - const outerExecutablePath = join(root, "installed", "Open Design Beta.app", "Contents", "MacOS", "Open Design Beta"); - const payloadExecutablePath = join( - versionPaths.payloadRoot, - "Open Design Beta.app", - "Contents", - "MacOS", - "Open Design Beta", - ); - await mkdir(join(payloadExecutablePath, ".."), { recursive: true }); - await mkdir(join(outerExecutablePath, ".."), { recursive: true }); - await mkdir(runtimeRoot, { recursive: true }); - await mkdir(launcherPaths.stateRoot, { recursive: true }); - await writeFile(payloadExecutablePath, ""); - await writeFile(outerExecutablePath, ""); - await writeFile(versionPaths.manifestPath, `${JSON.stringify({ - channel: "beta", - entry: { - cwd: "payload/Open Design Beta.app", - executable: "payload/Open Design Beta.app/Contents/MacOS/Open Design Beta", - }, - namespace, - payloadRoot: "payload", - platform: "darwin", - schemaVersion: LAUNCHER_SCHEMA_VERSION, - version: "1.2.3-beta.5", - })}\n`); - await writeFile(launcherPaths.runtimePath, `${JSON.stringify({ - active: { generation: 1, version: "1.2.3-beta.5" }, - channel: "beta", - lastSuccessful: { generation: 0, version: "1.2.3-beta.4" }, - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - })}\n`); - await writeFile(launcherPaths.attemptsPath, `${JSON.stringify({ - channel: "beta", - generation: 1, - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - version: "1.2.3-beta.5", - })}\n`); - await writeFile(launcherPaths.installPath, `${JSON.stringify({ - channel: "beta", - launchPath: join(root, "installed", "Open Design Beta.app"), - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - })}\n`); - const prepared = await prepareLegacyPayloadDesktopHandoff({ - env: { - OD_APP_VERSION: "1.2.3-beta.5", - OD_INSTALLATION_DIR: root, - }, - namespace, - parentPid: 4321, + dataRoot: join(value.root, "data"), + env: { OD_APP_VERSION: value.version, OD_INSTALLATION_DIR: value.root }, + namespace: value.namespace, + outerPid: 4321, platform: "darwin", randomId: () => "f5d4a712-8ba9-4c28-bcad-6dbed5db2d7c", - runtimeRoot, + requestDesktopStatus: async () => ({ + executablePath: value.outerExecutablePath, + pid: 4321, + state: "running", + }), + runtimeRoot: value.runtimeRoot, source: SIDECAR_SOURCES.PACKAGED, }); - - expect(prepared.kind).toBe("prepared"); - expect(JSON.parse(await readFile(launcherPaths.handoffPath, "utf8"))).toMatchObject({ - previous: { generation: 0, version: "1.2.3-beta.4" }, - source: { generation: 1, version: "1.2.3-beta.5" }, - state: "prepared", - }); - - await writeFile(join(runtimeRoot, "desktop-root.json"), `${JSON.stringify({ - executablePath: outerExecutablePath, - pid: 4321, - stamp: { - app: APP_KEYS.DESKTOP, - ipc: "/tmp/open-design/ipc/release-beta/desktop.sock", - mode: "runtime", - namespace, - source: SIDECAR_SOURCES.PACKAGED, - }, - version: 1, - })}\n`); - - await writeFile(launcherPaths.runtimePath, `${JSON.stringify({ - active: { generation: 1, version: "1.2.3-beta.5" }, - channel: "beta", - lastSuccessful: { generation: 1, version: "1.2.3-beta.5" }, - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - })}\n`); - await rm(launcherPaths.attemptsPath, { force: true }); - - const child = { - once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "spawn") queueMicrotask(callback); - return child; - }), - unref: vi.fn(), - }; - let launchedArgs: string[] = []; - let launchedEnv: NodeJS.ProcessEnv | undefined; - const spawn = vi.fn((_command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) => { - launchedArgs = args; - launchedEnv = options.env; - return child; - }); - const requestDesktop = vi.fn(async (message: "shutdown" | "status") => ( - message === "status" - ? { pid: 4321, state: "running" } - : { accepted: true } - )); + expect(prepared).toMatchObject({ kind: "prepared", descriptor: { state: "prepared" } }); if (prepared.kind !== "prepared") throw new Error("expected prepared handoff"); - const result = await executeLegacyPayloadDesktopHandoff(prepared, { + expect(prepared.descriptor.outer.pid).toBe(4321); + expect(prepared.descriptor.outer.pid).not.toBe(process.ppid); + + await writeFile(value.launcherPaths.runtimePath, JSON.stringify({ + active: { generation: 1, version: value.version }, + channel: "beta", + lastSuccessful: { generation: 1, version: value.version }, + namespace: value.namespace, + schemaVersion: LAUNCHER_SCHEMA_VERSION, + })); + await rm(value.launcherPaths.attemptsPath, { force: true }); + + const lifecycleHoldObservations: boolean[] = []; + const spawn = vi.fn(async () => { + lifecycleHoldObservations.push(isParentMonitorExitHeld()); + return { + process: { pid: 7654 }, + stamp: {} as never, + stop: vi.fn(), + }; + }); + const requestDesktop = vi.fn(async (message: "shutdown" | "status") => + message === "status" + ? { executablePath: value.outerExecutablePath, pid: 4321, state: "running" } + : (lifecycleHoldObservations.push(isParentMonitorExitHeld()), { accepted: true })); + await expect(executeLegacyPayloadDesktopHandoff(prepared, { confirmTimeoutMs: 100, - env: { - ELECTRON_RUN_AS_NODE: "1", - OD_SIDECAR_BASE: runtimeRoot, - [SIDECAR_ENV.TOOLS_DEV_PARENT_PID]: "4321", - PATH: "/usr/bin", - }, + spawn: spawn as never, now: () => new Date("2026-07-15T02:00:00.000Z"), requestDesktop, sleep: async () => undefined, - spawn: spawn as never, - }); - - expect(result).toMatchObject({ - kind: "scheduled", - target: { generation: 2, version: "1.2.3-beta.5" }, - }); - expect(JSON.parse(await readFile(launcherPaths.runtimePath, "utf8"))).toMatchObject({ - active: { generation: 2, version: "1.2.3-beta.5" }, - lastSuccessful: { generation: 0, version: "1.2.3-beta.4" }, - }); - expect(JSON.parse(await readFile(launcherPaths.attemptsPath, "utf8"))).toMatchObject({ - generation: 2, - version: "1.2.3-beta.5", - }); - expect(JSON.parse(await readFile(launcherPaths.handoffPath, "utf8"))).toMatchObject({ - state: "armed", - target: { generation: 2, version: "1.2.3-beta.5" }, - }); - expect(spawn).toHaveBeenCalledOnce(); - expect(launchedEnv).not.toHaveProperty("ELECTRON_RUN_AS_NODE"); - expect(launchedEnv).not.toHaveProperty("OD_SIDECAR_BASE"); - expect(launchedEnv).not.toHaveProperty(SIDECAR_ENV.TOOLS_DEV_PARENT_PID); - expect(launchedEnv).toMatchObject({ - OD_PACKAGED_NAMESPACE_BASE_ROOT: join(root, "namespaces"), - PATH: "/usr/bin", - }); - expect(parseLauncherAfterQuitArgs(launchedArgs)).toEqual({ targetPid: 4321, timeoutMs: 60_000 }); - expect(parseLauncherHandoffResumeArgs(launchedArgs)).toEqual({ - handoffId: "f5d4a712-8ba9-4c28-bcad-6dbed5db2d7c", - }); - expect(readProcessStamp(launchedArgs, OPEN_DESIGN_SIDECAR_CONTRACT)).toMatchObject({ - app: APP_KEYS.DESKTOP, - namespace, - source: SIDECAR_SOURCES.PACKAGED, - }); - expect(requestDesktop).toHaveBeenLastCalledWith("shutdown"); - - await writeFile(launcherPaths.runtimePath, `${JSON.stringify({ - active: { generation: 2, version: "1.2.3-beta.5" }, - channel: "beta", - lastSuccessful: { generation: 2, version: "1.2.3-beta.5" }, - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - })}\n`); - await rm(launcherPaths.attemptsPath, { force: true }); - await writeFile(launcherPaths.handoffPath, `${JSON.stringify({ - ...JSON.parse(await readFile(launcherPaths.handoffPath, "utf8")), - source: { generation: 2, version: "1.2.3-beta.5" }, - state: "confirmed", - target: { generation: 2, version: "1.2.3-beta.5" }, - })}\n`); - await writeFile(join(runtimeRoot, "desktop-root.json"), `${JSON.stringify({ - executablePath: outerExecutablePath, - pid: 4321, - stamp: { - app: APP_KEYS.DESKTOP, - ipc: "/tmp/open-design/ipc/release-beta/desktop.sock", - mode: "runtime", - namespace, - source: SIDECAR_SOURCES.TOOLS_PACK, + writeJsonFile: async (filePath, payload) => { + lifecycleHoldObservations.push(isParentMonitorExitHeld()); + await writeFile(filePath, `${JSON.stringify(payload)}\n`, "utf8"); }, - version: 1, - })}\n`); - - const coldStart = await prepareLegacyPayloadDesktopHandoff({ - env: { - OD_APP_VERSION: "1.2.3-beta.5", - OD_INSTALLATION_DIR: root, - }, - namespace, - now: () => new Date("2026-07-15T03:00:00.000Z"), - parentPid: 4321, - platform: "darwin", - randomId: () => "e7e48cc4-7334-4d99-ab8e-830b2360dff0", - runtimeRoot, - source: SIDECAR_SOURCES.TOOLS_PACK, - }); - - expect(coldStart).toMatchObject({ - descriptor: { - handoffId: "e7e48cc4-7334-4d99-ab8e-830b2360dff0", - previous: { generation: 0, version: "1.2.3-beta.4" }, - source: { generation: 2, version: "1.2.3-beta.5" }, - state: "prepared", - }, - kind: "prepared", - }); - if (coldStart.kind !== "prepared") throw new Error("expected cold-start handoff"); - expect(coldStart.descriptor).not.toHaveProperty("target"); - spawn.mockClear(); - await expect(executeLegacyPayloadDesktopHandoff(coldStart, { - confirmTimeoutMs: 100, - env: { PATH: "/usr/bin" }, - now: () => new Date("2026-07-15T03:00:01.000Z"), - requestDesktop, - sleep: async () => undefined, - spawn: spawn as never, })).resolves.toMatchObject({ kind: "scheduled", - target: { generation: 3, version: "1.2.3-beta.5" }, - }); - expect(spawn).toHaveBeenCalledOnce(); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - async function armedHandoffFixture(): Promise<{ - launcherPaths: ReturnType; - prepared: Extract< - Awaited>, - { kind: "prepared" } - >; - root: string; - }> { - const root = await mkdtemp(join(tmpdir(), "od-daemon-payload-handoff-fail-")); - const namespace = "release-beta"; - const runtimeRoot = join(root, "namespaces", namespace, "runtime"); - const launcherPaths = resolveLauncherPaths({ channel: "beta", namespace, root }); - const versionPaths = resolveLauncherVersionPaths({ - channel: "beta", - namespace, - root, - version: "1.2.3-beta.5", - }); - const outerExecutablePath = join(root, "installed", "Open Design Beta.app", "Contents", "MacOS", "Open Design Beta"); - const payloadExecutablePath = join( - versionPaths.payloadRoot, - "Open Design Beta.app", - "Contents", - "MacOS", - "Open Design Beta", - ); - await mkdir(join(payloadExecutablePath, ".."), { recursive: true }); - await mkdir(join(outerExecutablePath, ".."), { recursive: true }); - await mkdir(runtimeRoot, { recursive: true }); - await mkdir(launcherPaths.stateRoot, { recursive: true }); - await writeFile(payloadExecutablePath, ""); - await writeFile(outerExecutablePath, ""); - await writeFile(versionPaths.manifestPath, `${JSON.stringify({ - channel: "beta", - entry: { - cwd: "payload/Open Design Beta.app", - executable: "payload/Open Design Beta.app/Contents/MacOS/Open Design Beta", - }, - namespace, - payloadRoot: "payload", - platform: "darwin", - schemaVersion: LAUNCHER_SCHEMA_VERSION, - version: "1.2.3-beta.5", - })}\n`); - await writeFile(launcherPaths.runtimePath, `${JSON.stringify({ - active: { generation: 1, version: "1.2.3-beta.5" }, - channel: "beta", - lastSuccessful: { generation: 0, version: "1.2.3-beta.4" }, - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - })}\n`); - await writeFile(launcherPaths.attemptsPath, `${JSON.stringify({ - channel: "beta", - generation: 1, - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - version: "1.2.3-beta.5", - })}\n`); - await writeFile(launcherPaths.installPath, `${JSON.stringify({ - channel: "beta", - launchPath: join(root, "installed", "Open Design Beta.app"), - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - })}\n`); - - const prepared = await prepareLegacyPayloadDesktopHandoff({ - env: { - OD_APP_VERSION: "1.2.3-beta.5", - OD_INSTALLATION_DIR: root, - }, - namespace, - parentPid: 4321, - platform: "darwin", - randomId: () => "f5d4a712-8ba9-4c28-bcad-6dbed5db2d7c", - runtimeRoot, - source: SIDECAR_SOURCES.PACKAGED, - }); - if (prepared.kind !== "prepared") throw new Error("expected prepared handoff"); - - // Simulate the old outer confirming: the launcher promoted lastSuccessful to - // the payload source and cleared the pending attempt. - await writeFile(join(runtimeRoot, "desktop-root.json"), `${JSON.stringify({ - executablePath: outerExecutablePath, - pid: 4321, - stamp: { - app: APP_KEYS.DESKTOP, - ipc: "/tmp/open-design/ipc/release-beta/desktop.sock", - mode: "runtime", - namespace, - source: SIDECAR_SOURCES.PACKAGED, - }, - version: 1, - })}\n`); - await writeFile(launcherPaths.runtimePath, `${JSON.stringify({ - active: { generation: 1, version: "1.2.3-beta.5" }, - channel: "beta", - lastSuccessful: { generation: 1, version: "1.2.3-beta.5" }, - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - })}\n`); - await rm(launcherPaths.attemptsPath, { force: true }); - - return { launcherPaths, prepared, root }; - } - - it("does not strand launcher state when the payload desktop spawn fails", async () => { - const { launcherPaths, prepared, root } = await armedHandoffFixture(); - try { - const spawn = vi.fn(() => { - throw new Error("spawn ENOENT"); - }); - const requestDesktop = vi.fn(async (message: "shutdown" | "status") => ( - message === "status" - ? { pid: 4321, state: "running" } - : { accepted: true } - )); - - const result = await executeLegacyPayloadDesktopHandoff(prepared, { - confirmTimeoutMs: 100, - env: { PATH: "/usr/bin" }, - now: () => new Date("2026-07-15T02:00:00.000Z"), - requestDesktop, - sleep: async () => undefined, - spawn: spawn as never, + target: { generation: 2, version: value.version }, }); - expect(result).toEqual({ kind: "aborted", reason: "spawn-failed" }); - // The shutdown must not have been requested, and no armed journal / runtime - // rewrite may remain on disk to block the next cold-start retry. - expect(requestDesktop).not.toHaveBeenCalledWith("shutdown"); - expect(JSON.parse(await readFile(launcherPaths.handoffPath, "utf8"))).toMatchObject({ - state: "prepared", - }); - expect(JSON.parse(await readFile(launcherPaths.handoffPath, "utf8"))).not.toHaveProperty("target"); - expect(JSON.parse(await readFile(launcherPaths.runtimePath, "utf8"))).toMatchObject({ - active: { generation: 1, version: "1.2.3-beta.5" }, - }); - await expect(readFile(launcherPaths.attemptsPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - it("releases the parent-monitor hold when payload desktop spawn fails", async () => { - const { launcherPaths, prepared, root } = await armedHandoffFixture(); - let disposeMonitor = () => {}; - try { - let parentAlive = true; - let daemonExited = false; - disposeMonitor = attachParentMonitor(async () => undefined, { - env: { [SIDECAR_ENV.TOOLS_DEV_PARENT_PID]: "4321" }, - exit: () => { - daemonExited = true; + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + command: value.payloadExecutablePath, + resources: { + dataRoot: join(value.root, "data"), + ownerPid: null, + port: 0, + runtimeRoot: value.runtimeRoot, }, - intervalMs: 10, - isProcessAlive: () => parentAlive, - }); - const spawn = vi.fn(() => { - parentAlive = false; - throw new Error("spawn ENOENT"); - }); - const requestDesktop = vi.fn(async (message: "shutdown" | "status") => ( - message === "status" - ? { pid: 4321, state: "running" } - : { accepted: true } - )); - - const result = await executeLegacyPayloadDesktopHandoff(prepared, { - confirmTimeoutMs: 100, - env: { PATH: "/usr/bin" }, - now: () => new Date("2026-07-15T02:00:00.000Z"), - requestDesktop, - sleep: async () => undefined, - spawn: spawn as never, - }); - - expect(result).toEqual({ kind: "aborted", reason: "spawn-failed" }); - expect(JSON.parse(await readFile(launcherPaths.handoffPath, "utf8"))).toMatchObject({ - state: "prepared", - }); - await vi.waitFor(() => { - expect(daemonExited).toBe(true); + stamp: { + app: "desktop", + channel: "beta", + mode: "runtime", + namespace: value.namespace, + source: "packaged", + }, + })); + expect(requestDesktop).toHaveBeenLastCalledWith("shutdown"); + expect(lifecycleHoldObservations).toEqual([true, true, true, true, true]); + expect(isParentMonitorExitHeld()).toBe(false); + expect(JSON.parse(await readFile(value.launcherPaths.handoffPath, "utf8"))).toMatchObject({ + state: "armed", + target: { generation: 2, version: value.version }, }); } finally { - disposeMonitor(); - await rm(root, { force: true, recursive: true }); + await rm(value.root, { force: true, recursive: true }); } }); - it("does not strand launcher state when the old desktop shutdown fails", async () => { - const { launcherPaths, prepared, root } = await armedHandoffFixture(); + it("retires the launched payload generation when the outer refuses shutdown", async () => { + const value = await fixture(); try { - const child = { - once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "spawn") queueMicrotask(callback); - return child; - }), - unref: vi.fn(), - }; - const spawn = vi.fn(() => child); - const requestDesktop = vi.fn(async (message: "shutdown" | "status") => { - if (message === "shutdown") throw new Error("desktop ipc gone"); - return { pid: 4321, state: "running" }; - }); - - const result = await executeLegacyPayloadDesktopHandoff(prepared, { - confirmTimeoutMs: 100, - env: { PATH: "/usr/bin" }, - now: () => new Date("2026-07-15T02:00:00.000Z"), - requestDesktop, - sleep: async () => undefined, - spawn: spawn as never, - }); - - expect(result).toEqual({ kind: "aborted", reason: "shutdown-failed" }); - expect(spawn).toHaveBeenCalledOnce(); - // Even though the payload child already spawned, the failed shutdown must - // leave the journal at "prepared" so a later cold start can retry instead - // of bailing on "already-armed". - expect(JSON.parse(await readFile(launcherPaths.handoffPath, "utf8"))).toMatchObject({ - state: "prepared", - }); - expect(JSON.parse(await readFile(launcherPaths.handoffPath, "utf8"))).not.toHaveProperty("target"); - expect(JSON.parse(await readFile(launcherPaths.runtimePath, "utf8"))).toMatchObject({ - active: { generation: 1, version: "1.2.3-beta.5" }, - }); - await expect(readFile(launcherPaths.attemptsPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); - - // A subsequent cold start resumes the prepared handoff rather than reporting - // "already-armed". - const retry = await prepareLegacyPayloadDesktopHandoff({ - env: { - OD_APP_VERSION: "1.2.3-beta.5", - OD_INSTALLATION_DIR: root, - }, - namespace: prepared.descriptor.namespace, - parentPid: 4321, + const prepared = await prepareLegacyPayloadDesktopHandoff({ + dataRoot: join(value.root, "data"), + env: { OD_APP_VERSION: value.version, OD_INSTALLATION_DIR: value.root }, + namespace: value.namespace, + outerPid: 4321, platform: "darwin", - runtimeRoot: prepared.runtimeRoot, + requestDesktopStatus: async () => ({ + executablePath: value.outerExecutablePath, + pid: 4321, + state: "running", + }), + runtimeRoot: value.runtimeRoot, source: SIDECAR_SOURCES.PACKAGED, }); - expect(retry.kind).toBe("prepared"); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); + if (prepared.kind !== "prepared") throw new Error("expected prepared handoff"); + await writeFile(value.launcherPaths.runtimePath, JSON.stringify({ + active: { generation: 1, version: value.version }, + channel: "beta", + lastSuccessful: { generation: 1, version: value.version }, + namespace: value.namespace, + schemaVersion: LAUNCHER_SCHEMA_VERSION, + })); + await rm(value.launcherPaths.attemptsPath, { force: true }); - it("commits the armed journal when the outer parent dies at shutdown acknowledgement", async () => { - const { launcherPaths, prepared, root } = await armedHandoffFixture(); - let disposeMonitor = () => {}; - try { - let parentAlive = true; - let daemonExited = false; - disposeMonitor = attachParentMonitor(async () => undefined, { - env: { [SIDECAR_ENV.TOOLS_DEV_PARENT_PID]: "4321" }, - exit: () => { - daemonExited = true; + const stop = vi.fn(async () => ({ remainingPids: [] })); + await expect(executeLegacyPayloadDesktopHandoff(prepared, { + confirmTimeoutMs: 100, + requestDesktop: async (message) => { + if (message === "shutdown") throw new Error("outer refused shutdown"); + return { executablePath: value.outerExecutablePath, pid: 4321, state: "running" }; }, - intervalMs: 10, - isProcessAlive: () => parentAlive, - }); - - const child = { - once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "spawn") queueMicrotask(callback); - return child; - }), - unref: vi.fn(), - }; - const spawn = vi.fn(() => child); - const persist = vi.fn(async (filePath: string, payload: unknown) => { - expect(daemonExited, "parent monitor exited before journal commit").toBe(false); - await writeJsonFile(filePath, payload); - }); - const requestDesktop = vi.fn(async (message: "shutdown" | "status") => { - if (message === "status") return { pid: 4321, state: "running" }; - parentAlive = false; - await new Promise((resolve) => setTimeout(resolve, 35)); - return { accepted: true }; - }); - - const result = await executeLegacyPayloadDesktopHandoff(prepared, { - confirmTimeoutMs: 100, - env: { PATH: "/usr/bin" }, - now: () => new Date("2026-07-15T02:00:00.000Z"), - requestDesktop, sleep: async () => undefined, - spawn: spawn as never, - writeJsonFile: persist, - }); - - expect(result).toMatchObject({ - kind: "scheduled", - target: { generation: 2, version: "1.2.3-beta.5" }, - }); - expect(daemonExited).toBe(false); - expect(persist).toHaveBeenCalledTimes(3); - - const handoff = JSON.parse(await readFile(launcherPaths.handoffPath, "utf8")); - const attempt = JSON.parse(await readFile(launcherPaths.attemptsPath, "utf8")); - const runtime = JSON.parse(await readFile(launcherPaths.runtimePath, "utf8")); - expect(handoff).toMatchObject({ - state: "armed", - target: { generation: 2, version: "1.2.3-beta.5" }, - }); - expect(selectLauncherRuntimeTarget({ - attempted: attempt, - resume: handoff.target, - runtime, - })).toEqual({ - pointer: { generation: 2, version: "1.2.3-beta.5" }, - reason: "active-resume", - selected: true, - }); - - await new Promise((resolve) => setTimeout(resolve, 35)); - expect(daemonExited).toBe(true); + spawn: (async () => ({ process: { pid: 7654 }, stamp: {} as never, stop })) as never, + })).resolves.toEqual({ kind: "aborted", reason: "shutdown-failed" }); + expect(stop).toHaveBeenCalledWith({ termGraceMs: 0 }); + expect(JSON.parse(await readFile(value.launcherPaths.handoffPath, "utf8"))).toMatchObject({ state: "prepared" }); } finally { - disposeMonitor(); - await rm(root, { force: true, recursive: true }); + await rm(value.root, { force: true, recursive: true }); } }); - it("commits the armed journal when the outer parent dies after payload spawn", async () => { - const { launcherPaths, prepared, root } = await armedHandoffFixture(); - let disposeMonitor = () => {}; + it("does not prepare when the exact desktop endpoint reports the payload executable", async () => { + const value = await fixture(); try { - let parentAlive = true; - let daemonExited = false; - disposeMonitor = attachParentMonitor(async () => undefined, { - env: { [SIDECAR_ENV.TOOLS_DEV_PARENT_PID]: "4321" }, - exit: () => { - daemonExited = true; - }, - intervalMs: 10, - isProcessAlive: () => parentAlive, - }); - - const child = { - once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "spawn") { - parentAlive = false; - queueMicrotask(callback); - } - return child; + await expect(prepareLegacyPayloadDesktopHandoff({ + dataRoot: join(value.root, "data"), + env: { OD_APP_VERSION: value.version, OD_INSTALLATION_DIR: value.root }, + namespace: value.namespace, + outerPid: 4321, + platform: "darwin", + requestDesktopStatus: async () => ({ + executablePath: value.payloadExecutablePath, + pid: 4321, + state: "running", }), - unref: vi.fn(), - }; - const spawn = vi.fn(() => child); - const persist = vi.fn(async (filePath: string, payload: unknown) => { - expect(daemonExited, "parent monitor exited before journal commit").toBe(false); - await writeJsonFile(filePath, payload); - }); - const requestDesktop = vi.fn(async (message: "shutdown" | "status") => { - if (message === "status") return { pid: 4321, state: "running" }; - await new Promise((resolve) => setTimeout(resolve, 35)); - return { accepted: true }; - }); - - const result = await executeLegacyPayloadDesktopHandoff(prepared, { - confirmTimeoutMs: 100, - env: { PATH: "/usr/bin" }, - now: () => new Date("2026-07-15T02:00:00.000Z"), - requestDesktop, - sleep: async () => undefined, - spawn: spawn as never, - writeJsonFile: persist, - }); - - expect(result).toMatchObject({ - kind: "scheduled", - target: { generation: 2, version: "1.2.3-beta.5" }, - }); - expect(daemonExited).toBe(false); - expect(persist).toHaveBeenCalledTimes(3); - - const handoff = JSON.parse(await readFile(launcherPaths.handoffPath, "utf8")); - const attempt = JSON.parse(await readFile(launcherPaths.attemptsPath, "utf8")); - const runtime = JSON.parse(await readFile(launcherPaths.runtimePath, "utf8")); - expect(handoff).toMatchObject({ - state: "armed", - target: { generation: 2, version: "1.2.3-beta.5" }, - }); - expect(selectLauncherRuntimeTarget({ - attempted: attempt, - resume: handoff.target, - runtime, - })).toEqual({ - pointer: { generation: 2, version: "1.2.3-beta.5" }, - reason: "active-resume", - selected: true, - }); - - await new Promise((resolve) => setTimeout(resolve, 35)); - expect(daemonExited).toBe(true); + runtimeRoot: value.runtimeRoot, + source: SIDECAR_SOURCES.PACKAGED, + })).resolves.toEqual({ kind: "none", reason: "payload-desktop-active" }); } finally { - disposeMonitor(); - await rm(root, { force: true, recursive: true }); + await rm(value.root, { force: true, recursive: true }); } }); - it("commits the armed journal before packaged sidecar SHUTDOWN can exit the daemon", async () => { - const { launcherPaths, prepared, root } = await armedHandoffFixture(); + it("recognizes the installed outer through a symlinked launcher root", async () => { + const value = await fixture(); try { - let daemonExited = false; - const child = { - once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "spawn") queueMicrotask(callback); - return child; + const aliasRoot = join(value.root, "launcher-root-alias"); + await symlink(value.root, aliasRoot, "dir"); + await writeFile(value.launcherPaths.installPath, JSON.stringify({ + channel: "beta", + launchPath: join(aliasRoot, "installed", "Open Design Beta.local.app"), + namespace: value.namespace, + schemaVersion: LAUNCHER_SCHEMA_VERSION, + })); + + await expect(prepareLegacyPayloadDesktopHandoff({ + dataRoot: join(value.root, "data"), + env: { OD_APP_VERSION: value.version, OD_INSTALLATION_DIR: aliasRoot }, + namespace: value.namespace, + outerPid: 4321, + platform: "darwin", + requestDesktopStatus: async () => ({ + executablePath: value.outerExecutablePath, + pid: 4321, + state: "running", }), - unref: vi.fn(), - }; - const spawn = vi.fn(() => child); - const persist = vi.fn(async (filePath: string, payload: unknown) => { - expect(daemonExited, "daemon SHUTDOWN exited before journal commit").toBe(false); - await new Promise((resolve) => setTimeout(resolve, 20)); - await writeJsonFile(filePath, payload); - }); - const requestDesktop = vi.fn(async (message: "shutdown" | "status") => { - if (message === "status") return { pid: 4321, state: "running" }; - // Packaged desktop acks SHUTDOWN, then beforeShutdown -> sidecars.close() - // sends daemon SHUTDOWN. Use the same deferred-exit helper the sidecar - // SHUTDOWN handler uses so delayed journal writes still finish first. - scheduleHeldDaemonExit(async () => undefined, () => { - daemonExited = true; - }); - return { accepted: true }; - }); - - const result = await executeLegacyPayloadDesktopHandoff(prepared, { - confirmTimeoutMs: 100, - env: { PATH: "/usr/bin" }, - now: () => new Date("2026-07-15T02:00:00.000Z"), - requestDesktop, - sleep: async () => undefined, - spawn: spawn as never, - writeJsonFile: persist, - }); - - expect(result).toMatchObject({ - kind: "scheduled", - target: { generation: 2, version: "1.2.3-beta.5" }, - }); - expect(persist).toHaveBeenCalledTimes(3); - expect(persist.mock.calls.map(([filePath]) => filePath)).toEqual([ - launcherPaths.handoffPath, - launcherPaths.attemptsPath, - launcherPaths.runtimePath, - ]); - - const handoff = JSON.parse(await readFile(launcherPaths.handoffPath, "utf8")); - const attempt = JSON.parse(await readFile(launcherPaths.attemptsPath, "utf8")); - const runtime = JSON.parse(await readFile(launcherPaths.runtimePath, "utf8")); - expect(handoff).toMatchObject({ - state: "armed", - target: { generation: 2, version: "1.2.3-beta.5" }, - }); - expect(selectLauncherRuntimeTarget({ - attempted: attempt, - resume: handoff.target, - runtime, - })).toEqual({ - pointer: { generation: 2, version: "1.2.3-beta.5" }, - reason: "active-resume", - selected: true, - }); - - await vi.waitFor(() => { - expect(daemonExited).toBe(true); - }); + runtimeRoot: value.runtimeRoot, + source: SIDECAR_SOURCES.PACKAGED, + })).resolves.toMatchObject({ kind: "prepared", descriptor: { state: "prepared" } }); } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - it("commits the armed journal before a second held SIGTERM exit runs", async () => { - const { launcherPaths, prepared, root } = await armedHandoffFixture(); - try { - let daemonExited = false; - const child = { - once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "spawn") queueMicrotask(callback); - return child; - }), - unref: vi.fn(), - }; - const spawn = vi.fn(() => child); - const persist = vi.fn(async (filePath: string, payload: unknown) => { - expect(daemonExited, "daemon SIGTERM exited before journal commit").toBe(false); - // closeManagedChild waits 5s then stopProcesses() sends SIGTERM. Keep - // each write in flight across that escalation without a real 5s sleep. - await new Promise((resolve) => setTimeout(resolve, 20)); - await writeJsonFile(filePath, payload); - }); - const requestDesktop = vi.fn(async (message: "shutdown" | "status") => { - if (message === "status") return { pid: 4321, state: "running" }; - const exit = () => { - daemonExited = true; - }; - scheduleHeldDaemonExit(async () => undefined, exit); - // Model the 5s force-stop: SIGTERM uses the same hold-aware helper as - // SHUTDOWN, so a write still in flight must finish first. - scheduleHeldDaemonExit(async () => undefined, exit); - return { accepted: true }; - }); - - const result = await executeLegacyPayloadDesktopHandoff(prepared, { - confirmTimeoutMs: 100, - env: { PATH: "/usr/bin" }, - now: () => new Date("2026-07-15T02:00:00.000Z"), - requestDesktop, - sleep: async () => undefined, - spawn: spawn as never, - writeJsonFile: persist, - }); - - expect(result).toMatchObject({ - kind: "scheduled", - target: { generation: 2, version: "1.2.3-beta.5" }, - }); - expect(persist).toHaveBeenCalledTimes(3); - expect(persist.mock.calls.map(([filePath]) => filePath)).toEqual([ - launcherPaths.handoffPath, - launcherPaths.attemptsPath, - launcherPaths.runtimePath, - ]); - - const handoff = JSON.parse(await readFile(launcherPaths.handoffPath, "utf8")); - const attempt = JSON.parse(await readFile(launcherPaths.attemptsPath, "utf8")); - const runtime = JSON.parse(await readFile(launcherPaths.runtimePath, "utf8")); - expect(handoff).toMatchObject({ - state: "armed", - target: { generation: 2, version: "1.2.3-beta.5" }, - }); - expect(selectLauncherRuntimeTarget({ - attempted: attempt, - resume: handoff.target, - runtime, - })).toEqual({ - pointer: { generation: 2, version: "1.2.3-beta.5" }, - reason: "active-resume", - selected: true, - }); - - await vi.waitFor(() => { - expect(daemonExited).toBe(true); - }); - } finally { - await rm(root, { force: true, recursive: true }); + await rm(value.root, { force: true, recursive: true }); } }); it("does nothing outside the packaged desktop runtime", async () => { await expect(prepareLegacyPayloadDesktopHandoff({ + dataRoot: "/tmp/open-design/data", env: {}, namespace: "default", + outerPid: null, platform: "darwin", runtimeRoot: "/tmp/open-design/runtime", source: SIDECAR_SOURCES.TOOLS_DEV, })).resolves.toEqual({ kind: "none", reason: "not-packaged" }); }); - it("resolves the installed outer and payload executable for the real Windows beta namespace", async () => { - const root = await mkdtemp(join(tmpdir(), "od-daemon-win-payload-handoff-")); - try { - const namespace = "release-beta-win"; - const version = "1.2.3-beta.5"; - const runtimeRoot = join(root, "namespaces", namespace, "runtime"); - const launcherPaths = resolveLauncherPaths({ channel: "beta", namespace, root }); - const versionPaths = resolveLauncherVersionPaths({ - channel: "beta", - namespace, - root, - version, - }); - const outerExecutablePath = join(root, "installed", "Open Design Beta.exe"); - const payloadExecutablePath = join(versionPaths.payloadRoot, "Open Design Beta.exe"); - await mkdir(join(root, "installed"), { recursive: true }); - await mkdir(versionPaths.payloadRoot, { recursive: true }); - await mkdir(runtimeRoot, { recursive: true }); - await mkdir(launcherPaths.stateRoot, { recursive: true }); - await writeFile(outerExecutablePath, ""); - await writeFile(payloadExecutablePath, ""); - await writeFile(versionPaths.manifestPath, `${JSON.stringify({ - channel: "beta", - entry: { - cwd: "payload", - executable: "payload/Open Design Beta.exe", - }, - namespace, - payloadRoot: "payload", - platform: "win32", - schemaVersion: LAUNCHER_SCHEMA_VERSION, - version, - })}\n`); - await writeFile(launcherPaths.runtimePath, `${JSON.stringify({ - active: { generation: 1, version }, - channel: "beta", - lastSuccessful: { generation: 0, version: "1.2.3-beta.4" }, - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - })}\n`); - await writeFile(launcherPaths.attemptsPath, `${JSON.stringify({ - channel: "beta", - generation: 1, - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - version, - })}\n`); - await writeFile(launcherPaths.installPath, `${JSON.stringify({ - channel: "beta", - launchPath: outerExecutablePath, - namespace, - schemaVersion: LAUNCHER_SCHEMA_VERSION, - })}\n`); - - const prepared = await prepareLegacyPayloadDesktopHandoff({ - env: { - OD_APP_VERSION: version, - OD_INSTALLATION_DIR: root, - }, - namespace, - parentPid: 9876, - platform: "win32", - randomId: () => "4c5ca585-c7a1-4b9a-b725-495d72a5f97b", - runtimeRoot, - source: SIDECAR_SOURCES.PACKAGED, - }); - - expect(prepared).toMatchObject({ - descriptor: { - outer: { executablePath: outerExecutablePath, pid: 9876 }, - payloadExecutablePath, - previous: { generation: 0, version: "1.2.3-beta.4" }, - source: { generation: 1, version }, - state: "prepared", - }, - kind: "prepared", - }); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - it("uses the stable channel for an unlabelled version and custom namespace", async () => { - const root = await mkdtemp(join(tmpdir(), "od-daemon-stable-handoff-")); + it("quick-fails packaged handoff when the outer owner identity is absent", async () => { + const value = await fixture(); try { await expect(prepareLegacyPayloadDesktopHandoff({ - env: { - OD_APP_VERSION: "1.2.3", - OD_INSTALLATION_DIR: root, - }, - namespace: "custom-stable-namespace", - parentPid: 4321, + dataRoot: join(value.root, "data"), + env: { OD_APP_VERSION: value.version, OD_INSTALLATION_DIR: value.root }, + namespace: value.namespace, + outerPid: null, platform: "darwin", - runtimeRoot: join(root, "runtime"), + requestDesktopStatus: async () => ({ + executablePath: value.outerExecutablePath, + pid: process.ppid, + state: "running", + }), + runtimeRoot: value.runtimeRoot, source: SIDECAR_SOURCES.PACKAGED, - })).resolves.toEqual({ kind: "none", reason: "invalid-runtime" }); + })).resolves.toEqual({ kind: "none", reason: "invalid-install-anchor" }); } finally { - await rm(root, { force: true, recursive: true }); + await rm(value.root, { force: true, recursive: true }); } }); }); diff --git a/apps/daemon/tests/telemetry-sidecar-namespace.test.ts b/apps/daemon/tests/telemetry-sidecar-namespace.test.ts new file mode 100644 index 0000000000..92a53de80d --- /dev/null +++ b/apps/daemon/tests/telemetry-sidecar-namespace.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveInstallerObservationNamespace } from '../src/routes/telemetry.js'; + +describe('installer observation namespace', () => { + it('uses the daemon runtime namespace instead of ambient sidecar env', () => { + const previous = process.env.OD_SIDECAR_NAMESPACE; + process.env.OD_SIDECAR_NAMESPACE = 'stale-env-namespace'; + try { + expect(resolveInstallerObservationNamespace('release-beta')).toBe('release-beta'); + expect(resolveInstallerObservationNamespace(undefined)).toBe('default'); + } finally { + if (previous == null) delete process.env.OD_SIDECAR_NAMESPACE; + else process.env.OD_SIDECAR_NAMESPACE = previous; + } + }); +}); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index bdd8f3e022..4686ac78c4 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -7,9 +7,10 @@ import { BrowserWindow, Menu, app, dialog, globalShortcut, shell, type MenuItemC import { APP_KEYS, OPEN_DESIGN_SIDECAR_CONTRACT, - SIDECAR_ENV, SIDECAR_MESSAGES, SIDECAR_MODES, + isSidecarMode, + isSidecarSource, normalizeDesktopSidecarMessage, type DesktopClickInput, type DesktopEvalInput, @@ -23,22 +24,18 @@ import { type DaemonStatusSnapshot, type DesktopUpdateInput, type RegisterDesktopAuthResult, - type SidecarStamp, + type LegacySidecarRuntimeLayout, type WebStatusSnapshot, } from "@open-design/sidecar-proto"; import { dirname, join } from "node:path"; import { - bootstrapSidecarRuntime, - createJsonIpcServer, - requestJsonIpc, - resolveAppIpcPath, resolveLogFilePath, resolveRuntimeNamespaceRoot, - type JsonIpcServerHandle, + SidecarFactory, + type SidecarClient, type SidecarRuntimeContext, } from "@open-design/sidecar"; -import { readProcessStamp } from "@open-design/platform"; import { createDesktopRuntime, type DesktopRuntime } from "./runtime.js"; import { dispatchInviteDeeplink, registerInviteDeeplink } from "./invite-deeplink.js"; @@ -105,7 +102,6 @@ export { type PickAndImportFolderResult, } from "./runtime.js"; -const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID; const AMR_PROFILE_ENV_KEY = "OPEN_DESIGN_AMR_PROFILE"; const AMR_PROFILE_AGENT_ID = "amr"; const AMR_ENVIRONMENT_PROFILES = ["prod", "test", "feature-test", "local"] as const; @@ -168,7 +164,7 @@ export function applyLoopbackConnectionLimitSwitch(electronApp: Electron.App): v export type DesktopMainOptions = { beforeShutdown?: () => Promise; onExternalShow?: () => void | Promise; - discoverWebUrl?: () => Promise; + discoverWebUrl: () => Promise; /** * Round-7 (lefarcen P2 @ runtime.ts:336): packaged builds report the * renderer URL (`od://app/`) over `discoverWebUrl`, but Node-side @@ -178,7 +174,8 @@ export type DesktopMainOptions = { * omit it because their web URL IS already an http://127.0.0.1 URL * Node fetch can hit. */ - discoverDaemonUrl?: () => Promise; + discoverDaemonUrl: () => Promise; + registerDesktopAuth: (secret: Buffer) => Promise; /** Stable installed launcher used for Windows opendesign:// registration. */ inviteProtocolClientPath?: string | null; preloadPath?: string; @@ -208,6 +205,12 @@ export type DesktopMainOptions = { }; }; +export type DesktopMainHandle = { + invoke(action: string, input: unknown): Promise; + status(): Promise; + stop(): Promise; +}; + function isDirectEntry(): boolean { const entryPath = process.argv[1]; if (entryPath == null || entryPath.length === 0 || entryPath.startsWith("--")) return false; @@ -219,55 +222,6 @@ function isDirectEntry(): boolean { } } -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function attachParentMonitor(stop: () => Promise): void { - const parentPid = Number(process.env[TOOLS_DEV_PARENT_PID_ENV]); - if (!Number.isInteger(parentPid) || parentPid <= 0) return; - - const timer = setInterval(() => { - if (isProcessAlive(parentPid)) return; - clearInterval(timer); - void stop().finally(() => process.exit(0)); - }, 1000); - timer.unref(); -} - -function createWebDiscovery(runtime: SidecarRuntimeContext): () => Promise { - return async () => { - const webIpc = resolveAppIpcPath({ - app: APP_KEYS.WEB, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: runtime.namespace, - }); - const web = await requestJsonIpc(webIpc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 600 }).catch(() => null); - return web?.url ?? null; - }; -} - -function createDaemonDiscovery(runtime: SidecarRuntimeContext): () => Promise { - return async () => { - const daemonIpc = resolveAppIpcPath({ - app: APP_KEYS.DAEMON, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: runtime.namespace, - }); - const daemon = await requestJsonIpc( - daemonIpc, - { type: SIDECAR_MESSAGES.STATUS }, - { timeoutMs: 600 }, - ).catch(() => null); - return daemon?.url ?? null; - }; -} - type BaseUrlDiscovery = () => Promise; export async function resolveFirstAvailableBaseUrl( @@ -289,14 +243,11 @@ export async function resolveFirstAvailableBaseUrl( // the direct daemon before the web `/api/*` proxy so a compiling web renderer // cannot make native app-config actions temporarily unavailable. function resolveDaemonBaseUrl( - runtime: SidecarRuntimeContext, options: Pick, ): () => Promise { return () => resolveFirstAvailableBaseUrl([ - ...(options.discoverDaemonUrl ? [options.discoverDaemonUrl] : []), - createDaemonDiscovery(runtime), - ...(options.discoverWebUrl ? [options.discoverWebUrl] : []), - createWebDiscovery(runtime), + options.discoverDaemonUrl, + options.discoverWebUrl, ]); } @@ -361,7 +312,7 @@ export function createAmrEnvironmentProfileMenuItems( ]; } -export function resolveAboutPanelVersion(options: DesktopMainOptions): string | null { +export function resolveAboutPanelVersion(options: Pick): string | null { const version = options.update?.currentVersion?.trim(); return version == null || version.length === 0 ? null : version; } @@ -413,7 +364,7 @@ type DesktopMenuController = { }; function installDesktopMenu( - runtime: SidecarRuntimeContext, + runtime: SidecarRuntimeContext, options: Pick & { onOpenUpdateDialog?: () => void; updater: DesktopUpdater; @@ -430,7 +381,7 @@ function installDesktopMenu( dialog.showErrorBox(message, detail); }; - const discoverAppConfigBaseUrl = resolveDaemonBaseUrl(runtime, options); + const discoverAppConfigBaseUrl = resolveDaemonBaseUrl(options); const readCurrentAmrProfile = async (): Promise => { const baseUrl = await discoverAppConfigBaseUrl(); @@ -649,7 +600,6 @@ function installDesktopMenu( }; } -const REGISTER_DESKTOP_AUTH_RETRY_DELAYS_MS = [120, 240, 480, 960, 1500]; const REGISTER_DESKTOP_AUTH_TIMEOUT_MS = 800; function summarizeDesktopIpcInput(input: unknown): Record | null { @@ -686,44 +636,10 @@ function summarizeDesktopIpcInput(input: unknown): Record | nul * (because no secret is in scope), instead of opening a renderer- * bypassable path. We log the failure so the operator can investigate. */ -async function registerDesktopAuthWithDaemon( - runtime: SidecarRuntimeContext, - secret: Buffer, -): Promise { - const daemonIpc = resolveAppIpcPath({ - app: APP_KEYS.DAEMON, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: runtime.namespace, - }); - const message = { - input: { secret: secret.toString("base64") }, - type: SIDECAR_MESSAGES.REGISTER_DESKTOP_AUTH, - }; - const delays = REGISTER_DESKTOP_AUTH_RETRY_DELAYS_MS; - for (let attempt = 0; attempt <= delays.length; attempt += 1) { - try { - const result = await requestJsonIpc( - daemonIpc, - message, - { timeoutMs: REGISTER_DESKTOP_AUTH_TIMEOUT_MS }, - ); - if (result?.accepted === true) return true; - } catch { - // Daemon not yet listening on the IPC socket, or message rejected. - // Fall through to the retry sleep below. - } - if (attempt >= delays.length) break; - await new Promise((resolveDelay) => { - setTimeout(resolveDelay, delays[attempt]); - }); - } - return false; -} - export async function runDesktopMain( - runtime: SidecarRuntimeContext, - options: DesktopMainOptions = {}, -): Promise { + runtime: SidecarRuntimeContext, + options: DesktopMainOptions, +): Promise { // Install the defensive uncaughtException filter BEFORE awaiting // app.whenReady, so a setTypeOfService EINVAL thrown by undici during // the renderer's first fetch is intercepted rather than surfacing as @@ -764,7 +680,7 @@ export async function runDesktopMain( // once with a fresh token. A persistent failure surfaces in the // renderer toast rather than silently dropping forever. const desktopAuthSecret = randomBytes(32); - const registered = await registerDesktopAuthWithDaemon(runtime, desktopAuthSecret); + const registered = await options.registerDesktopAuth(desktopAuthSecret); if (!registered) { console.warn( "[open-design desktop] initial import-token handshake with daemon did not complete; " + @@ -831,7 +747,6 @@ export async function runDesktopMain( let disposeMenu: () => void = () => undefined; let updateScheduler: DesktopUpdaterScheduler | null = null; let removeDiagnosticsIpc: () => void = () => undefined; - let ipcServer: JsonIpcServerHandle | null = null; let shuttingDown = false; let pendingUpdateDialogRequest = false; @@ -865,6 +780,7 @@ export async function runDesktopMain( const update = await snapshotUpdateForStatus(); if (activeDesktop == null) { return { + executablePath: process.execPath, capabilities: { frameRenderer: true }, pid: process.pid, state: "idle", @@ -875,6 +791,7 @@ export async function runDesktopMain( }; } return { + executablePath: process.execPath, ...activeDesktop.status(), capabilities: { frameRenderer: true }, ...update, @@ -890,7 +807,6 @@ export async function runDesktopMain( updateScheduler?.stop("shutdown"); disposeMenu(); removeDiagnosticsIpc(); - await ipcServer?.close().catch(() => undefined); await desktop?.close().catch(() => undefined); // Mark the session clean only AFTER teardown actually completed, right // before app.quit(). Doing it at the start of shutdown would flag a quit as @@ -904,24 +820,21 @@ export async function runDesktopMain( void shutdown().finally(() => process.exit(0)); } - console.info("[open-design desktop] starting desktop IPC server", { ipc: runtime.ipc }); - ipcServer = await createJsonIpcServer({ - socketPath: runtime.ipc, - handler: async (message: unknown) => { - const request = normalizeDesktopSidecarMessage(message); + async function invoke(action: string, messageInput: unknown): Promise { + const request = normalizeDesktopSidecarMessage({ + ...(messageInput === undefined ? {} : { input: messageInput }), + type: action, + }); const startedAt = Date.now(); const input = "input" in request ? summarizeDesktopIpcInput(request.input) : null; - console.info("[open-design desktop] desktop IPC request start", { input, type: request.type }); + console.info("[open-design desktop] sidecar action start", { input, type: request.type }); try { const activeDesktop = desktop; switch (request.type) { case SIDECAR_MESSAGES.STATUS: return await desktopStatusSnapshot(activeDesktop); case SIDECAR_MESSAGES.SHUTDOWN: - setImmediate(() => { - shutdownAndExit(); - }); - return { accepted: true }; + throw new Error("sidecar lifecycle messages are private"); } if (activeDesktop == null) { throw new Error("desktop runtime is not initialized"); @@ -952,21 +865,19 @@ export async function runDesktopMain( return await updater.handle((request.input as DesktopUpdateInput).action); } } catch (error) { - console.error("[open-design desktop] desktop IPC request failed", { + console.error("[open-design desktop] sidecar action failed", { durationMs: Date.now() - startedAt, error: error instanceof Error ? error.message : String(error), type: request.type, }); throw error; } finally { - console.info("[open-design desktop] desktop IPC request end", { + console.info("[open-design desktop] sidecar action end", { durationMs: Date.now() - startedAt, type: request.type, }); } - }, - }); - console.info("[open-design desktop] desktop IPC server listening", { ipc: runtime.ipc }); + } const menuController = installDesktopMenu(runtime, { ...options, @@ -984,7 +895,7 @@ export async function runDesktopMain( console.info("[open-design desktop] creating desktop runtime"); desktop = await createDesktopRuntime({ desktopAuthSecret, - discoverUrl: options.discoverWebUrl ?? createWebDiscovery(runtime), + discoverUrl: options.discoverWebUrl, discoverDaemonUrl: options.discoverDaemonUrl, osLocale, preloadPath: options.preloadPath, @@ -993,7 +904,7 @@ export async function runDesktopMain( // (after a daemon restart, or after a missed startup window). The // runtime then mints a FRESH token (new nonce + new exp — replay // protection still works) and POSTs once more. - registerDesktopAuthWithDaemon: () => registerDesktopAuthWithDaemon(runtime, desktopAuthSecret), + registerDesktopAuthWithDaemon: () => options.registerDesktopAuth(desktopAuthSecret), rendererLogPath, // Mark "reached running" only when the window is ACTUALLY revealed (web app // mounted + shown), not when createDesktopRuntime returns — it starts async @@ -1020,7 +931,7 @@ export async function runDesktopMain( }, }); - const discoverDaemonBaseUrl = resolveDaemonBaseUrl(runtime, options); + const discoverDaemonBaseUrl = resolveDaemonBaseUrl(options); // Report each abnormal exit of a prior run now that the daemon is up to relay // it (best-effort; the events carry no user content). Each is dropped from the // queue only once the daemon acks it, so a failed report is retried next launch. @@ -1044,18 +955,18 @@ export async function runDesktopMain( (event, properties) => reportDesktopObservabilityEvent(discoverDaemonBaseUrl, event, properties), ); removeDiagnosticsIpc = registerDesktopDiagnosticsIpc({ - discoverDaemonBaseUrl: resolveDaemonBaseUrl(runtime, options), + discoverDaemonBaseUrl: resolveDaemonBaseUrl(options), }); // Route opendesign:// team-invite deeplinks to the daemon (desktop wake-up). registerInviteDeeplink({ - resolveDaemonBaseUrl: resolveDaemonBaseUrl(runtime, options), + resolveDaemonBaseUrl: resolveDaemonBaseUrl(options), focus: () => focusDesktopForDeeplink(desktop), onCompleted: (outcome) => { console.info("[open-design desktop] invite deeplink continuation completed", outcome); }, protocolClientPath: options.inviteProtocolClientPath, }); - const discoverUpdaterAppConfigBaseUrl = resolveDaemonBaseUrl(runtime, options); + const discoverUpdaterAppConfigBaseUrl = resolveDaemonBaseUrl(options); updateScheduler = createDesktopUpdaterScheduler(updater, { backoffInitialMs: updater.config.checkBackoffInitialMs, backoffMaxMs: updater.config.checkBackoffMaxMs, @@ -1072,20 +983,12 @@ export async function runDesktopMain( }); if (updater.shouldAutoCheck()) updateScheduler.start(); - attachParentMonitor(shutdown); - app.on("before-quit", (event) => { if (shuttingDown) return; event.preventDefault(); void shutdown().finally(() => process.exit(0)); }); - app.on("before-quit", (event) => { - if (shuttingDown) return; - event.preventDefault(); - shutdownAndExit(); - }); - app.on("window-all-closed", () => { shutdownAndExit(); }); @@ -1094,23 +997,87 @@ export async function runDesktopMain( desktop?.show(); }); - for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.on(signal, () => { - shutdownAndExit(); - }); - } + return { + invoke, + status: () => desktopStatusSnapshot(desktop), + stop: shutdown, + }; } if (isDirectEntry()) { - const stamp = readProcessStamp(process.argv.slice(2), OPEN_DESIGN_SIDECAR_CONTRACT); - if (stamp == null) throw new Error("sidecar stamp is required"); - - const runtime = bootstrapSidecarRuntime(stamp, process.env, { - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, + let runtimeHandle: DesktopMainHandle | null = null; + const invoke = async (action: string, input: unknown) => { + if (runtimeHandle == null) throw new Error("desktop sidecar is not running"); + return await runtimeHandle.invoke(action, input); + }; + let client!: SidecarClient; + client = SidecarFactory.create({ + handlers: Object.fromEntries([ + SIDECAR_MESSAGES.CLICK, + SIDECAR_MESSAGES.CONSOLE, + SIDECAR_MESSAGES.EVAL, + SIDECAR_MESSAGES.EXPORT_ARTIFACT, + SIDECAR_MESSAGES.EXPORT_PDF, + SIDECAR_MESSAGES.RENDER_FRAMES, + SIDECAR_MESSAGES.RENDER_SLIDES, + SIDECAR_MESSAGES.SCREENSHOT, + SIDECAR_MESSAGES.SHOW, + SIDECAR_MESSAGES.UPDATE, + ].map((action) => [action, (input: unknown) => invoke(action, input)])), + lifecycle: { + async start(resources) { + if (client.stamp.app !== APP_KEYS.DESKTOP) throw new Error(`desktop sidecar cannot run stamp app ${client.stamp.app}`); + if (!isSidecarMode(client.stamp.mode)) throw new Error(`unsupported desktop sidecar mode: ${client.stamp.mode}`); + if (!isSidecarSource(client.stamp.source)) throw new Error(`unsupported desktop sidecar source: ${client.stamp.source}`); + const runtime: SidecarRuntimeContext = { + app: APP_KEYS.DESKTOP, + base: resources.runtimeRoot, + mode: client.stamp.mode, + namespace: client.stamp.namespace, + source: client.stamp.source, + }; + const started = await runDesktopMain(runtime, { + discoverDaemonUrl: async () => { + try { + return (await client.status(APP_KEYS.DAEMON, { timeoutMs: 600 })).url ?? null; + } catch { + return null; + } + }, + discoverWebUrl: async () => { + try { + return (await client.status(APP_KEYS.WEB, { timeoutMs: 600 })).url ?? null; + } catch { + return null; + } + }, + registerDesktopAuth: async (secret) => { + try { + const result = await client.invoke( + APP_KEYS.DAEMON, + SIDECAR_MESSAGES.REGISTER_DESKTOP_AUTH, + { secret: secret.toString("base64") }, + { timeoutMs: REGISTER_DESKTOP_AUTH_TIMEOUT_MS }, + ); + return result.accepted === true; + } catch { + return false; + } + }, + }); + runtimeHandle = started; + return started; + }, + async status(runtime) { + return await runtime.status(); + }, + async stop(runtime) { + await runtime.stop(); + runtimeHandle = null; + }, + }, }); - - void runDesktopMain(runtime).catch((error: unknown) => { + void client.start().then(() => client.waitUntilStopped()).catch((error: unknown) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); }); diff --git a/apps/desktop/src/main/updater/deferred-launch.ts b/apps/desktop/src/main/updater/deferred-launch.ts index d537b53a13..c6892552b9 100644 --- a/apps/desktop/src/main/updater/deferred-launch.ts +++ b/apps/desktop/src/main/updater/deferred-launch.ts @@ -6,6 +6,7 @@ import { buildLauncherAfterQuitArgs, buildLauncherDelegatedArgs, } from "@open-design/launcher-proto"; +import { SidecarFactory } from "@open-design/sidecar"; import { HELPERS_DIR, ensureOwnedSubdir } from "./store.js"; @@ -23,7 +24,7 @@ export type DetachedProcess = Pick, "once" | "unref">; export type SpawnInstallerHelper = ( command: string, args: string[], - options: { cwd?: string; detached?: true; stdio: "ignore"; windowsHide: true }, + options: { cwd?: string; detached?: true; env?: NodeJS.ProcessEnv; stdio: "ignore"; windowsHide: true }, ) => DetachedProcess; export type DeferredInstallerLaunchInput = { @@ -276,7 +277,13 @@ export async function launchPayloadAppAfterQuit( ...buildLauncherAfterQuitArgs({ targetPid: input.appPid, timeoutMs: input.timeoutMs }), ...(input.delegated == null ? [] : buildLauncherDelegatedArgs(input.delegated)), ], - { cwd: input.cwd, detached: true, stdio: "ignore", windowsHide: true }, + { + cwd: input.cwd, + detached: true, + env: SidecarFactory.newGenerationEnvironment(), + stdio: "ignore", + windowsHide: true, + }, ); await new Promise((resolveSpawn, rejectSpawn) => { child.once("spawn", () => resolveSpawn()); diff --git a/apps/desktop/tests/main/updater-host-boundary.test.ts b/apps/desktop/tests/main/updater-host-boundary.test.ts index a3c298d841..98a522bc11 100644 --- a/apps/desktop/tests/main/updater-host-boundary.test.ts +++ b/apps/desktop/tests/main/updater-host-boundary.test.ts @@ -8,7 +8,7 @@ const here = dirname(fileURLToPath(import.meta.url)); const desktopRoot = join(here, "../.."); function source(relativePath: string): string { - return readFileSync(join(desktopRoot, relativePath), "utf8"); + return readFileSync(join(desktopRoot, relativePath), "utf8").replace(/\r\n?/g, "\n"); } describe("desktop updater host boundary", () => { @@ -38,7 +38,7 @@ describe("desktop updater host boundary", () => { it("does not turn automatic startup checks into native desktop dialogs", () => { const main = source("src/main/index.ts"); const scheduleStart = main.indexOf("updateScheduler = createDesktopUpdaterScheduler"); - const nextSection = main.indexOf("attachParentMonitor", scheduleStart); + const nextSection = main.indexOf('app.on("before-quit"', scheduleStart); expect(scheduleStart).toBeGreaterThanOrEqual(0); expect(nextSection).toBeGreaterThan(scheduleStart); const scheduleBody = main.slice(scheduleStart, nextSection); @@ -46,16 +46,35 @@ describe("desktop updater host boundary", () => { expect(scheduleBody).not.toContain("showUpdateResultDialog"); }); - it("starts desktop IPC before creating the BrowserWindow runtime", () => { + it("starts the normalized sidecar client after creating the BrowserWindow runtime", () => { const main = source("src/main/index.ts"); - const ipcStart = main.indexOf("ipcServer = await createJsonIpcServer"); const runtimeStart = main.indexOf("desktop = await createDesktopRuntime"); - expect(ipcStart).toBeGreaterThanOrEqual(0); - expect(runtimeStart).toBeGreaterThan(ipcStart); - const startupIpcBody = main.slice(ipcStart, runtimeStart); + const clientStart = main.indexOf("void client.start()", runtimeStart); + expect(runtimeStart).toBeGreaterThanOrEqual(0); + expect(clientStart).toBeGreaterThan(runtimeStart); expect(main).toContain('state: "idle"'); - expect(startupIpcBody).toContain("desktopStatusSnapshot(activeDesktop)"); - expect(startupIpcBody).toContain("desktop runtime is not initialized"); + expect(main).toContain("SidecarFactory.create"); + expect(main).not.toContain("createJsonIpcServer"); + }); + + it("registers frame rendering on the normalized sidecar client", () => { + const main = source("src/main/index.ts"); + const handlersStart = main.indexOf("handlers: Object.fromEntries(["); + const lifecycleStart = main.indexOf("lifecycle:", handlersStart); + expect(handlersStart).toBeGreaterThanOrEqual(0); + expect(lifecycleStart).toBeGreaterThan(handlersStart); + expect(main.slice(handlersStart, lifecycleStart)).toContain("SIDECAR_MESSAGES.RENDER_FRAMES"); + }); + + it("keeps direct sidecar discovery and auth registration soft-failing", () => { + const main = source("src/main/index.ts"); + const wiringStart = main.indexOf("discoverDaemonUrl: async () => {"); + const wiringEnd = main.indexOf("const started = await runDesktopMain", wiringStart + 1); + expect(wiringStart).toBeGreaterThanOrEqual(0); + const wiring = main.slice(wiringStart, wiringEnd > wiringStart ? wiringEnd : undefined); + expect(wiring).toContain("catch {\n return null;"); + expect(wiring.match(/return null;/g)).toHaveLength(2); + expect(wiring).toContain("catch {\n return false;"); }); it("keeps obsolete installed-outer policy outside generic desktop while exposing the SHOW hook", () => { diff --git a/apps/desktop/tests/main/updater.test.ts b/apps/desktop/tests/main/updater.test.ts index eca46dcea5..3dc0c34204 100644 --- a/apps/desktop/tests/main/updater.test.ts +++ b/apps/desktop/tests/main/updater.test.ts @@ -2275,7 +2275,13 @@ describe("desktop updater", () => { expect(spawned).toHaveLength(1); expect(unref).toHaveBeenCalledTimes(1); expect(spawned[0]?.command).toBe(payloadLaunchPath); - expect(spawned[0]?.options).toEqual({ cwd: runtimeBase, detached: true, stdio: "ignore", windowsHide: true }); + expect(spawned[0]?.options).toEqual(expect.objectContaining({ + cwd: runtimeBase, + detached: true, + env: expect.any(Object), + stdio: "ignore", + windowsHide: true, + })); const args = spawned[0]?.args ?? []; expect(args).toEqual(expect.arrayContaining([ LAUNCHER_AFTER_QUIT_FLAG, diff --git a/apps/packaged/src/headless-runtime.ts b/apps/packaged/src/headless-runtime.ts index 8ab0539a16..19634e5277 100644 --- a/apps/packaged/src/headless-runtime.ts +++ b/apps/packaged/src/headless-runtime.ts @@ -2,37 +2,26 @@ import { mkdir } from "node:fs/promises"; import { APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, - SIDECAR_MESSAGES, - SIDECAR_MODES, SIDECAR_SOURCES, - normalizeDesktopSidecarMessage, - type SidecarStamp, } from "@open-design/sidecar-proto"; -import { bootstrapSidecarRuntime, createJsonIpcServer, resolveAppIpcPath } from "@open-design/sidecar"; -import type { JsonIpcServerHandle } from "@open-design/sidecar"; +import { + getSidecarStatus, + registerSidecarProcess, + readCurrentSidecarStamp, + SidecarFactory, + type SidecarClient, + type SidecarRuntimeContext, + type SidecarStamp, +} from "@open-design/sidecar"; +import { releaseChannelFromNamespace, releaseChannelFromVersion } from "@open-design/release"; import type { PackagedConfig } from "./config.js"; -import type { PackagedDesktopIdentityHandle } from "./identity.js"; -import { writePackagedDesktopIdentity, writePackagedWebIdentity } from "./identity.js"; import { confirmPackagedLauncherRuntime, resolvePackagedLauncherRuntime } from "./launcher-runtime.js"; import { resolvePackagedNamespacePaths } from "./paths.js"; import type { PackagedSidecarHandle } from "./sidecars.js"; import { startPackagedSidecars } from "./sidecars.js"; -function createHeadlessStamp(namespace: string): SidecarStamp { - return { - app: APP_KEYS.DESKTOP, - ipc: resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace, - }), - mode: SIDECAR_MODES.RUNTIME, - namespace, - source: SIDECAR_SOURCES.PACKAGED, - }; -} +const PACKAGED_SIDECAR_SOURCES = [SIDECAR_SOURCES.TOOLS_PACK, SIDECAR_SOURCES.PACKAGED] as const; function colorize(text: string): string { if (process.stdout.isTTY !== true || process.env.NO_COLOR != null) return text; @@ -53,17 +42,32 @@ export interface RunPackagedHeadlessOptions { mcpBootstrapLaunch?: PackagedMcpBootstrapLaunch; } +export async function runPackagedMcpActionAgainstExistingDaemon( + request: PackagedHeadlessRequest, + stamp: SidecarStamp, + dependencies: { + getStatus?: typeof getSidecarStatus; + installMcp?: (daemonUrl: string | null) => Promise; + } = {}, +): Promise { + if (request.mcpInstallAgent == null) return false; + let status: { state?: unknown; url?: unknown } | null = null; + for (const source of [stamp.source, ...PACKAGED_SIDECAR_SOURCES.filter((candidate) => candidate !== stamp.source)]) { + status = await (dependencies.getStatus ?? getSidecarStatus)<{ state?: unknown; url?: unknown }>( + { ...stamp, app: APP_KEYS.DAEMON, mode: stamp.mode, source }, + { timeoutMs: 350 }, + ).catch(() => null); + if (status?.state === "running" && typeof status.url === "string" && status.url.length > 0) break; + } + if (status?.state !== "running" || typeof status.url !== "string" || status.url.length === 0) return false; + await (dependencies.installMcp ?? installCodexMcp)(status.url); + return true; +} + export interface PackagedHeadlessStartupDependencies { confirmRuntime(): Promise; - createIpcServer(options: { - shutdown(): Promise; - webUrl: string; - }): Promise; - exit(code: number): void; installMcp(daemonUrl: string | null): Promise; startSidecars(): Promise; - writeIdentity(): Promise; - writeWebIdentity(webUrl: string): Promise; } export interface PackagedHeadlessStartupHandle { @@ -74,25 +78,19 @@ export interface PackagedHeadlessStartupHandle { export async function acquirePackagedHeadlessStartup( dependencies: PackagedHeadlessStartupDependencies, ): Promise { - let identity: PackagedDesktopIdentityHandle | null = null; let sidecars: PackagedSidecarHandle | null = null; - let ipcServer: JsonIpcServerHandle | null = null; let closed = false; const close = async (): Promise => { if (closed) return; closed = true; - await ipcServer?.close().catch(() => undefined); await sidecars?.close().catch(() => undefined); - await identity?.close().catch(() => undefined); }; const shutdown = async (): Promise => { await close(); - dependencies.exit(0); }; try { - identity = await dependencies.writeIdentity(); sidecars = await dependencies.startSidecars(); const webUrl = sidecars.web.url; if (!webUrl) { @@ -101,8 +99,6 @@ export async function acquirePackagedHeadlessStartup( ); } await dependencies.installMcp(sidecars.daemon.url); - ipcServer = await dependencies.createIpcServer({ shutdown, webUrl }); - await dependencies.writeWebIdentity(webUrl); await dependencies.confirmRuntime(); return { shutdown, webUrl }; } catch (error) { @@ -174,7 +170,18 @@ export async function runPackagedHeadless( const launcherRuntime = await resolvePackagedLauncherRuntime(config, initialPaths); const activeConfig = launcherRuntime.config; const paths = launcherRuntime.paths; - const stamp = createHeadlessStamp(config.namespace); + const argvStamp = (() => { + try { return readCurrentSidecarStamp(); } catch { return null; } + })(); + const stamp = { + app: APP_KEYS.DESKTOP, + channel: argvStamp?.channel ?? releaseChannelFromVersion(activeConfig.appVersion) + ?? releaseChannelFromNamespace(config.namespace, "default") + ?? "stable", + mode: "headless", + namespace: config.namespace, + source: argvStamp?.source ?? SIDECAR_SOURCES.PACKAGED, + }; const mcpBootstrap = options.mcpBootstrapLaunch ?? resolvePackagedMcpBootstrapLaunch({ @@ -182,38 +189,27 @@ export async function runPackagedHeadless( }); await mkdir(paths.runtimeRoot, { recursive: true }); - - const runtime = bootstrapSidecarRuntime(stamp, process.env, { + registerSidecarProcess(stamp, { + dataRoot: paths.dataRoot, + ownerPid: null, + port: 0, + runtimeRoot: paths.runtimeRoot, + }); + const runtime: SidecarRuntimeContext = { app: APP_KEYS.DESKTOP, base: paths.runtimeRoot, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - }); + mode: "headless", + namespace: config.namespace, + source: stamp.source, + }; - const { shutdown, webUrl } = await acquirePackagedHeadlessStartup({ + type HeadlessRuntime = PackagedHeadlessStartupHandle; + let client!: SidecarClient; + client = SidecarFactory.create({ + lifecycle: { + async start() { + return await acquirePackagedHeadlessStartup({ confirmRuntime: async () => await confirmPackagedLauncherRuntime(launcherRuntime), - createIpcServer: async ({ shutdown: stop, webUrl: activeWebUrl }) => - await createJsonIpcServer({ - socketPath: stamp.ipc, - handler: async (message: unknown) => { - const normalized = normalizeDesktopSidecarMessage(message); - switch (normalized.type) { - case SIDECAR_MESSAGES.STATUS: - return { - pid: process.pid, - state: "running", - updatedAt: new Date().toISOString(), - url: activeWebUrl, - windowVisible: false, - }; - case SIDECAR_MESSAGES.SHUTDOWN: - setImmediate(() => { - void stop(); - }); - return { accepted: true }; - } - }, - }), - exit: (code) => process.exit(code), installMcp: async (daemonUrl) => { if (request.mcpInstallAgent === "codex") { await installCodexMcp(daemonUrl); @@ -247,35 +243,27 @@ export async function runPackagedHeadless( webStandaloneRoot: activeConfig.webStandaloneRoot, webOutputMode: activeConfig.webOutputMode, }), - // Write a headless-specific identity marker so `tools-pack linux stop - // --headless` can find this process without confusing it for a - // menu-launched AppImage that owns desktop-root.json in the same namespace. - writeIdentity: async () => - await writePackagedDesktopIdentity({ - identityPath: paths.headlessIdentityPath, - paths, - stamp, - }), - writeWebIdentity: async (activeWebUrl) => - await writePackagedWebIdentity({ - paths, + }); + }, + status: (started) => ({ pid: process.pid, - url: activeWebUrl, + state: "running", + updatedAt: new Date().toISOString(), + url: started.webUrl, + windowVisible: false, }), + async stop(started) { + await started.shutdown(); + }, + }, }); + await client.start(); + const webUrl = (await client.status<{ url: string }>(APP_KEYS.DESKTOP)).url; process.stdout.write(`\n Open Design is running\n\n`); process.stdout.write(` ➜ ${colorize(webUrl)}\n\n`); process.stdout.write(` Press Ctrl+C to stop\n\n`); - process.on("SIGINT", () => { - process.stdout.write("\n Shutting down Open Design...\n"); - void shutdown(); - }); - process.on("SIGTERM", () => { - process.stdout.write("\n Shutting down Open Design...\n"); - void shutdown(); - }); } async function installCodexMcp(daemonUrl: string | null): Promise { diff --git a/apps/packaged/src/headless.ts b/apps/packaged/src/headless.ts index d3427852e3..9cdbfddcc1 100644 --- a/apps/packaged/src/headless.ts +++ b/apps/packaged/src/headless.ts @@ -3,9 +3,13 @@ import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { + APP_KEYS, OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_DEFAULTS, + SIDECAR_SOURCES, } from "@open-design/sidecar-proto"; +import { bootstrapSidecarProcess, readCurrentSidecarStamp } from "@open-design/sidecar"; +import { releaseChannelFromNamespace } from "@open-design/release"; import { PACKAGED_NAMESPACE_ENV, @@ -16,6 +20,7 @@ import { parsePackagedHeadlessRequest, runPackagedHeadless, } from "./headless-runtime.js"; +import { resolvePackagedNamespacePaths } from "./paths.js"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); @@ -82,16 +87,34 @@ const headlessRequest = parsePackagedHeadlessRequest([ ...process.argv.slice(2), ]); -void runPackagedHeadless( - resolveHeadlessConfig(), - headlessRequest, - { +async function main(): Promise { + const config = resolveHeadlessConfig(); + const paths = resolvePackagedNamespacePaths(config, config.namespace, process.env); + const currentStamp = (() => { + try { return readCurrentSidecarStamp(); } catch { return null; } + })(); + const stamp = currentStamp ?? { + app: APP_KEYS.DESKTOP, + channel: releaseChannelFromNamespace(config.namespace, "default") ?? "stable", + mode: "headless", + namespace: config.namespace, + source: SIDECAR_SOURCES.PACKAGED, + }; + if (await bootstrapSidecarProcess(stamp, { + dataRoot: paths.dataRoot, + ownerPid: null, + port: 0, + runtimeRoot: paths.runtimeRoot, + })) return; + await runPackagedHeadless(config, headlessRequest, { mcpBootstrapLaunch: { command: process.execPath, args: [fileURLToPath(import.meta.url), "--headless"], }, - }, -).catch((error: unknown) => { + }); +} + +void main().catch((error: unknown) => { process.stderr.write( `open-design headless failed: ${ error instanceof Error ? error.message : String(error) diff --git a/apps/packaged/src/identity.ts b/apps/packaged/src/identity.ts deleted file mode 100644 index 9c9349aca1..0000000000 --- a/apps/packaged/src/identity.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { dirname } from "node:path"; - -import { removeFile, writeJsonFile } from "@open-design/sidecar"; -import type { SidecarStamp } from "@open-design/sidecar-proto"; - -import type { PackagedNamespacePaths } from "./paths.js"; - -export type PackagedDesktopRootIdentity = { - appPath: string; - executablePath: string; - logPath: string; - namespaceRoot: string; - pid: number; - ppid: number; - stamp: SidecarStamp; - startedAt: string; - updatedAt: string; - version: 1; -}; - -export type PackagedWebRootIdentity = { - namespace: string; - pid: number; - url: string; - startedAt: string; - version: 1; -}; - -export type PackagedDesktopIdentityHandle = { - close(): Promise; - identity: PackagedDesktopRootIdentity; -}; - -function resolveCurrentMacAppPath(executablePath: string): string { - return dirname(dirname(dirname(executablePath))); -} - -function createPackagedDesktopRootIdentity(options: { - paths: PackagedNamespacePaths; - stamp: SidecarStamp; -}): PackagedDesktopRootIdentity { - const now = new Date().toISOString(); - const executablePath = process.execPath; - - return { - appPath: resolveCurrentMacAppPath(executablePath), - executablePath, - logPath: options.paths.desktopLogPath, - namespaceRoot: options.paths.namespaceRoot, - pid: process.pid, - ppid: process.ppid, - stamp: options.stamp, - startedAt: now, - updatedAt: now, - version: 1, - }; -} - -export async function writePackagedDesktopIdentity(options: { - identityPath?: string; - paths: PackagedNamespacePaths; - stamp: SidecarStamp; -}): Promise { - const identity = createPackagedDesktopRootIdentity(options); - const identityPath = options.identityPath ?? options.paths.desktopIdentityPath; - - const writeIdentity = async () => { - identity.updatedAt = new Date().toISOString(); - await writeJsonFile(identityPath, identity); - }; - - await writeIdentity(); - const heartbeat = setInterval(() => { - void writeIdentity().catch(() => undefined); - }, 5000); - heartbeat.unref(); - - return { - async close() { - clearInterval(heartbeat); - await removeFile(identityPath).catch(() => undefined); - }, - identity, - }; -} - -export async function writePackagedWebIdentity(options: { - paths: PackagedNamespacePaths; - pid: number; - url: string; -}): Promise { - const identity: PackagedWebRootIdentity = { - namespace: options.paths.namespaceRoot.split("/").pop() ?? "default", - pid: options.pid, - url: options.url, - startedAt: new Date().toISOString(), - version: 1, - }; - await writeJsonFile(options.paths.webIdentityPath, identity); -} diff --git a/apps/packaged/src/index.ts b/apps/packaged/src/index.ts index 0f42f2e3bc..f70f20a33f 100644 --- a/apps/packaged/src/index.ts +++ b/apps/packaged/src/index.ts @@ -1,9 +1,9 @@ import { APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, + SIDECAR_MESSAGES, SIDECAR_MODES, SIDECAR_SOURCES, - type SidecarStamp, + type SidecarSource, } from "@open-design/sidecar-proto"; import { parseLauncherAfterQuitArgs, @@ -11,17 +11,24 @@ import { parseLauncherHandoffResumeArgs, } from "@open-design/launcher-proto"; import { - bootstrapSidecarRuntime, - createSidecarLaunchEnv, - resolveAppIpcPath, + bootstrapSidecarProcess, + isCurrentSidecarLauncher, + readCurrentSidecarStamp, + registerSidecarProcess, + resolveSidecarLauncherExitCode, + SidecarFactory, + type SidecarClient, + type SidecarRuntimeContext, + type SidecarStamp, } from "@open-design/sidecar"; import { applyLoopbackConnectionLimitSwitch, applyOsLocaleSwitch, createSplashWindow, setSplashStage, + type DesktopMainHandle, } from "@open-design/desktop/main"; -import { readProcessStamp } from "@open-design/platform"; +import { releaseChannelFromNamespace, releaseChannelFromVersion } from "@open-design/release"; import { join } from "node:path"; import { app, dialog } from "electron"; @@ -30,14 +37,15 @@ import { claimPackagedDownloadAttribution, discoverPackagedDownloadAttribution, } from "./download-attribution.js"; -import { writePackagedDesktopIdentity } from "./identity.js"; import { parsePackagedHeadlessRequest, resolvePackagedMcpBootstrapLaunch, + runPackagedMcpActionAgainstExistingDaemon, } from "./headless-runtime.js"; import { PackagedPathAccessError } from "./errors.js"; import { exitPackagedLauncherForExistingDesktop, + findExistingPackagedDesktopOwner, inspectExistingDesktopForLauncher, waitForLauncherAfterQuit, } from "./launcher-after-quit.js"; @@ -83,32 +91,6 @@ let startupTelemetryContext: } | null = null; -function createPackagedDesktopStamp(namespace: string): SidecarStamp { - return { - app: APP_KEYS.DESKTOP, - ipc: resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace, - }), - mode: SIDECAR_MODES.RUNTIME, - namespace, - source: SIDECAR_SOURCES.PACKAGED, - }; -} - -function applyLaunchEnv(base: string, stamp: SidecarStamp): void { - const env = createSidecarLaunchEnv({ - base, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - stamp, - }); - - for (const [key, value] of Object.entries(env)) { - if (value != null) process.env[key] = value; - } -} - function applyPackagedUpdaterEnv(updateMetadataUrl: string | null): void { if (updateMetadataUrl == null) return; if (process.env.OD_UPDATE_METADATA_URL != null && process.env.OD_UPDATE_METADATA_URL.length > 0) return; @@ -118,11 +100,6 @@ function applyPackagedUpdaterEnv(updateMetadataUrl: string | null): void { async function main(): Promise { const config = await readPackagedConfig(); const headlessRequest = parsePackagedHeadlessRequest(process.argv.slice(1)); - if (headlessRequest.headless) { - const { runPackagedHeadless } = await import("./headless-runtime.js"); - await runPackagedHeadless(config, headlessRequest); - return; - } // Must run BEFORE `app.whenReady()` below, because Chromium consumes // `--lang` at session bootstrap. Doing it here lets the packaged @@ -142,15 +119,66 @@ async function main(): Promise { const afterQuit = parseLauncherAfterQuitArgs(process.argv.slice(1)); const handoffResume = parseLauncherHandoffResumeArgs(process.argv.slice(1)); const delegated = parseLauncherDelegatedArgs(process.argv.slice(1)); - const argvStamp = readProcessStamp(process.argv.slice(1), OPEN_DESIGN_SIDECAR_CONTRACT); - const namespace = argvStamp?.namespace ?? config.namespace; + const convergedArgvStamp = (() => { + try { return readCurrentSidecarStamp(); } catch { return null; } + })(); + const namespace = convergedArgvStamp?.namespace ?? config.namespace; const namespaceConfig = namespace === config.namespace ? config : { ...config, namespace }; const initialPaths = resolvePackagedNamespacePaths(namespaceConfig, namespace, process.env); - if (!await waitForLauncherAfterQuit(afterQuit, initialPaths)) { + const launchStamp: SidecarStamp = { + app: APP_KEYS.DESKTOP, + channel: convergedArgvStamp?.channel + ?? releaseChannelFromVersion(namespaceConfig.appVersion) + ?? releaseChannelFromNamespace(namespace, "default") + ?? "stable", + mode: headlessRequest.headless ? "headless" : SIDECAR_MODES.RUNTIME, + namespace, + source: convergedArgvStamp?.source ?? SIDECAR_SOURCES.PACKAGED, + }; + if (await runPackagedMcpActionAgainstExistingDaemon(headlessRequest, launchStamp)) { + app.exit(0); + return; + } + if (headlessRequest.mcpInstallAgent != null) { + const existingOwner = await findExistingPackagedDesktopOwner(launchStamp, { + modes: [SIDECAR_MODES.RUNTIME, "headless"], + }); + if (existingOwner != null) { + throw new Error( + `Cannot install MCP while the existing ${existingOwner.stamp.mode} desktop runtime has no healthy daemon. Quit Open Design and retry.`, + ); + } + } + const oppositeDesktop = await inspectExistingDesktopForLauncher(launchStamp, { + deeplinkUrl: findPackagedDeeplinkArg(process.argv), + logger: console, + modes: [ + headlessRequest.headless ? SIDECAR_MODES.RUNTIME : "headless", + ...( + convergedArgvStamp == null || isCurrentSidecarLauncher() + ? [launchStamp.mode] + : [] + ), + ], + paths: initialPaths, + }); + if (exitPackagedLauncherForExistingDesktop(oppositeDesktop, (code) => app.exit(code))) { + return; + } + if (await bootstrapSidecarProcess(launchStamp, { + dataRoot: initialPaths.dataRoot, + ownerPid: null, + port: 0, + runtimeRoot: initialPaths.runtimeRoot, + })) { + app.exit(0); + return; + } + if (!headlessRequest.headless && !await waitForLauncherAfterQuit(afterQuit, initialPaths)) { app.exit(1); return; } - const existingDesktop = await inspectExistingDesktopForLauncher(namespace, { + const existingDesktop = await inspectExistingDesktopForLauncher(launchStamp, { deeplinkUrl: findPackagedDeeplinkArg(process.argv), incomingVersion: namespaceConfig.appVersion, logger: console, @@ -159,12 +187,16 @@ async function main(): Promise { if (exitPackagedLauncherForExistingDesktop(existingDesktop, (code) => app.exit(code))) { return; } - const stamp = argvStamp ?? createPackagedDesktopStamp(namespace); + if (headlessRequest.headless) { + const { runPackagedHeadless } = await import("./headless-runtime.js"); + await runPackagedHeadless(config, headlessRequest); + return; + } const launcherRuntime = await resolvePackagedLauncherRuntime(namespaceConfig, initialPaths, { delegated, resume: handoffResume, }); - if (await launchPackagedPayloadDesktop(launcherRuntime, stamp)) { + if (await launchPackagedPayloadDesktop(launcherRuntime)) { app.exit(0); return; } @@ -182,7 +214,7 @@ async function main(): Promise { posthogHost: activeConfig.posthogHost, appVersion: activeConfig.appVersion, namespace, - source: SIDECAR_SOURCES.PACKAGED, + source: convergedArgvStamp?.source ?? SIDECAR_SOURCES.PACKAGED, // Pass installationRoot explicitly: OD_INSTALLATION_DIR is only set in the // daemon child env, not this parent process (see startup-telemetry.ts). installationRoot: paths.installationRoot, @@ -201,13 +233,20 @@ async function main(): Promise { }; await ensurePackagedNamespacePaths(paths); + const convergedStamp = launchStamp; + registerSidecarProcess(convergedStamp, { + dataRoot: paths.dataRoot, + ownerPid: null, + port: 0, + runtimeRoot: paths.runtimeRoot, + }); stabilizePackagedWorkingDirectory(paths); const downloadAttribution = await discoverPackagedDownloadAttribution(paths, console).catch((error: unknown) => { console.warn("[attribution] failed to discover packaged download attribution", error); return null; }); packagedLogger = createPackagedDesktopLogger(paths); - attachPackagedDesktopProcessLogging({ logger: packagedLogger, paths, stamp }); + attachPackagedDesktopProcessLogging({ logger: packagedLogger, paths, stamp: convergedStamp }); const retireObsoleteInstalledOuter = createObsoleteInstalledOuterRetirement({ currentExecutablePath: process.execPath, currentPid: process.pid, @@ -224,7 +263,6 @@ async function main(): Promise { })) { return; } - const identity = await writePackagedDesktopIdentity({ paths, stamp }); await app.whenReady(); // Show the brand splash IMMEDIATELY, before we await the daemon/web sidecars @@ -236,13 +274,13 @@ async function main(): Promise { // BEFORE the sidecar boot below — rather than re-adding the delay afterwards. const splash = createSplashWindow(); - applyLaunchEnv(paths.runtimeRoot, stamp); - - const runtime = bootstrapSidecarRuntime(stamp, process.env, { + const runtime = { app: APP_KEYS.DESKTOP, base: paths.runtimeRoot, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - }); + mode: SIDECAR_MODES.RUNTIME, + namespace, + source: convergedStamp.source as SidecarSource, + } satisfies SidecarRuntimeContext; const sidecars = await startPackagedSidecars(runtime, paths, { appVersion: activeConfig.appVersion, @@ -300,18 +338,35 @@ async function main(): Promise { registerOdProtocol(() => sidecars.currentWebUrl()); const { runDesktopMain } = await import("@open-design/desktop/main"); - await runDesktopMain(runtime, { + let desktopHandle: DesktopMainHandle | null = null; + const invokeDesktop = async (action: string, input: unknown) => { + if (desktopHandle == null) throw new Error("packaged desktop sidecar is not running"); + return await desktopHandle.invoke(action, input); + }; + let client!: SidecarClient; + client = SidecarFactory.create({ + handlers: Object.fromEntries([ + SIDECAR_MESSAGES.CLICK, + SIDECAR_MESSAGES.CONSOLE, + SIDECAR_MESSAGES.EVAL, + SIDECAR_MESSAGES.EXPORT_ARTIFACT, + SIDECAR_MESSAGES.EXPORT_PDF, + SIDECAR_MESSAGES.RENDER_FRAMES, + SIDECAR_MESSAGES.RENDER_SLIDES, + SIDECAR_MESSAGES.SCREENSHOT, + SIDECAR_MESSAGES.SHOW, + SIDECAR_MESSAGES.UPDATE, + ].map((action) => [action, (input: unknown) => invokeDesktop(action, input)])), + lifecycle: { + async start() { + const started = await runDesktopMain(runtime, { splashWindow: splash.window, splashStartedAt: splash.startedAt, async beforeShutdown() { try { await retireObsoleteInstalledOuter(); } finally { - try { - await sidecars.close(); - } finally { - await identity.close(); - } + await sidecars.close(); } }, async discoverWebUrl() { @@ -324,6 +379,19 @@ async function main(): Promise { async discoverDaemonUrl() { return sidecars.daemon.url; }, + registerDesktopAuth: async (secret) => { + try { + const result = await client.invoke<{ accepted: true }>( + APP_KEYS.DAEMON, + "register-desktop-auth", + { secret: secret.toString("base64") }, + { timeoutMs: 800 }, + ); + return result.accepted === true; + } catch { + return false; + } + }, windowTitle: resolvePackagedWindowTitle(activeConfig), inviteProtocolClientPath: process.platform === "win32" ? launcherRuntime.installedLaunchPath : null, @@ -354,8 +422,19 @@ async function main(): Promise { launcherRoot: launcherRuntime.launcherPaths.root, launcherPayloadExtractorPath: activeConfig.resourceRoot == null ? null : join(activeConfig.resourceRoot, "bin", "7z.exe"), launcherRuntimePath: launcherRuntime.launcherPaths.runtimePath, + }, + }); + desktopHandle = started; + return started; + }, + status: (started) => started.status(), + async stop(started) { + await started.stop(); + desktopHandle = null; + }, }, }); + await client.start(); } void main().catch(async (error: unknown) => { @@ -390,5 +469,5 @@ void main().catch(async (error: unknown) => { nativeModulePath: startupTelemetryContext.nativeModulePath, }); } - process.exit(1); + process.exit(resolveSidecarLauncherExitCode(error)); }); diff --git a/apps/packaged/src/launcher-after-quit.ts b/apps/packaged/src/launcher-after-quit.ts index 3612174665..f6cd421d09 100644 --- a/apps/packaged/src/launcher-after-quit.ts +++ b/apps/packaged/src/launcher-after-quit.ts @@ -5,20 +5,77 @@ import { stopProcesses, waitForProcessExit, type StopProcessesResult } from "@op import { compareLauncherVersions, type LauncherAfterQuitRequest } from "@open-design/launcher-proto"; import { APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_MESSAGES, + SIDECAR_MODES, + SIDECAR_SOURCES, type AppKey, type DesktopStatusSnapshot, } from "@open-design/sidecar-proto"; -import { requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; +import { + getSidecarStatus, + invokeSidecar, + stopSidecar, + type SidecarStamp, +} from "@open-design/sidecar"; import type { PackagedNamespacePaths } from "./paths.js"; type LauncherAfterQuitLogger = Pick & Partial>; +const HEADLESS_SIDECAR_MODE = "headless"; +const PACKAGED_SIDECAR_SOURCES = [SIDECAR_SOURCES.TOOLS_PACK, SIDECAR_SOURCES.PACKAGED] as const; + +function packagedSidecarSourcesFor(stamp: SidecarStamp): readonly SidecarStamp["source"][] { + return [stamp.source, ...PACKAGED_SIDECAR_SOURCES.filter((source) => source !== stamp.source)]; +} export type LauncherExistingDesktopGateResult = | { action: "continue"; reason: "headless-owner" | "inspect-failed" | "not-running" | "stale-sidecar" | "superseded-version" } - | { action: "exit"; reason: "existing-focused" | "existing-focus-failed" }; + | { action: "exit"; reason: "existing-focused" | "existing-focus-failed" | "existing-headless" }; + +type ExistingDesktopOwnerInspection = { + lastError: unknown; + observedNotRunning: DesktopStatusSnapshot | null; + running: { stamp: SidecarStamp; status: DesktopStatusSnapshot } | null; +}; + +async function inspectExistingDesktopOwnerCandidates( + stamp: SidecarStamp, + modes: readonly SidecarStamp["mode"][], + sources: readonly SidecarStamp["source"][], + getStatus: typeof getSidecarStatus, +): Promise { + let lastError: unknown = null; + let observedNotRunning: DesktopStatusSnapshot | null = null; + const candidates = [...new Set(modes)].flatMap((mode) => + [...new Set(sources)].map((source) => ({ ...stamp, mode, source })), + ); + for (const candidate of candidates) { + try { + const status = await getStatus(candidate, { timeoutMs: 350 }); + if (status.state === "running") { + return { lastError, observedNotRunning, running: { stamp: candidate, status } }; + } + observedNotRunning ??= status; + } catch (error) { + lastError = error; + } + } + return { lastError, observedNotRunning, running: null }; +} + +export async function findExistingPackagedDesktopOwner( + stamp: SidecarStamp, + options: { + getStatus?: typeof getSidecarStatus; + modes?: readonly SidecarStamp["mode"][]; + sources?: readonly SidecarStamp["source"][]; + } = {}, +): Promise<{ stamp: SidecarStamp; status: DesktopStatusSnapshot } | null> { + const getStatus = options.getStatus ?? getSidecarStatus; + const modes = options.modes ?? [stamp.mode]; + const sources = options.sources ?? packagedSidecarSourcesFor(stamp); + return (await inspectExistingDesktopOwnerCandidates(stamp, modes, sources, getStatus)).running; +} /** * Finish a duplicate packaged entry after the healthy namespace desktop has @@ -85,41 +142,29 @@ async function forceStopLingeringDesktop( async function restartExistingDesktop( input: { - ipcPath: string; logger: LauncherAfterQuitLogger; namespace: string; paths: PackagedNamespacePaths; pid: number | null; reason: "headless-owner" | "stale-sidecar" | "superseded-version"; - requestIpc: typeof requestJsonIpc; - stop: typeof stopProcesses; - waitForExit: typeof waitForProcessExit; + stamp: SidecarStamp; + stopSidecar: typeof stopSidecar; }, ): Promise { try { - await input.requestIpc(input.ipcPath, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 800 }); + const result = await input.stopSidecar(input.stamp); + const exited = result.remainingPids.length === 0; + await writeLauncherAfterQuitLog( + input.paths, + `inspect-found-existing namespace=${input.namespace} shutdown=${exited ? "exited" : "timed-out"} reason=${input.reason} pid=${input.pid ?? "unknown"}`, + ); + return exited; } catch (error) { const message = `inspect-found-existing namespace=${input.namespace} shutdown=failed reason=${input.reason} error=${error instanceof Error ? error.message : String(error)}`; await writeLauncherAfterQuitLog(input.paths, message); input.logger.warn(`[open-design launcher] ${message}`); return false; } - if (input.pid == null) return true; - - const exited = await input.waitForExit(input.pid, 5000); - await writeLauncherAfterQuitLog( - input.paths, - `inspect-found-existing namespace=${input.namespace} shutdown=${exited ? "exited" : "timed-out"} reason=${input.reason} pid=${input.pid}`, - ); - if (exited) return true; - - return await forceStopLingeringDesktop( - input.pid, - input.reason, - input.paths, - input.logger, - input.stop, - ); } function incomingVersionSupersedesExisting( @@ -161,55 +206,47 @@ export async function waitForLauncherAfterQuit( } export async function inspectExistingDesktopForLauncher( - namespace: string, + stamp: SidecarStamp, options: { deeplinkUrl?: string | null; incomingVersion?: string | null; logger?: LauncherAfterQuitLogger; paths: PackagedNamespacePaths; - requestIpc?: typeof requestJsonIpc; - stopProcesses?: typeof stopProcesses; - waitForExit?: typeof waitForProcessExit; + getStatus?: typeof getSidecarStatus; + invoke?: typeof invokeSidecar; + modes?: readonly SidecarStamp["mode"][]; + sources?: readonly SidecarStamp["source"][]; + stopSidecar?: typeof stopSidecar; }, ): Promise { + const namespace = stamp.namespace; const logger = options.logger ?? console; - const requestIpc = options.requestIpc ?? requestJsonIpc; - const waitForExit = options.waitForExit ?? waitForProcessExit; - const stop = options.stopProcesses ?? stopProcesses; - const ipcPath = resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace, - }); - let status: DesktopStatusSnapshot | null = null; - try { - status = await requestIpc( - ipcPath, - { type: SIDECAR_MESSAGES.STATUS }, - { timeoutMs: 350 }, - ); - } catch (error) { - const message = `inspect-unavailable namespace=${namespace} action=continue error=${error instanceof Error ? error.message : String(error)}`; + const getStatus = options.getStatus ?? getSidecarStatus; + const invoke = options.invoke ?? invokeSidecar; + const stop = options.stopSidecar ?? stopSidecar; + const modes = options.modes ?? [ + stamp.mode, + stamp.mode === HEADLESS_SIDECAR_MODE ? SIDECAR_MODES.RUNTIME : HEADLESS_SIDECAR_MODE, + ]; + const sources = options.sources ?? packagedSidecarSourcesFor(stamp); + const ownerInspection = await inspectExistingDesktopOwnerCandidates(stamp, modes, sources, getStatus); + if (ownerInspection.running == null) { + const { lastError, observedNotRunning } = ownerInspection; + if (observedNotRunning != null) { + await writeLauncherAfterQuitLog(options.paths, `inspect-not-running namespace=${namespace} state=${observedNotRunning.state}`); + return { action: "continue", reason: "not-running" }; + } + const message = `inspect-unavailable namespace=${namespace} action=continue error=${lastError instanceof Error ? lastError.message : String(lastError)}`; await writeLauncherAfterQuitLog(options.paths, message); logger.info?.(`[open-design launcher] ${message}`); return { action: "continue", reason: "inspect-failed" }; } - - if (status.state !== "running") { - await writeLauncherAfterQuitLog(options.paths, `inspect-not-running namespace=${namespace} state=${status.state}`); - return { action: "continue", reason: "not-running" }; - } + const { stamp: inspectedStamp, status } = ownerInspection.running; const staleSidecars: AppKey[] = []; for (const app of [APP_KEYS.DAEMON, APP_KEYS.WEB]) { - const sidecarIpcPath = resolveAppIpcPath({ - app, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace, - }); - const sidecarStatus = await requestIpc<{ url?: unknown }>( - sidecarIpcPath, - { type: SIDECAR_MESSAGES.STATUS }, + const sidecarStatus = await getStatus<{ url?: unknown }>( + { ...inspectedStamp, app, mode: inspectedStamp.mode }, { timeoutMs: 350 }, ).catch(() => null); if (typeof sidecarStatus?.url !== "string" || sidecarStatus.url.length === 0) { @@ -224,15 +261,13 @@ export async function inspectExistingDesktopForLauncher( `inspect-found-existing namespace=${namespace} action=restart reason=stale-sidecar apps=${staleSidecars.join(",")} pid=${pid ?? "unknown"}`, ); const restarted = await restartExistingDesktop({ - ipcPath, logger, namespace, paths: options.paths, pid, reason: "stale-sidecar", - requestIpc, - stop, - waitForExit, + stamp: inspectedStamp, + stopSidecar: stop, }); if (!restarted) return { action: "exit", reason: "existing-focus-failed" }; return { action: "continue", reason: "stale-sidecar" }; @@ -246,15 +281,13 @@ export async function inspectExistingDesktopForLauncher( `inspect-found-existing namespace=${namespace} action=restart reason=superseded-version incomingVersion=${options.incomingVersion?.trim()} existingVersion=${existingVersion?.trim()} pid=${pid ?? "unknown"}`, ); const restarted = await restartExistingDesktop({ - ipcPath, logger, namespace, paths: options.paths, pid, reason: "superseded-version", - requestIpc, - stop, - waitForExit, + stamp: inspectedStamp, + stopSidecar: stop, }); if (!restarted) return { action: "exit", reason: "existing-focus-failed" }; return { action: "continue", reason: "superseded-version" }; @@ -262,32 +295,35 @@ export async function inspectExistingDesktopForLauncher( if (status.windowVisible === false) { const pid = typeof status.pid === "number" ? status.pid : null; + if (stamp.mode === HEADLESS_SIDECAR_MODE && inspectedStamp.mode === HEADLESS_SIDECAR_MODE) { + await writeLauncherAfterQuitLog( + options.paths, + `inspect-found-existing namespace=${namespace} action=reuse reason=existing-headless pid=${pid ?? "unknown"}`, + ); + return { action: "exit", reason: "existing-headless" }; + } await writeLauncherAfterQuitLog( options.paths, `inspect-found-existing namespace=${namespace} action=restart reason=headless-owner pid=${pid ?? "unknown"}`, ); const restarted = await restartExistingDesktop({ - ipcPath, logger, namespace, paths: options.paths, pid, reason: "headless-owner", - requestIpc, - stop, - waitForExit, + stamp: inspectedStamp, + stopSidecar: stop, }); if (!restarted) return { action: "exit", reason: "existing-focus-failed" }; return { action: "continue", reason: "headless-owner" }; } try { - await requestIpc( - ipcPath, - { - ...(options.deeplinkUrl == null ? {} : { input: { deeplinkUrl: options.deeplinkUrl } }), - type: SIDECAR_MESSAGES.SHOW, - }, + await invoke( + inspectedStamp, + SIDECAR_MESSAGES.SHOW, + options.deeplinkUrl == null ? {} : { deeplinkUrl: options.deeplinkUrl }, { timeoutMs: 800 }, ); await writeLauncherAfterQuitLog(options.paths, `inspect-found-existing namespace=${namespace} focus=accepted`); diff --git a/apps/packaged/src/launcher-runtime.ts b/apps/packaged/src/launcher-runtime.ts index 773ff61583..d76bbf939d 100644 --- a/apps/packaged/src/launcher-runtime.ts +++ b/apps/packaged/src/launcher-runtime.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { access, lstat, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { access, lstat, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { basename, dirname, join, resolve, sep } from "node:path"; import { @@ -229,9 +229,15 @@ async function resolvePayloadDesktopExecutable( return executablePath; } -function sameExecutablePath(left: string, right: string): boolean { - const normalizedLeft = resolve(left); - const normalizedRight = resolve(right); +async function canonicalExecutablePath(path: string): Promise { + return await realpath(path).catch(() => resolve(path)); +} + +export async function sameExecutablePath(left: string, right: string): Promise { + const [normalizedLeft, normalizedRight] = await Promise.all([ + canonicalExecutablePath(left), + canonicalExecutablePath(right), + ]); return process.platform === "win32" ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() : normalizedLeft === normalizedRight; @@ -476,7 +482,7 @@ export async function resolvePackagedLauncherRuntime( descriptor.active.generation === handoff.target.generation && attempted?.version === handoff.target.version && attempted.generation === handoff.target.generation && - sameExecutablePath(currentExecutablePath, handoff.payloadExecutablePath) + await sameExecutablePath(currentExecutablePath, handoff.payloadExecutablePath) ? handoff.target : null; const selection = selectLauncherRuntimeTarget({ @@ -501,16 +507,16 @@ export async function resolvePackagedLauncherRuntime( }); const payloadConfig = await resolvePayloadConfig(config, versionPaths, channel); if (payloadConfig != null) { - const payloadDesktopProcess = sameExecutablePath( + const payloadDesktopProcess = await sameExecutablePath( currentExecutablePath, payloadConfig.desktopExecutablePath, ); if ( selection.reason === "active-resume" && - (handoff == null || !payloadDesktopProcess || !sameExecutablePath( + (handoff == null || !payloadDesktopProcess || !(await sameExecutablePath( handoff.payloadExecutablePath, payloadConfig.desktopExecutablePath, - )) + ))) ) { return await resolvePackagedLauncherRuntime(config, paths, { currentExecutablePath, @@ -541,7 +547,7 @@ export async function resolvePackagedLauncherRuntime( // must keep the stable launch path the launcher persisted. const installedLaunchPath = !payloadDesktopProcess && (persistedInstall == null - || !sameExecutablePath(persistedInstall.launchPath, currentPackageLaunchPath)) + || !(await sameExecutablePath(persistedInstall.launchPath, currentPackageLaunchPath))) ? (await writeLauncherInstallDescriptor( launcherPaths, channel, diff --git a/apps/packaged/src/logging.ts b/apps/packaged/src/logging.ts index 505ce33102..08e1bbafa7 100644 --- a/apps/packaged/src/logging.ts +++ b/apps/packaged/src/logging.ts @@ -1,6 +1,6 @@ import { appendFileSync } from "node:fs"; -import type { SidecarStamp } from "@open-design/sidecar-proto"; +import type { SidecarStamp } from "@open-design/sidecar"; import type { PackagedNamespacePaths } from "./paths.js"; diff --git a/apps/packaged/src/paths.ts b/apps/packaged/src/paths.ts index 9f03e1ce2e..5b340c499d 100644 --- a/apps/packaged/src/paths.ts +++ b/apps/packaged/src/paths.ts @@ -8,13 +8,11 @@ import { PackagedPathAccessError } from "./errors.js"; export type PackagedNamespacePaths = { cacheRoot: string; - desktopIdentityPath: string; desktopLogPath: string; dataRoot: string; desktopLogsRoot: string; electronSessionDataRoot: string; electronUserDataRoot: string; - headlessIdentityPath: string; /** * Channel-root directory — one level above the `namespaces/` parent. The * daemon writes `installation.json` here so installationId survives any @@ -29,7 +27,6 @@ export type PackagedNamespacePaths = { resourceRoot: string; runtimeRoot: string; updateRoot: string; - webIdentityPath: string; }; const HOME_BARE_TOKENS = new Set(["~", "$HOME", "${HOME}"]); @@ -114,13 +111,11 @@ export function resolvePackagedNamespacePaths( return { cacheRoot: join(namespaceRoot, "cache"), - desktopIdentityPath: join(namespaceRoot, "runtime", "desktop-root.json"), desktopLogPath: join(namespaceRoot, "logs", APP_KEYS.DESKTOP, "latest.log"), dataRoot, desktopLogsRoot: join(namespaceRoot, "logs", APP_KEYS.DESKTOP), electronSessionDataRoot: join(namespaceRoot, "user-data", "session"), electronUserDataRoot: join(namespaceRoot, "user-data"), - headlessIdentityPath: join(namespaceRoot, "runtime", "headless-root.json"), installationRoot, installerObservationRoot: join(dataRoot, "observations", "installer"), logsRoot: join(namespaceRoot, "logs"), @@ -128,6 +123,5 @@ export function resolvePackagedNamespacePaths( resourceRoot: config.resourceRoot, runtimeRoot: join(namespaceRoot, "runtime"), updateRoot: join(namespaceRoot, "updates"), - webIdentityPath: join(namespaceRoot, "runtime", "web-root.json"), }; } diff --git a/apps/packaged/src/payload-desktop-launch.ts b/apps/packaged/src/payload-desktop-launch.ts index 9397072543..e333edbad5 100644 --- a/apps/packaged/src/payload-desktop-launch.ts +++ b/apps/packaged/src/payload-desktop-launch.ts @@ -1,9 +1,7 @@ -import { spawn } from "node:child_process"; import { dirname } from "node:path"; import { buildLauncherAfterQuitArgs, buildLauncherDelegatedArgs } from "@open-design/launcher-proto"; -import { createProcessStampArgs } from "@open-design/platform"; -import { OPEN_DESIGN_SIDECAR_CONTRACT, type SidecarStamp } from "@open-design/sidecar-proto"; +import { handoffCurrentSidecarGeneration } from "@open-design/sidecar"; import { armPackagedLauncherRuntimeAttempt, @@ -25,7 +23,6 @@ export type PackagedPayloadDesktopLaunchPlan = { export function planPackagedPayloadDesktopDelegation( runtime: PackagedLauncherRuntime, - stamp: SidecarStamp, options: { currentPid?: number; forwardedArgs?: readonly string[]; @@ -56,7 +53,6 @@ export function planPackagedPayloadDesktopDelegation( ...(options.forwardedArgs ?? process.argv).filter((arg) => arg.startsWith("opendesign://") ), - ...createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT), ], command: runtime.desktopExecutablePath, cwd: dirname(runtime.desktopExecutablePath), @@ -65,16 +61,15 @@ export function planPackagedPayloadDesktopDelegation( export async function launchPackagedPayloadDesktop( runtime: PackagedLauncherRuntime, - stamp: SidecarStamp, options: { currentPid?: number; forwardedArgs?: readonly string[]; recordFailedAttempt?: (runtime: PackagedLauncherRuntime) => Promise; - spawn?: typeof spawn; + handoff?: typeof handoffCurrentSidecarGeneration; timeoutMs?: number; } = {}, ): Promise { - const plan = planPackagedPayloadDesktopDelegation(runtime, stamp, options); + const plan = planPackagedPayloadDesktopDelegation(runtime, options); if (plan == null) return false; // Pre-arm BEFORE spawn: a payload that spawns successfully but dies before @@ -82,17 +77,12 @@ export async function launchPackagedPayloadDesktop( // evidence, and every later cold start would retry the same broken payload. await armPackagedLauncherRuntimeAttempt(runtime); try { - const child = (options.spawn ?? spawn)(plan.command, plan.args, { + await (options.handoff ?? handoffCurrentSidecarGeneration)({ + args: plan.args, + command: plan.command, cwd: plan.cwd, - detached: true, - stdio: "ignore", - windowsHide: true, - }); - await new Promise((resolveSpawn, rejectSpawn) => { - child.once("spawn", () => resolveSpawn()); - child.once("error", rejectSpawn); - }); - child.unref(); + env: process.env, + }, { timeoutMs: options.timeoutMs ?? DEFAULT_DELEGATION_TIMEOUT_MS }); } catch (error) { await (options.recordFailedAttempt ?? recordPackagedLauncherRuntimeFailedAttempt)(runtime); throw error; diff --git a/apps/packaged/src/sidecars.ts b/apps/packaged/src/sidecars.ts index c6feb3bc48..be66516efe 100644 --- a/apps/packaged/src/sidecars.ts +++ b/apps/packaged/src/sidecars.ts @@ -1,5 +1,5 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { access, appendFile, lstat, mkdir, open, rename, rm, type FileHandle } from "node:fs/promises"; +import type { ChildProcess } from "node:child_process"; +import { access, appendFile, mkdir, open, rename, type FileHandle } from "node:fs/promises"; import { createRequire } from "node:module"; import { delimiter, dirname, join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; @@ -13,30 +13,23 @@ import { type AppKey, type DaemonStatusSnapshot, type RegisterWebUrlResult, - type ShutdownResult, - type SidecarStamp, type WebStatusSnapshot, } from "@open-design/sidecar-proto"; import { - createSidecarLaunchEnv, - isWindowsNamedPipePath, - requestJsonIpc, - resolveAppIpcPath, + getSidecarStatus, + invokeSidecar, + spawnSidecar, + stopSidecar, + type SpawnedSidecar, + type SidecarStamp, type SidecarRuntimeContext, } from "@open-design/sidecar"; import { - collectProcessTreePids, - createProcessStampArgs, - isProcessAlive, - listProcessSnapshots, - matchesStampedProcess, mergeProxyAwareEnv, resolveSystemProxyEnv, - stopProcesses, - waitForProcessExit, wellKnownUserToolchainBins, - type StopProcessesOptions, } from "@open-design/platform"; +import { releaseChannelFromNamespace, releaseChannelFromVersion } from "@open-design/release"; import type { PackagedWebOutputMode } from "./config.js"; import type { PackagedNamespacePaths } from "./paths.js"; @@ -115,12 +108,13 @@ export type PackagedSidecarHandle = { web: WebStatusSnapshot; }; -export type ManagedSidecarChild = { +type ManagedSidecarChild = { app: AppKey; child: ChildProcess; - ipcPath: string; + generation: SpawnedSidecar; logHandle: FileHandle; logPath: string; + stamp: SidecarStamp; }; type PackagedDaemonManagedPathEnv = { @@ -493,7 +487,7 @@ export function resolveDaemonStatusTimeoutMs( * so the user can read the actual failure reason. */ export async function waitForStatus( - ipcPath: string, + probe: { label: string; read(timeoutMs: number): Promise }, isReady: (status: T) => boolean, timeoutMs = DAEMON_STATUS_TIMEOUT_MS, watch: { child: { exitCode: number | null; pid?: number; signalCode: NodeJS.Signals | null; once: (event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void) => void; off: (event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void) => void }; logPath: string } | null = null, @@ -523,33 +517,14 @@ export async function waitForStatus( ); } try { - const status = await requestJsonIpc( - ipcPath, - { type: SIDECAR_MESSAGES.STATUS }, - { timeoutMs: 800 }, - ); - const statusPid = typeof (status as { pid?: unknown }).pid === "number" - ? (status as { pid: number }).pid - : null; - if (watch?.child.pid != null) { - if (statusPid == null) { - lastError = new Error(`sidecar status did not include pid for spawned pid ${watch.child.pid}`); - await sleep(STATUS_POLL_INITIAL_MS); - continue; - } - if (statusPid !== watch.child.pid) { - lastError = new Error(`sidecar status pid ${statusPid} did not match spawned pid ${watch.child.pid}`); - await sleep(STATUS_POLL_INITIAL_MS); - continue; - } - } + const status = await probe.read(800); if (isReady(status)) return status; } catch (error) { lastError = error; } // Keep timeoutMs a hard-ish upper bound: never sleep past the deadline, so // the widened win32 budget can't be overshot by a full backoff interval on - // a slow/dead sidecar. The in-flight requestJsonIpc timeout is the only + // a slow/dead sidecar. The in-flight client request timeout is the only // residual overshoot; the while-condition re-checks the deadline next tick. const remaining = timeoutMs - (Date.now() - startedAt); if (remaining <= 0) break; @@ -558,7 +533,7 @@ export async function waitForStatus( } throw new Error( - `timed out waiting for sidecar status at ${ipcPath}${ + `timed out waiting for ${probe.label} sidecar status${ lastError instanceof Error ? ` (${lastError.message})` : "" }`, ); @@ -567,142 +542,28 @@ export async function waitForStatus( } } -type StopStampedWebSidecarOwnerDeps = { - listProcessSnapshots?: typeof listProcessSnapshots; - stopProcesses?: typeof stopProcesses; -}; - -type StampedWebSidecarRetirement = "absent" | "stopped" | "still-running" | "discovery-failed"; - -async function stopStampedWebSidecarOwner( - ipcPath: string, +export async function retireExistingSidecar( + stamp: SidecarStamp, logPath: string, - deps: StopStampedWebSidecarOwnerDeps = {}, -): Promise { - const listSnapshots = deps.listProcessSnapshots ?? listProcessSnapshots; - const stop = deps.stopProcesses ?? stopProcesses; - let processes: Awaited>; - try { - processes = await listSnapshots(); - } catch (error) { - await appendSidecarLifecycleLog( - logPath, - `[open-design packaged] failed to enumerate processes before web socket takeover ipc=${ipcPath} error=${error instanceof Error ? error.message : String(error)}`, - ); - return "discovery-failed"; - } - // listProcessSnapshots returns [] on enumeration failure, so an empty table - // is not proof that no stamped owner exists. Leave the live socket in place. - if (processes.length === 0) { - await appendSidecarLifecycleLog( - logPath, - `[open-design packaged] process discovery failed before web socket takeover ipc=${ipcPath}`, - ); - return "discovery-failed"; - } - const rootPids = processes - .filter((processInfo) => - matchesStampedProcess( - processInfo, - { app: APP_KEYS.WEB, ipc: ipcPath }, - OPEN_DESIGN_SIDECAR_CONTRACT, - ), - ) - .map((processInfo) => processInfo.pid); - // Successful discovery, no stamped owner: leftover or unstamped hung socket. - // Unlink remains the original recovery. A live JsonIpcServer.close() race - // requires a stamped sidecar, which this helper would have found. - if (rootPids.length === 0) return "absent"; - - // Stop the whole tree before unlinking. A later SIGTERM on the old owner - // would run JsonIpcServer.close(), which unconditionally removes this - // pathname and can delete the replacement socket after rebind. - const pids = collectProcessTreePids(processes, rootPids); - await appendSidecarLifecycleLog( - logPath, - `[open-design packaged] stopping unresponsive stamped web sidecar before socket takeover ipc=${ipcPath} pids=${pids.join(",")}`, - ); - try { - const result = await stop(pids, { killGraceMs: 1_500, termGraceMs: 1_500 }); - if (result.remainingPids.length > 0) { - await appendSidecarLifecycleLog( - logPath, - `[open-design packaged] unresponsive stamped web sidecar still running after stop ipc=${ipcPath} remainingPids=${result.remainingPids.join(",")}`, - ); - return "still-running"; - } - return "stopped"; - } catch (error) { - await appendSidecarLifecycleLog( - logPath, - `[open-design packaged] failed to stop unresponsive stamped web sidecar ipc=${ipcPath} error=${error instanceof Error ? error.message : String(error)}`, - ); - return "still-running"; - } -} - -export async function retireExistingSidecarEndpoint( - ipcPath: string, - logPath: string, - app: AppKey, - deps: StopStampedWebSidecarOwnerDeps = {}, + deps: { + stop?: typeof stopSidecar; + } = {}, ): Promise { - let status: { pid?: number | null } | null = null; - try { - status = await requestJsonIpc<{ pid?: number | null }>( - ipcPath, - { type: SIDECAR_MESSAGES.STATUS }, - { timeoutMs: 350 }, - ); - } catch (error) { - // A web sidecar can survive an abruptly-terminated desktop while becoming - // too wedged to answer STATUS. A plain connect probe still sees its Unix - // socket as live, so the generic JSON-IPC stale-socket cleanup cannot - // distinguish it from a healthy endpoint and the replacement dies with - // EADDRINUSE. Web is stateless, so taking over this exact namespace socket - // is safe; daemon recovery remains conservative because starting a second - // daemon beside a hung first owner could put two writers on the same DB. - if (app !== APP_KEYS.WEB || isWindowsNamedPipePath(ipcPath)) return; - const retirement = await stopStampedWebSidecarOwner(ipcPath, logPath, deps); - if (retirement === "still-running" || retirement === "discovery-failed") return; - try { - const stat = await lstat(ipcPath); - if (!stat.isSocket()) return; - await rm(ipcPath, { force: true }); - const message = - `[open-design packaged] unresponsive web sidecar endpoint removed before relaunch ipc=${ipcPath} error=${error instanceof Error ? error.message : String(error)}`; - await appendSidecarLifecycleLog(logPath, message); - // The child log handle is already open and may overwrite a pre-spawn - // append from offset zero. Also emit through the packaged desktop logger, - // whose append-only file is the durable startup-recovery record. - console.warn(message); - } catch { - // ENOENT means the endpoint disappeared during the probe. Other failures - // are left for the real bind to report without deleting an unknown path. - } - return; - } - - const pid = typeof status.pid === "number" ? status.pid : null; await appendSidecarLifecycleLog( logPath, - `[open-design packaged] existing sidecar endpoint detected ipc=${ipcPath} pid=${pid ?? "unknown"}; requesting shutdown before relaunch`, + `[open-design packaged] retiring prior ${stamp.app} generation before launch`, ); try { - await requestJsonIpc(ipcPath, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 800 }); + const stopped = await (deps.stop ?? stopSidecar)(stamp, { termGraceMs: 2_500 }); + if (stopped.remainingPids.length > 0) { + throw new Error(`generation remains: ${stopped.remainingPids.join(", ")}`); + } } catch (error) { await appendSidecarLifecycleLog( logPath, - `[open-design packaged] existing sidecar shutdown request failed ipc=${ipcPath} error=${error instanceof Error ? error.message : String(error)}`, - ); - } - - if (pid != null && pid !== process.pid && isProcessAlive(pid)) { - const exited = await waitForProcessExit(pid, 2500); - await appendSidecarLifecycleLog( - logPath, - `[open-design packaged] existing sidecar endpoint ${exited ? "exited" : "still-running"} ipc=${ipcPath} pid=${pid}`, + `[open-design packaged] existing sidecar shutdown failed app=${stamp.app} error=${error instanceof Error ? error.message : String(error)}`, ); + throw error; } } @@ -740,16 +601,9 @@ export function resolvePackagedChildBaseEnv( forwardedEnv[key] = value; } } - const mergedEnv = includeSystemProxyEnv + return includeSystemProxyEnv ? mergeProxyAwareEnv(process.platform, systemProxyEnv, forwardedEnv) : mergeProxyAwareEnv(process.platform, forwardedEnv); - return { - ...mergedEnv, - // Daemon and web already monitor this protocol-owned parent PID. Packaged - // launches must provide it too so a SIGKILL/crash of the Electron owner - // cannot leave sidecars holding namespace IPC endpoints indefinitely. - [SIDECAR_ENV.TOOLS_DEV_PARENT_PID]: String(process.pid), - }; } function createPackagedDaemonManagedPathEnv( @@ -872,8 +726,23 @@ function pickPackagedDesktopHandoffEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEn return selected; } +export function packagedChildStamp( + app: AppKey, + channel: string, + runtime: SidecarRuntimeContext, +): SidecarStamp { + return { + app, + channel, + mode: runtime.mode, + namespace: runtime.namespace, + source: runtime.source, + }; +} + async function spawnSidecarChild(options: { app: AppKey; + channel: string; electronNodeCommand: string | null; entryPath: string; env: NodeJS.ProcessEnv; @@ -881,29 +750,15 @@ async function spawnSidecarChild(options: { paths: PackagedNamespacePaths; runtime: SidecarRuntimeContext; }): Promise { - const ipcPath = resolveAppIpcPath({ - app: options.app, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: options.runtime.namespace, - }); - const stamp = { - app: options.app, - ipc: ipcPath, - mode: SIDECAR_MODES.RUNTIME, - namespace: options.runtime.namespace, - source: options.runtime.source, - } satisfies SidecarStamp; + const stamp = packagedChildStamp(options.app, options.channel, options.runtime); const logPath = logPathFor(options.paths, options.app); const logHandle = await openLog(logPath); - await retireExistingSidecarEndpoint(ipcPath, logPath, options.app); + await retireExistingSidecar(stamp, logPath); const usesElectronAsNode = options.nodeCommand == null; const command = options.nodeCommand ?? options.electronNodeCommand ?? await resolvePackagedElectronNodeCommand(); - const childEnv = createSidecarLaunchEnv({ - base: options.paths.runtimeRoot, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - extraEnv: { + const childEnv = { ...resolvePackagedChildBaseEnv( process.env, options.app === APP_KEYS.DAEMON, @@ -914,25 +769,25 @@ async function spawnSidecarChild(options: { NODE_ENV: "production", PATH: resolvePackagedPathEnv(), ...(usesElectronAsNode ? { ELECTRON_RUN_AS_NODE: "1" } : {}), + }; + const generation = await spawnSidecar({ + args: [options.entryPath], + command, + cwd: options.paths.runtimeRoot, + env: childEnv, + logFd: logHandle.fd, + resources: { + dataRoot: options.paths.dataRoot, + ownerPid: process.pid, + port: Number(options.env[ + options.app === APP_KEYS.DAEMON ? SIDECAR_ENV.DAEMON_PORT : SIDECAR_ENV.WEB_PORT + ] ?? 0), + runtimeRoot: options.paths.runtimeRoot, }, stamp, }); - const child = spawn( - command, - [options.entryPath, ...createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT)], - createPackagedSidecarSpawnOptions({ - env: childEnv, - logFd: logHandle.fd, - paths: options.paths, - }), - ); - await new Promise((resolveSpawn, rejectSpawn) => { - child.once("error", rejectSpawn); - child.once("spawn", resolveSpawn); - }); - - return { app: options.app, child, ipcPath, logHandle, logPath }; + return { app: options.app, child: generation.process, generation, logHandle, logPath, stamp }; } export function createPackagedSidecarSpawnOptions(input: { @@ -953,66 +808,35 @@ export function createPackagedSidecarSpawnOptions(input: { }; } -export const MANAGED_CHILD_EXIT_GRACE_MS = 5_000; -export const DEFERRED_MANAGED_CHILD_EXIT_GRACE_MS = 30_000; - -export type CloseManagedChildDeps = { - deferredExitGraceMs?: number; - exitGraceMs?: number; - requestIpc?: typeof requestJsonIpc; - stopOptions?: StopProcessesOptions; - stopProcesses?: typeof stopProcesses; - waitForExit?: typeof waitForProcessExit; -}; - -export function resolveManagedChildExitGraceMs( - shutdown: ShutdownResult | null | undefined, -): number { - return shutdown?.deferred === true - ? DEFERRED_MANAGED_CHILD_EXIT_GRACE_MS - : MANAGED_CHILD_EXIT_GRACE_MS; -} - -export async function closeManagedChild( - child: ManagedSidecarChild, - deps: CloseManagedChildDeps = {}, -): Promise { +export async function closeManagedChild(child: ManagedSidecarChild): Promise { const appendLifecycleLog = async (message: string): Promise => appendSidecarLifecycleLog(child.logPath, message); - await appendLifecycleLog(`[open-design packaged] shutdown requested app=${child.app} pid=${child.child.pid ?? "unknown"}`); - let shutdown: ShutdownResult | undefined; try { - shutdown = await (deps.requestIpc ?? requestJsonIpc)( - child.ipcPath, - { type: SIDECAR_MESSAGES.SHUTDOWN }, - { timeoutMs: 1200 }, - ); - } catch { - // Fall through to process cleanup. + await appendLifecycleLog(`[open-design packaged] shutdown requested app=${child.app} pid=${child.child.pid ?? "unknown"}`); + // A daemon may briefly hold shutdown while committing a desktop handoff. + // The sidecar generation boundary still owns escalation; packaged only + // supplies a bounded grace appropriate for that lifecycle operation. + const stop = await child.generation.stop({ termGraceMs: child.app === APP_KEYS.DAEMON ? 30_000 : 5_000 }); + if (stop.forcedPids.length > 0) { + await appendLifecycleLog(`[open-design packaged] graceful shutdown timed out app=${child.app} pid=${child.child.pid ?? "unknown"}; forced=${stop.forcedPids.join(",")}`); + } + if (stop.remainingPids.length > 0) { + throw new Error(`failed to stop packaged ${child.app} sidecar processes: ${stop.remainingPids.join(", ")}`); + } + await appendLifecycleLog(`[open-design packaged] exited app=${child.app} pid=${child.child.pid ?? "unknown"} code=${child.child.exitCode ?? "unknown"} signal=${child.child.signalCode ?? "none"}`); + } finally { + await child.logHandle.close().catch(() => undefined); } - - const exitGraceMs = shutdown?.deferred === true - ? (deps.deferredExitGraceMs ?? resolveManagedChildExitGraceMs(shutdown)) - : (deps.exitGraceMs ?? resolveManagedChildExitGraceMs(shutdown)); - - if (!(await (deps.waitForExit ?? waitForProcessExit)(child.child.pid, exitGraceMs))) { - await appendLifecycleLog(`[open-design packaged] shutdown timeout app=${child.app} pid=${child.child.pid ?? "unknown"}; forcing stop`); - await (deps.stopProcesses ?? stopProcesses)([child.child.pid], deps.stopOptions); - } - - await appendLifecycleLog(`[open-design packaged] exited app=${child.app} pid=${child.child.pid ?? "unknown"} code=${child.child.exitCode ?? "unknown"} signal=${child.child.signalCode ?? "none"}`); - await child.logHandle.close().catch(() => undefined); } export async function registerPackagedWebUrl( - daemonIpcPath: string, + daemonStamp: SidecarStamp, webUrl: string, + invoke: typeof invokeSidecar = invokeSidecar, ): Promise { - const result = await requestJsonIpc( - daemonIpcPath, - { - input: { url: webUrl }, - type: SIDECAR_MESSAGES.REGISTER_WEB_URL, - }, + const result = await invoke( + daemonStamp, + SIDECAR_MESSAGES.REGISTER_WEB_URL, + { url: webUrl }, { timeoutMs: 1_200 }, ); if (result.accepted !== true) { @@ -1061,6 +885,9 @@ export async function startPackagedSidecars( onPhase?: (phase: "daemon-spawning" | "daemon-ready" | "web-spawning" | "web-ready") => void; }, ): Promise { + const channel = releaseChannelFromVersion(options.appVersion) + ?? releaseChannelFromNamespace(runtime.namespace, "default") + ?? "stable"; await mkdir(paths.namespaceRoot, { recursive: true }); await mkdir(paths.cacheRoot, { recursive: true }); await mkdir(paths.dataRoot, { recursive: true }); @@ -1101,6 +928,7 @@ export async function startPackagedSidecars( options.onPhase?.("daemon-spawning"); const daemon = await spawnSidecarChild({ app: APP_KEYS.DAEMON, + channel, entryPath: daemonSidecarEntry, env: buildPackagedDaemonSpawnEnv(paths, { appVersion: options.appVersion, @@ -1136,7 +964,13 @@ export async function startPackagedSidecars( { log: prewarmLog }, ); const daemonStatus = await waitForStatus( - daemon.ipcPath, + { + label: APP_KEYS.DAEMON, + read: async (timeoutMs) => await getSidecarStatus(daemon.stamp, { + generationPid: daemon.generation.process.pid, + timeoutMs, + }), + }, (status) => status.url != null, resolveDaemonStatusTimeoutMs(), // Race the IPC polling against the daemon child's exit. Without @@ -1162,9 +996,10 @@ export async function startPackagedSidecars( closeChild: closeManagedChild, hasExited: (web) => web.child.exitCode !== null || web.child.signalCode !== null, onExit: (web, listener) => web.child.once("exit", listener), - registerUrl: async (url) => await registerPackagedWebUrl(daemon.ipcPath, url), + registerUrl: async (url) => await registerPackagedWebUrl(daemon.stamp, url), spawn: async () => await spawnSidecarChild({ app: APP_KEYS.WEB, + channel, entryPath: webSidecarEntry, env: { [SIDECAR_ENV.DAEMON_PORT]: daemonPort, @@ -1179,7 +1014,13 @@ export async function startPackagedSidecars( runtime, }), waitUntilReady: async (web) => await waitForStatus( - web.ipcPath, + { + label: APP_KEYS.WEB, + read: async (timeoutMs) => await getSidecarStatus(web.stamp, { + generationPid: web.generation.process.pid, + timeoutMs, + }), + }, (candidate) => candidate.url != null, // Web has no legacy-migration path, so it uses the plain platform // baseline (still widened on win32, where AV scanning can also slow the @@ -1202,12 +1043,20 @@ export async function startPackagedSidecars( web: webStatus, currentWebUrl: supervisor.currentUrl, async close() { - await supervisor.close(); + const closeErrors: unknown[] = []; + await supervisor.close().catch((error: unknown) => { + closeErrors.push(error); + console.error("failed to close packaged web sidecar", error); + }); for (const child of [...children].reverse()) { await closeManagedChild(child).catch((error: unknown) => { + closeErrors.push(error); console.error(`failed to close packaged ${child.app} sidecar`, error); }); } + if (closeErrors.length > 0) { + throw new AggregateError(closeErrors, "failed to close packaged sidecars"); + } }, }; } catch (error) { diff --git a/apps/packaged/tests/desktop-sidecar-boundary.test.ts b/apps/packaged/tests/desktop-sidecar-boundary.test.ts new file mode 100644 index 0000000000..ddca4a9542 --- /dev/null +++ b/apps/packaged/tests/desktop-sidecar-boundary.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const here = dirname(fileURLToPath(import.meta.url)); + +function source(relativePath: string): string { + return readFileSync(join(here, relativePath), "utf8").replace(/\r\n?/g, "\n"); +} + +describe("packaged desktop sidecar boundary", () => { + it("forwards every desktop business action", () => { + const packagedMain = source("../src/index.ts"); + const desktopMain = source("../../desktop/src/main/index.ts"); + const actions = (source: string, startAt = 0) => { + const handlersStart = source.indexOf("handlers: Object.fromEntries([", startAt); + const lifecycleStart = source.indexOf("lifecycle:", handlersStart); + expect(handlersStart).toBeGreaterThanOrEqual(0); + expect(lifecycleStart).toBeGreaterThan(handlersStart); + return source.slice(handlersStart, lifecycleStart).match(/SIDECAR_MESSAGES\.[A-Z_]+/g); + }; + + expect(actions(packagedMain)).toEqual( + actions(desktopMain, desktopMain.indexOf("if (isDirectEntry())")), + ); + }); + + it("turns desktop auth transport failures into a false registration result", () => { + const main = source("../src/index.ts"); + const registrationStart = main.indexOf("registerDesktopAuth: async (secret) => {"); + const registrationEnd = main.indexOf("windowTitle:", registrationStart); + expect(registrationStart).toBeGreaterThanOrEqual(0); + expect(registrationEnd).toBeGreaterThan(registrationStart); + const registration = main.slice(registrationStart, registrationEnd); + expect(registration).toContain("try {"); + expect(registration).toContain("catch {\n return false;"); + }); +}); diff --git a/apps/packaged/tests/headless-runtime.test.ts b/apps/packaged/tests/headless-runtime.test.ts index c64a9eec86..22dcafa207 100644 --- a/apps/packaged/tests/headless-runtime.test.ts +++ b/apps/packaged/tests/headless-runtime.test.ts @@ -4,7 +4,9 @@ import { acquirePackagedHeadlessStartup, parsePackagedHeadlessRequest, resolvePackagedMcpBootstrapLaunch, + runPackagedMcpActionAgainstExistingDaemon, } from "../src/headless-runtime.js"; +import { APP_KEYS, SIDECAR_SOURCES } from "@open-design/sidecar-proto"; describe("parsePackagedHeadlessRequest", () => { it("accepts a headless Codex MCP install request", () => { @@ -58,20 +60,72 @@ describe("resolvePackagedMcpBootstrapLaunch", () => { }); }); +describe("runPackagedMcpActionAgainstExistingDaemon", () => { + const launchStamp = { + app: APP_KEYS.DESKTOP, + channel: "beta", + mode: "headless", + namespace: "release-beta", + source: "packaged", + } as const; + + it("installs through a healthy existing headless daemon without bootstrapping", async () => { + const installMcp = vi.fn(async () => undefined); + const getStatus = vi.fn(async () => ({ + state: "running", + url: "http://127.0.0.1:7457", + })); + + await expect(runPackagedMcpActionAgainstExistingDaemon( + { headless: true, mcpInstallAgent: "codex" }, + launchStamp, + { getStatus: getStatus as never, installMcp }, + )).resolves.toBe(true); + expect(getStatus).toHaveBeenCalledWith( + { ...launchStamp, app: APP_KEYS.DAEMON }, + { timeoutMs: 350 }, + ); + expect(installMcp).toHaveBeenCalledWith("http://127.0.0.1:7457"); + }); + + it("leaves bootstrap to the caller when no healthy daemon exists", async () => { + const installMcp = vi.fn(async () => undefined); + await expect(runPackagedMcpActionAgainstExistingDaemon( + { headless: true, mcpInstallAgent: "codex" }, + launchStamp, + { + getStatus: vi.fn(async () => ({ state: "stopped", url: "http://127.0.0.1:7457" })) as never, + installMcp, + }, + )).resolves.toBe(false); + expect(installMcp).not.toHaveBeenCalled(); + }); + + it("finds a packaged-source daemon for a tools-pack request", async () => { + const installMcp = vi.fn(async () => undefined); + const getStatus = vi.fn(async (candidate: { source: string }) => candidate.source === SIDECAR_SOURCES.PACKAGED + ? { state: "running", url: "http://127.0.0.1:7457" } + : Promise.reject(new Error("missing endpoint"))); + await expect(runPackagedMcpActionAgainstExistingDaemon( + { headless: true, mcpInstallAgent: "codex" }, + { ...launchStamp, source: SIDECAR_SOURCES.TOOLS_PACK }, + { getStatus: getStatus as never, installMcp }, + )).resolves.toBe(true); + expect(getStatus.mock.calls.map(([candidate]) => candidate.source)).toEqual([ + SIDECAR_SOURCES.TOOLS_PACK, + SIDECAR_SOURCES.PACKAGED, + ]); + expect(installMcp).toHaveBeenCalledWith("http://127.0.0.1:7457"); + }); +}); + describe("acquirePackagedHeadlessStartup", () => { - function createDependencies(failAt: "mcp" | "web-identity") { + function createDependencies(failAt: "mcp" | "none") { const closed: string[] = []; - const exit = vi.fn(); return { closed, dependencies: { confirmRuntime: vi.fn(async () => undefined), - createIpcServer: vi.fn(async () => ({ - close: async () => { - closed.push("ipc"); - }, - })), - exit, installMcp: vi.fn(async () => { if (failAt === "mcp") throw new Error("MCP install failed"); }), @@ -87,41 +141,24 @@ describe("acquirePackagedHeadlessStartup", () => { }, web: { state: "running" as const, url: "http://127.0.0.1:7456" }, })), - writeIdentity: vi.fn(async () => ({ - close: async () => { - closed.push("identity"); - }, - identity: {} as never, - })), - writeWebIdentity: vi.fn(async () => { - if (failAt === "web-identity") { - throw new Error("web identity write failed"); - } - }), }, - exit, }; } - it("closes identity and sidecars when MCP installation fails", async () => { - const { closed, dependencies, exit } = createDependencies("mcp"); + it("closes sidecars when MCP installation fails", async () => { + const { closed, dependencies } = createDependencies("mcp"); await expect(acquirePackagedHeadlessStartup(dependencies)).rejects.toThrow( "MCP install failed", ); - expect(closed).toEqual(["sidecars", "identity"]); - expect(exit).not.toHaveBeenCalled(); + expect(closed).toEqual(["sidecars"]); }); - it("closes IPC, sidecars, and identity when identity publication fails", async () => { - const { closed, dependencies, exit } = createDependencies("web-identity"); - - await expect(acquirePackagedHeadlessStartup(dependencies)).rejects.toThrow( - "web identity write failed", - ); - - expect(closed).toEqual(["ipc", "sidecars", "identity"]); - expect(exit).not.toHaveBeenCalled(); + it("returns a shutdown handle that closes sidecars", async () => { + const { closed, dependencies } = createDependencies("none"); + const handle = await acquirePackagedHeadlessStartup(dependencies); + await handle.shutdown(); + expect(closed).toEqual(["sidecars"]); }); }); diff --git a/apps/packaged/tests/identity.test.ts b/apps/packaged/tests/identity.test.ts deleted file mode 100644 index 906f9e580b..0000000000 --- a/apps/packaged/tests/identity.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { - APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, - SIDECAR_MODES, - SIDECAR_SOURCES, -} from "@open-design/sidecar-proto"; -import { resolveAppIpcPath } from "@open-design/sidecar"; -import { describe, expect, it } from "vitest"; - -import { writePackagedDesktopIdentity } from "../src/identity.js"; -import type { PackagedNamespacePaths } from "../src/paths.js"; - -async function pathExists(path: string): Promise { - try { - await readFile(path); - return true; - } catch { - return false; - } -} - -function fakePaths(root: string): PackagedNamespacePaths { - return { - cacheRoot: join(root, "cache"), - dataRoot: join(root, "data"), - desktopIdentityPath: join(root, "runtime", "desktop-root.json"), - desktopLogPath: join(root, "logs", "desktop", "latest.log"), - desktopLogsRoot: join(root, "logs", "desktop"), - electronSessionDataRoot: join(root, "user-data", "session"), - electronUserDataRoot: join(root, "user-data"), - headlessIdentityPath: join(root, "runtime", "headless-root.json"), - installationRoot: join(root, ".."), - installerObservationRoot: join(root, "data", "observations", "installer"), - logsRoot: join(root, "logs"), - namespaceRoot: root, - resourceRoot: join(root, "resources"), - runtimeRoot: join(root, "runtime"), - updateRoot: join(root, "updates"), - webIdentityPath: join(root, "runtime", "web-root.json"), - }; -} - -describe("packaged identity markers", () => { - it("can write and close the desktop identity shape at the headless marker path", async () => { - const root = join(tmpdir(), `od-packaged-identity-${process.pid}-${Date.now()}`); - const paths = fakePaths(root); - const stamp = { - app: APP_KEYS.DESKTOP, - ipc: resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: "default", - }), - mode: SIDECAR_MODES.RUNTIME, - namespace: "default", - source: SIDECAR_SOURCES.PACKAGED, - }; - - try { - const handle = await writePackagedDesktopIdentity({ - identityPath: paths.headlessIdentityPath, - paths, - stamp, - }); - - expect(await pathExists(paths.headlessIdentityPath)).toBe(true); - expect(await pathExists(paths.desktopIdentityPath)).toBe(false); - - await handle.close(); - expect(await pathExists(paths.headlessIdentityPath)).toBe(false); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); -}); diff --git a/apps/packaged/tests/launch.test.ts b/apps/packaged/tests/launch.test.ts index b1cf0eb1f0..8a9dfcc296 100644 --- a/apps/packaged/tests/launch.test.ts +++ b/apps/packaged/tests/launch.test.ts @@ -23,12 +23,10 @@ function fakePaths(root: string): PackagedNamespacePaths { return { cacheRoot: join(root, "cache"), dataRoot: join(root, "data"), - desktopIdentityPath: join(root, "runtime", "desktop-root.json"), desktopLogPath: join(root, "logs", "desktop", "latest.log"), desktopLogsRoot: join(root, "logs", "desktop"), electronSessionDataRoot: join(root, "user-data", "session"), electronUserDataRoot: join(root, "user-data"), - headlessIdentityPath: join(root, "runtime", "headless-root.json"), installationRoot: root, installerObservationRoot: join(root, "data", "observations", "installer"), logsRoot: join(root, "logs"), @@ -36,7 +34,6 @@ function fakePaths(root: string): PackagedNamespacePaths { resourceRoot: join(root, "resources", "open-design"), runtimeRoot: join(root, "runtime"), updateRoot: join(root, "updates"), - webIdentityPath: join(root, "runtime", "web-root.json"), }; } @@ -138,10 +135,12 @@ describe("claimPackagedSingleInstanceLock", () => { const show = vi.fn(); try { - await expect(inspectExistingDesktopForLauncher("release-beta-win", { + await expect(inspectExistingDesktopForLauncher({ + app: "desktop", channel: "beta", mode: "runtime", namespace: "release-beta-win", source: "packaged", + }, { deeplinkUrl, paths: fakePaths(root), - requestIpc: vi.fn(async () => { + getStatus: vi.fn(async () => { throw new Error("desktop IPC is not ready"); }), })).resolves.toEqual({ action: "continue", reason: "inspect-failed" }); diff --git a/apps/packaged/tests/launcher-after-quit.test.ts b/apps/packaged/tests/launcher-after-quit.test.ts index a89cadd094..af170a30b4 100644 --- a/apps/packaged/tests/launcher-after-quit.test.ts +++ b/apps/packaged/tests/launcher-after-quit.test.ts @@ -2,433 +2,213 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { SIDECAR_MESSAGES } from "@open-design/sidecar-proto"; +import type { SidecarStamp, SidecarStopResult } from "@open-design/sidecar"; +import { APP_KEYS, SIDECAR_SOURCES } from "@open-design/sidecar-proto"; import type { StopProcessesResult, stopProcesses, waitForProcessExit } from "@open-design/platform"; +import { describe, expect, it, vi } from "vitest"; -import { - exitPackagedLauncherForExistingDesktop, - inspectExistingDesktopForLauncher, - waitForLauncherAfterQuit, -} from "../src/launcher-after-quit.js"; +import { exitPackagedLauncherForExistingDesktop, inspectExistingDesktopForLauncher, waitForLauncherAfterQuit } from "../src/launcher-after-quit.js"; import type { PackagedNamespacePaths } from "../src/paths.js"; -/** Build a `stopProcesses` result without signalling any real PID. */ -function fakeStopResult(pid: number, opts: { forced?: boolean; survived?: boolean } = {}): StopProcessesResult { - return { - alreadyStopped: false, - forcedPids: opts.forced ? [pid] : [], - matchedPids: [pid], - remainingPids: opts.survived ? [pid] : [], - stoppedPids: opts.survived ? [] : [pid], - }; -} - -const neverExits = (async () => false) as typeof waitForProcessExit; - -function fakeStop(pid: number, opts: { forced?: boolean; survived?: boolean } = {}) { - return vi.fn(async () => fakeStopResult(pid, opts)) as unknown as typeof stopProcesses; +function stamp(namespace = "release-prerelease"): SidecarStamp { + return { app: APP_KEYS.DESKTOP, channel: "prerelease", mode: "runtime", namespace, source: "packaged" }; } function fakePaths(root: string): PackagedNamespacePaths { return { - cacheRoot: join(root, "cache"), - dataRoot: join(root, "data"), - desktopIdentityPath: join(root, "runtime", "desktop-root.json"), - desktopLogPath: join(root, "logs", "desktop", "latest.log"), - desktopLogsRoot: join(root, "logs", "desktop"), - electronSessionDataRoot: join(root, "user-data", "session"), - electronUserDataRoot: join(root, "user-data"), - headlessIdentityPath: join(root, "runtime", "headless-root.json"), - installationRoot: root, - installerObservationRoot: join(root, "data", "observations", "installer"), - logsRoot: join(root, "logs"), - namespaceRoot: root, - resourceRoot: join(root, "resources", "open-design"), - runtimeRoot: join(root, "runtime"), - updateRoot: join(root, "updates"), - webIdentityPath: join(root, "runtime", "web-root.json"), + cacheRoot: join(root, "cache"), dataRoot: join(root, "data"), desktopLogPath: join(root, "logs", "desktop", "latest.log"), + desktopLogsRoot: join(root, "logs", "desktop"), electronSessionDataRoot: join(root, "user-data", "session"), + electronUserDataRoot: join(root, "user-data"), installationRoot: root, + installerObservationRoot: join(root, "data", "observations", "installer"), logsRoot: join(root, "logs"), namespaceRoot: root, + resourceRoot: join(root, "resources", "open-design"), runtimeRoot: join(root, "runtime"), updateRoot: join(root, "updates"), }; } +function processStop(pid: number, survived = false): StopProcessesResult { + return { alreadyStopped: false, forcedPids: [], matchedPids: [pid], remainingPids: survived ? [pid] : [], stoppedPids: survived ? [] : [pid] }; +} + +function sidecarStop(pid = 1234): SidecarStopResult { + return { ...processStop(pid), gracefulAccepted: true }; +} + describe("waitForLauncherAfterQuit", () => { - it("logs and returns when the old pid is already gone", async () => { + it("logs a completed updater wait", async () => { const root = await mkdtemp(join(tmpdir(), "od-launcher-after-quit-")); try { - const paths = fakePaths(root); - - const exited = await waitForLauncherAfterQuit({ targetPid: 999999, timeoutMs: 1000 }, paths); - - expect(exited).toBe(true); - const log = await readFile(join(root, "logs", "launcher", "after-quit.log"), "utf8"); - expect(log).toContain("armed targetPid=999999 timeoutMs=1000"); - expect(log).toContain("observed-exit targetPid=999999"); + const result = await waitForLauncherAfterQuit({ targetPid: 999999, timeoutMs: 1000 }, fakePaths(root), console, { + waitForExit: (async () => true) as typeof waitForProcessExit, + }); + expect(result).toBe(true); + expect(await readFile(join(root, "logs", "launcher", "after-quit.log"), "utf8")).toContain("observed-exit targetPid=999999"); } finally { await rm(root, { force: true, recursive: true }); } }); - it("logs and warns on timeout", async () => { - const root = await mkdtemp(join(tmpdir(), "od-launcher-after-quit-timeout-")); - const logger = { warn: vi.fn() }; - const stop = fakeStop(4242, { survived: true }); + it("force-stops a pid after the updater grace expires", async () => { + const root = await mkdtemp(join(tmpdir(), "od-launcher-timeout-")); + const stop = vi.fn(async () => processStop(4242)) as unknown as typeof stopProcesses; try { - const paths = fakePaths(root); - - const exited = await waitForLauncherAfterQuit({ targetPid: 4242, timeoutMs: 1 }, paths, logger, { - stopProcesses: stop, - waitForExit: neverExits, - }); - - expect(exited).toBe(false); - const log = await readFile(join(root, "logs", "launcher", "after-quit.log"), "utf8"); - expect(log).toContain("armed targetPid=4242 timeoutMs=1"); - expect(log).toContain("timed-out targetPid=4242; forcing stop"); - expect(log).toContain("force-stop after-quit-timeout pid=4242 outcome=survived"); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("timed-out targetPid=4242")); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - it("force-stops the lingering pid when the old process never exits", async () => { - const root = await mkdtemp(join(tmpdir(), "od-launcher-after-quit-forcekill-")); - const stop = fakeStop(4242); - try { - const paths = fakePaths(root); - - const exited = await waitForLauncherAfterQuit({ targetPid: 4242, timeoutMs: 5 }, paths, { warn: vi.fn() }, { - stopProcesses: stop, - waitForExit: neverExits, - }); - - expect(exited).toBe(true); + await expect(waitForLauncherAfterQuit({ targetPid: 4242, timeoutMs: 1 }, fakePaths(root), console, { + stopProcesses: stop, waitForExit: (async () => false) as typeof waitForProcessExit, + })).resolves.toBe(true); expect(stop).toHaveBeenCalledWith([4242]); - const log = await readFile(join(root, "logs", "launcher", "after-quit.log"), "utf8"); - expect(log).toContain("timed-out targetPid=4242; forcing stop"); - expect(log).toContain("force-stop after-quit-timeout pid=4242 outcome=sigterm"); } finally { await rm(root, { force: true, recursive: true }); } }); }); -describe("exitPackagedLauncherForExistingDesktop", () => { - it.each(["existing-focused", "existing-focus-failed"] as const)( - "terminates the duplicate outer after %s", - (reason) => { - const exit = vi.fn(); - - expect(exitPackagedLauncherForExistingDesktop({ action: "exit", reason }, exit)).toBe(true); - expect(exit).toHaveBeenCalledExactlyOnceWith(0); - }, - ); - - it("keeps the outer alive when startup must continue", () => { - const exit = vi.fn(); - - expect(exitPackagedLauncherForExistingDesktop( - { action: "continue", reason: "superseded-version" }, - exit, - )).toBe(false); - expect(exit).not.toHaveBeenCalled(); - }); -}); - describe("inspectExistingDesktopForLauncher", () => { - it("restarts a healthy older desktop before a newer installed package continues", async () => { - const root = await mkdtemp(join(tmpdir(), "od-launcher-inspect-superseded-")); - const requests: unknown[] = []; + it("continues when no stamped desktop answers", async () => { + const root = await mkdtemp(join(tmpdir(), "od-launcher-inspect-")); try { - const paths = fakePaths(root); - - const result = await inspectExistingDesktopForLauncher("release-prerelease", { - incomingVersion: "0.16.0-prerelease.1", - paths, - requestIpc: (async (ipcPath: string, message: unknown) => { - requests.push(message); - if ((message as { type?: string }).type === SIDECAR_MESSAGES.STATUS) { - if (ipcPath.includes("daemon") || ipcPath.includes("web")) { - return { pid: 2345, state: "running", updatedAt: new Date().toISOString(), url: "http://127.0.0.1:1234" }; - } - return { - pid: 1234, - state: "running", - update: { currentVersion: "0.15.1-prerelease.15" }, - updatedAt: new Date().toISOString(), - }; - } - return { accepted: true }; - }) as typeof import("@open-design/sidecar").requestJsonIpc, - waitForExit: (async (pid: number) => pid === 1234) as typeof import("@open-design/platform").waitForProcessExit, - }); - - expect(result).toEqual({ action: "continue", reason: "superseded-version" }); - expect(requests).toEqual([ - { type: SIDECAR_MESSAGES.STATUS }, - { type: SIDECAR_MESSAGES.STATUS }, - { type: SIDECAR_MESSAGES.STATUS }, - { type: SIDECAR_MESSAGES.SHUTDOWN }, - ]); - const log = await readFile(join(root, "logs", "launcher", "after-quit.log"), "utf8"); - expect(log).toContain( - "action=restart reason=superseded-version incomingVersion=0.16.0-prerelease.1 existingVersion=0.15.1-prerelease.15 pid=1234", - ); - expect(log).toContain("shutdown=exited reason=superseded-version pid=1234"); + await expect(inspectExistingDesktopForLauncher(stamp(), { + getStatus: vi.fn(async () => { throw new Error("not running"); }), paths: fakePaths(root), + })).resolves.toEqual({ action: "continue", reason: "inspect-failed" }); } finally { await rm(root, { force: true, recursive: true }); } }); - it.each([ - { existingVersion: "0.16.0-prerelease.1", incomingVersion: "0.16.0-prerelease.1", label: "the same version" }, - { existingVersion: "0.16.0-prerelease.2", incomingVersion: "0.16.0-prerelease.1", label: "a newer version" }, - { existingVersion: undefined, incomingVersion: "0.16.0-prerelease.1", label: "an unknown historical version" }, - ])("focuses an existing namespace desktop running $label", async ({ existingVersion, incomingVersion }) => { - const root = await mkdtemp(join(tmpdir(), "od-launcher-inspect-focus-")); - const requests: Array<{ message: unknown; timeoutMs?: number }> = []; + it("focuses a healthy desktop through the business invoke boundary", async () => { + const root = await mkdtemp(join(tmpdir(), "od-launcher-focus-")); + const invoke = vi.fn(async () => ({ accepted: true })); try { - const paths = fakePaths(root); - - const result = await inspectExistingDesktopForLauncher("release-beta-win", { - deeplinkUrl: "opendesign://workspace/invite/continue?nonce=hot-delivery", - incomingVersion, - paths, - requestIpc: (async (ipcPath: string, message: unknown, options?: { timeoutMs?: number }) => { - requests.push({ message, timeoutMs: options?.timeoutMs }); - if ((message as { type?: string }).type === SIDECAR_MESSAGES.STATUS) { - if (ipcPath.includes("daemon") || ipcPath.includes("web")) { - return { pid: 2345, state: "running", updatedAt: new Date().toISOString(), url: "http://127.0.0.1:1234" }; - } - return { - pid: 1234, - state: "running", - ...(existingVersion == null ? {} : { update: { currentVersion: existingVersion } }), - updatedAt: new Date().toISOString(), - }; - } - return { accepted: true }; - }) as typeof import("@open-design/sidecar").requestJsonIpc, - }); - - expect(result).toEqual({ action: "exit", reason: "existing-focused" }); - expect(requests.map((request) => request.message)).toEqual([ - { type: SIDECAR_MESSAGES.STATUS }, - { type: SIDECAR_MESSAGES.STATUS }, - { type: SIDECAR_MESSAGES.STATUS }, - { - input: { deeplinkUrl: "opendesign://workspace/invite/continue?nonce=hot-delivery" }, - type: SIDECAR_MESSAGES.SHOW, - }, - ]); - const log = await readFile(join(root, "logs", "launcher", "after-quit.log"), "utf8"); - expect(log).toContain("inspect-found-existing namespace=release-beta-win focus=accepted"); + await expect(inspectExistingDesktopForLauncher(stamp(), { + deeplinkUrl: "opendesign://invite", + getStatus: vi.fn(async (target: SidecarStamp) => target.app === APP_KEYS.DESKTOP + ? { pid: 1234, state: "running", updatedAt: new Date().toISOString(), windowVisible: true } + : { state: "running", url: "http://127.0.0.1:1234" }) as never, + invoke: invoke as never, paths: fakePaths(root), + })).resolves.toEqual({ action: "exit", reason: "existing-focused" }); + expect(invoke).toHaveBeenCalledWith(stamp(), "show", { deeplinkUrl: "opendesign://invite" }, { timeoutMs: 800 }); } finally { await rm(root, { force: true, recursive: true }); } }); - it("replaces a healthy headless owner before opening the desktop window", async () => { - const root = await mkdtemp(join(tmpdir(), "od-launcher-inspect-headless-")); - const requests: unknown[] = []; + it("terminally stops a stale peer set before continuing", async () => { + const root = await mkdtemp(join(tmpdir(), "od-launcher-stale-")); + const stop = vi.fn(async () => sidecarStop()); try { - const paths = fakePaths(root); - - const result = await inspectExistingDesktopForLauncher("release-beta", { - incomingVersion: "0.16.0-beta.1", - paths, - requestIpc: (async (ipcPath: string, message: unknown) => { - requests.push(message); - if ((message as { type?: string }).type === SIDECAR_MESSAGES.STATUS) { - if (ipcPath.includes("daemon") || ipcPath.includes("web")) { - return { - pid: 2345, - state: "running", - updatedAt: new Date().toISOString(), - url: "http://127.0.0.1:1234", - }; - } - return { - pid: 1234, - state: "running", - update: { currentVersion: "0.16.0-beta.1" }, - updatedAt: new Date().toISOString(), - windowVisible: false, - }; - } - return { accepted: true }; - }) as typeof import("@open-design/sidecar").requestJsonIpc, - waitForExit: (async (pid: number) => pid === 1234) as typeof import("@open-design/platform").waitForProcessExit, - }); - - expect(result).toEqual({ action: "continue", reason: "headless-owner" }); - expect(requests).toEqual([ - { type: SIDECAR_MESSAGES.STATUS }, - { type: SIDECAR_MESSAGES.STATUS }, - { type: SIDECAR_MESSAGES.STATUS }, - { type: SIDECAR_MESSAGES.SHUTDOWN }, - ]); - const log = await readFile(join(root, "logs", "launcher", "after-quit.log"), "utf8"); - expect(log).toContain("action=restart reason=headless-owner pid=1234"); - expect(log).toContain("shutdown=exited reason=headless-owner pid=1234"); + await expect(inspectExistingDesktopForLauncher(stamp(), { + getStatus: vi.fn(async (target: SidecarStamp) => { + if (target.app === APP_KEYS.WEB) throw new Error("stale"); + return target.app === APP_KEYS.DESKTOP + ? { pid: 1234, state: "running", updatedAt: new Date().toISOString(), windowVisible: true } + : { state: "running", url: "http://127.0.0.1:1234" }; + }) as never, + paths: fakePaths(root), stopSidecar: stop, + })).resolves.toEqual({ action: "continue", reason: "stale-sidecar" }); + expect(stop).toHaveBeenCalledWith(stamp()); } finally { await rm(root, { force: true, recursive: true }); } }); - it("continues when inspect cannot reach desktop", async () => { - const root = await mkdtemp(join(tmpdir(), "od-launcher-inspect-failed-")); + it("discovers a headless owner and checks its daemon and web peers in headless mode", async () => { + const root = await mkdtemp(join(tmpdir(), "od-launcher-headless-owner-")); + const getStatus = vi.fn(async (target: SidecarStamp) => { + if (target.app === APP_KEYS.DESKTOP && target.mode === "runtime") throw new Error("runtime desktop absent"); + if (target.app === APP_KEYS.DESKTOP && target.mode === "headless") { + return { pid: 4321, state: "running", updatedAt: new Date().toISOString(), windowVisible: false }; + } + if (target.mode !== "headless") throw new Error("peer did not use the desktop owner mode"); + return { state: "running", url: "http://127.0.0.1:1234" }; + }); + const stop = vi.fn(async () => sidecarStop(4321)); try { - const paths = fakePaths(root); - - const result = await inspectExistingDesktopForLauncher("release-beta-win", { - paths, - requestIpc: (async () => { - throw new Error("pipe closed"); - }) as typeof import("@open-design/sidecar").requestJsonIpc, - }); - - expect(result).toEqual({ action: "continue", reason: "inspect-failed" }); - const log = await readFile(join(root, "logs", "launcher", "after-quit.log"), "utf8"); - expect(log).toContain("inspect-unavailable namespace=release-beta-win action=continue error=pipe closed"); + await expect(inspectExistingDesktopForLauncher(stamp(), { + getStatus: getStatus as never, + paths: fakePaths(root), + stopSidecar: stop, + })).resolves.toEqual({ action: "continue", reason: "headless-owner" }); + expect(getStatus).toHaveBeenCalledWith({ ...stamp(), mode: "headless" }, { timeoutMs: 350 }); + expect(getStatus).toHaveBeenCalledWith({ ...stamp(), app: APP_KEYS.DAEMON, mode: "headless" }, { timeoutMs: 350 }); + expect(getStatus).toHaveBeenCalledWith({ ...stamp(), app: APP_KEYS.WEB, mode: "headless" }, { timeoutMs: 350 }); + expect(stop).toHaveBeenCalledWith({ ...stamp(), mode: "headless" }); } finally { await rm(root, { force: true, recursive: true }); } }); - it("exits without launching a duplicate when focus fails", async () => { - const root = await mkdtemp(join(tmpdir(), "od-launcher-inspect-focus-failed-")); - const logger = { warn: vi.fn() }; + it("reuses a healthy same-mode headless generation instead of restarting it", async () => { + const root = await mkdtemp(join(tmpdir(), "od-launcher-existing-headless-")); + const headlessStamp = { ...stamp(), mode: "headless" as const }; + const stop = vi.fn(async () => sidecarStop(4321)); try { - const paths = fakePaths(root); - - const result = await inspectExistingDesktopForLauncher("release-beta-win", { - logger, - paths, - requestIpc: (async (ipcPath: string, message: unknown) => { - if ((message as { type?: string }).type === SIDECAR_MESSAGES.STATUS) { - if (ipcPath.includes("daemon") || ipcPath.includes("web")) { - return { pid: 2345, state: "running", updatedAt: new Date().toISOString(), url: "http://127.0.0.1:1234" }; - } - return { pid: 1234, state: "running", updatedAt: new Date().toISOString() }; - } - throw new Error("show rejected"); - }) as typeof import("@open-design/sidecar").requestJsonIpc, - }); - - expect(result).toEqual({ action: "exit", reason: "existing-focus-failed" }); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("focus=failed")); + await expect(inspectExistingDesktopForLauncher(headlessStamp, { + getStatus: vi.fn(async (target: SidecarStamp) => { + if (target.mode !== "headless") throw new Error("generation peer mode mismatch"); + return target.app === APP_KEYS.DESKTOP + ? { pid: 4321, state: "running", updatedAt: new Date().toISOString(), windowVisible: false } + : { state: "running", url: "http://127.0.0.1:1234" }; + }) as never, + modes: ["headless"], + paths: fakePaths(root), + stopSidecar: stop, + })).resolves.toEqual({ action: "exit", reason: "existing-headless" }); + expect(stop).not.toHaveBeenCalled(); } finally { await rm(root, { force: true, recursive: true }); } }); - it("restarts instead of focusing when an existing desktop has a stale web sidecar", async () => { - const root = await mkdtemp(join(tmpdir(), "od-launcher-inspect-stale-web-")); - const requests: unknown[] = []; + it("finds and reuses a packaged headless owner for a tools-pack headless request", async () => { + const root = await mkdtemp(join(tmpdir(), "od-launcher-cross-source-headless-")); + const requestedStamp = { ...stamp(), mode: "headless", source: SIDECAR_SOURCES.TOOLS_PACK } satisfies SidecarStamp; + const ownerStamp = { ...requestedStamp, source: SIDECAR_SOURCES.PACKAGED }; + const getStatus = vi.fn(async (target: SidecarStamp) => { + if (target.source === SIDECAR_SOURCES.TOOLS_PACK) throw new Error("tools-pack owner absent"); + return target.app === APP_KEYS.DESKTOP + ? { pid: 4321, state: "running", updatedAt: new Date().toISOString(), windowVisible: false } + : { state: "running", url: "http://127.0.0.1:1234" }; + }); + const stop = vi.fn(async () => sidecarStop(4321)); try { - const paths = fakePaths(root); - - const result = await inspectExistingDesktopForLauncher("release-beta-win", { - paths, - requestIpc: (async (ipcPath: string, message: unknown) => { - requests.push(message); - if ((message as { type?: string }).type === SIDECAR_MESSAGES.STATUS) { - if (ipcPath.includes("daemon")) { - return { pid: 2345, state: "running", updatedAt: new Date().toISOString(), url: "http://127.0.0.1:1234" }; - } - if (ipcPath.includes("web")) { - throw new Error("web pipe missing"); - } - return { pid: 1234, state: "running", updatedAt: new Date().toISOString() }; - } - return { accepted: true }; - }) as typeof import("@open-design/sidecar").requestJsonIpc, - waitForExit: (async (pid: number) => pid === 1234) as typeof import("@open-design/platform").waitForProcessExit, - }); - - expect(result).toEqual({ action: "continue", reason: "stale-sidecar" }); - expect(requests).toEqual([ - { type: SIDECAR_MESSAGES.STATUS }, - { type: SIDECAR_MESSAGES.STATUS }, - { type: SIDECAR_MESSAGES.STATUS }, - { type: SIDECAR_MESSAGES.SHUTDOWN }, - ]); - const log = await readFile(join(root, "logs", "launcher", "after-quit.log"), "utf8"); - expect(log).toContain("action=restart reason=stale-sidecar apps=web pid=1234"); - expect(log).toContain("shutdown=exited reason=stale-sidecar pid=1234"); + await expect(inspectExistingDesktopForLauncher(requestedStamp, { + getStatus: getStatus as never, + modes: ["headless"], + paths: fakePaths(root), + stopSidecar: stop, + })).resolves.toEqual({ action: "exit", reason: "existing-headless" }); + expect(getStatus).toHaveBeenCalledWith(ownerStamp, { timeoutMs: 350 }); + expect(getStatus).toHaveBeenCalledWith({ ...ownerStamp, app: APP_KEYS.DAEMON }, { timeoutMs: 350 }); + expect(getStatus).toHaveBeenCalledWith({ ...ownerStamp, app: APP_KEYS.WEB }, { timeoutMs: 350 }); + expect(stop).not.toHaveBeenCalled(); } finally { await rm(root, { force: true, recursive: true }); } }); - it("force-stops a skewed desktop that ignores SHUTDOWN, then restarts", async () => { - const root = await mkdtemp(join(tmpdir(), "od-launcher-inspect-stale-forcekill-")); - const stop = fakeStop(1234, { forced: true }); + it("finds and focuses a packaged GUI owner for a tools-pack GUI request", async () => { + const root = await mkdtemp(join(tmpdir(), "od-launcher-cross-source-gui-")); + const requestedStamp = { ...stamp(), source: SIDECAR_SOURCES.TOOLS_PACK } satisfies SidecarStamp; + const ownerStamp = { ...requestedStamp, source: SIDECAR_SOURCES.PACKAGED }; + const invoke = vi.fn(async () => ({ accepted: true })); try { - const paths = fakePaths(root); - - const result = await inspectExistingDesktopForLauncher("release-beta-win", { - paths, - requestIpc: (async (ipcPath: string, message: unknown) => { - if ((message as { type?: string }).type === SIDECAR_MESSAGES.STATUS) { - if (ipcPath.includes("daemon")) { - return { pid: 2345, state: "running", updatedAt: new Date().toISOString(), url: "http://127.0.0.1:1234" }; - } - if (ipcPath.includes("web")) { - throw new Error("web pipe missing"); - } - return { pid: 1234, state: "running", updatedAt: new Date().toISOString() }; - } - return { accepted: true }; - }) as typeof import("@open-design/sidecar").requestJsonIpc, - stopProcesses: stop, - waitForExit: neverExits, - }); - - expect(result).toEqual({ action: "continue", reason: "stale-sidecar" }); - expect(stop).toHaveBeenCalledWith([1234]); - const log = await readFile(join(root, "logs", "launcher", "after-quit.log"), "utf8"); - expect(log).toContain("shutdown=timed-out reason=stale-sidecar pid=1234"); - expect(log).toContain("force-stop stale-sidecar pid=1234 outcome=sigkill"); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - it("exits without restarting when a skewed desktop cannot be force-stopped", async () => { - const root = await mkdtemp(join(tmpdir(), "od-launcher-inspect-stale-survives-")); - const logger = { warn: vi.fn() }; - const stop = fakeStop(1234, { survived: true }); - try { - const paths = fakePaths(root); - - const result = await inspectExistingDesktopForLauncher("release-beta-win", { - logger, - paths, - requestIpc: (async (ipcPath: string, message: unknown) => { - if ((message as { type?: string }).type === SIDECAR_MESSAGES.STATUS) { - if (ipcPath.includes("daemon")) { - return { pid: 2345, state: "running", updatedAt: new Date().toISOString(), url: "http://127.0.0.1:1234" }; - } - if (ipcPath.includes("web")) { - throw new Error("web pipe missing"); - } - return { pid: 1234, state: "running", updatedAt: new Date().toISOString() }; - } - return { accepted: true }; - }) as typeof import("@open-design/sidecar").requestJsonIpc, - stopProcesses: stop, - waitForExit: neverExits, - }); - - expect(result).toEqual({ action: "exit", reason: "existing-focus-failed" }); - expect(stop).toHaveBeenCalledWith([1234]); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining("force-stop stale-sidecar pid=1234 outcome=survived"), - ); + await expect(inspectExistingDesktopForLauncher(requestedStamp, { + getStatus: vi.fn(async (target: SidecarStamp) => { + if (target.source === SIDECAR_SOURCES.TOOLS_PACK) throw new Error("tools-pack owner absent"); + return target.app === APP_KEYS.DESKTOP + ? { pid: 1234, state: "running", updatedAt: new Date().toISOString(), windowVisible: true } + : { state: "running", url: "http://127.0.0.1:1234" }; + }) as never, + invoke: invoke as never, + modes: ["runtime"], + paths: fakePaths(root), + })).resolves.toEqual({ action: "exit", reason: "existing-focused" }); + expect(invoke).toHaveBeenCalledWith(ownerStamp, "show", {}, { timeoutMs: 800 }); } finally { await rm(root, { force: true, recursive: true }); } }); }); + +it("exits Electron only for an existing desktop result", () => { + const exit = vi.fn(); + expect(exitPackagedLauncherForExistingDesktop({ action: "exit", reason: "existing-focused" }, exit)).toBe(true); + expect(exit).toHaveBeenCalledWith(0); +}); diff --git a/apps/packaged/tests/launcher-runtime.test.ts b/apps/packaged/tests/launcher-runtime.test.ts index 02935591da..4f3af0e612 100644 --- a/apps/packaged/tests/launcher-runtime.test.ts +++ b/apps/packaged/tests/launcher-runtime.test.ts @@ -1,6 +1,6 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, sep } from "node:path"; +import { dirname, join, sep } from "node:path"; import { LAUNCHER_SCHEMA_VERSION, @@ -12,6 +12,7 @@ import { describe, expect, it } from "vitest"; import type { PackagedConfig } from "../src/config.js"; import { confirmPackagedLauncherRuntime, + sameExecutablePath, type PackagedLauncherRuntime, resolvePackagedLauncherRuntime, } from "../src/launcher-runtime.js"; @@ -100,6 +101,25 @@ async function writeActiveMacPayloadFixture( } describe("resolvePackagedLauncherRuntime", () => { + it("recognizes the same executable through a symlinked root", async () => { + const root = await mkdtemp(join(tmpdir(), "od-packaged-executable-identity-")); + try { + const physicalRoot = join(root, "physical"); + const aliasRoot = join(root, "alias"); + const executable = join(physicalRoot, "Open Design.app", "Contents", "MacOS", "Open Design"); + await mkdir(dirname(executable), { recursive: true }); + await writeFile(executable, ""); + await symlink(physicalRoot, aliasRoot, "dir"); + + await expect(sameExecutablePath( + join(aliasRoot, "Open Design.app", "Contents", "MacOS", "Open Design"), + executable, + )).resolves.toBe(true); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + it.each([ { expected: "https://relay.payload.example/v1", diff --git a/apps/packaged/tests/logging.test.ts b/apps/packaged/tests/logging.test.ts index f3db9f9011..54418f3322 100644 --- a/apps/packaged/tests/logging.test.ts +++ b/apps/packaged/tests/logging.test.ts @@ -56,13 +56,11 @@ afterEach(() => { function makePaths(root: string, desktopLogPath = join(root, 'logs', 'desktop', 'latest.log')): PackagedNamespacePaths { return { cacheRoot: join(root, 'cache'), - desktopIdentityPath: join(root, 'runtime', 'desktop-root.json'), desktopLogPath, dataRoot: join(root, 'data'), desktopLogsRoot: join(root, 'logs', 'desktop'), electronSessionDataRoot: join(root, 'user-data', 'session'), electronUserDataRoot: join(root, 'user-data'), - headlessIdentityPath: join(root, 'runtime', 'headless-root.json'), installationRoot: root, installerObservationRoot: join(root, 'data', 'observations', 'installer'), logsRoot: join(root, 'logs'), @@ -70,7 +68,6 @@ function makePaths(root: string, desktopLogPath = join(root, 'logs', 'desktop', resourceRoot: join(root, 'resources'), runtimeRoot: join(root, 'runtime'), updateRoot: join(root, 'updates'), - webIdentityPath: join(root, 'runtime', 'web-root.json'), }; } diff --git a/apps/packaged/tests/payload-desktop-launch.test.ts b/apps/packaged/tests/payload-desktop-launch.test.ts index 4cfb308145..11131695d1 100644 --- a/apps/packaged/tests/payload-desktop-launch.test.ts +++ b/apps/packaged/tests/payload-desktop-launch.test.ts @@ -4,14 +4,6 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { parseLauncherAfterQuitArgs, parseLauncherDelegatedArgs } from "@open-design/launcher-proto"; -import { readProcessStamp } from "@open-design/platform"; -import { - APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, - SIDECAR_MODES, - SIDECAR_SOURCES, - type SidecarStamp, -} from "@open-design/sidecar-proto"; import { describe, expect, it, vi } from "vitest"; import type { PackagedLauncherRuntime } from "../src/launcher-runtime.js"; @@ -21,14 +13,6 @@ import { planPackagedPayloadDesktopDelegation, } from "../src/payload-desktop-launch.js"; -const stamp: SidecarStamp = { - app: APP_KEYS.DESKTOP, - ipc: "/tmp/open-design/ipc/release-beta/desktop.sock", - mode: SIDECAR_MODES.RUNTIME, - namespace: "release-beta", - source: SIDECAR_SOURCES.PACKAGED, -}; - function fakeRuntime(payloadDesktopProcess: boolean): PackagedLauncherRuntime { return { config: {} as PackagedLauncherRuntime["config"], @@ -37,7 +21,7 @@ function fakeRuntime(payloadDesktopProcess: boolean): PackagedLauncherRuntime { electronNodeCommand: null, installedLaunchPath: "/Applications/Open Design Beta.app", launcherPaths: {} as PackagedLauncherRuntime["launcherPaths"], - paths: {} as PackagedLauncherRuntime["paths"], + paths: { dataRoot: "/tmp/data", runtimeRoot: "/tmp/runtime" } as PackagedLauncherRuntime["paths"], payloadDesktopProcess, selection: { pointer: { generation: 1, version: "1.2.3-beta.5" }, @@ -50,9 +34,9 @@ function fakeRuntime(payloadDesktopProcess: boolean): PackagedLauncherRuntime { } describe("payload desktop delegation", () => { - it("plans an early outer-to-payload handoff with stable after-quit and stamp args", () => { + it("plans the launcher handoff while leaving stamp serialization to sidecar", () => { const runtime = fakeRuntime(false); - const plan = planPackagedPayloadDesktopDelegation(runtime, stamp, { + const plan = planPackagedPayloadDesktopDelegation(runtime, { currentPid: 4321, timeoutMs: 60_000, }); @@ -65,11 +49,11 @@ describe("payload desktop delegation", () => { targetPid: 4321, timeoutMs: 60_000, }); - expect(readProcessStamp(plan?.args ?? [], OPEN_DESIGN_SIDECAR_CONTRACT)).toEqual(stamp); + expect(plan?.args.some((arg) => arg.startsWith("--od-sidecar-"))).toBe(false); }); it("carries the delegated pointer for a normal active delegation", () => { - const plan = planPackagedPayloadDesktopDelegation(fakeRuntime(false), stamp, { + const plan = planPackagedPayloadDesktopDelegation(fakeRuntime(false), { currentPid: 4321, timeoutMs: 60_000, }); @@ -83,7 +67,7 @@ describe("payload desktop delegation", () => { const deeplink = "opendesign://workspace/invite/continue?nonce=payload-cold-start"; expect(findPackagedDeeplinkArg(["Open Design.exe", "--unrelated", deeplink])).toBe(deeplink); expect(findPackagedDeeplinkArg(["Open Design.exe", "--unrelated"])).toBeNull(); - const plan = planPackagedPayloadDesktopDelegation(fakeRuntime(false), stamp, { + const plan = planPackagedPayloadDesktopDelegation(fakeRuntime(false), { currentPid: 4321, forwardedArgs: ["Open Design.exe", "--unrelated", deeplink], timeoutMs: 60_000, @@ -105,7 +89,7 @@ describe("payload desktop delegation", () => { selected: true, }, }; - const plan = planPackagedPayloadDesktopDelegation(runtime, stamp, { + const plan = planPackagedPayloadDesktopDelegation(runtime, { currentPid: 4321, timeoutMs: 60_000, }); @@ -130,19 +114,11 @@ describe("payload desktop delegation", () => { } as PackagedLauncherRuntime["launcherPaths"], }; let spawnSawAttempt: boolean | null = null; - const child = { - once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "spawn") queueMicrotask(callback); - return child; - }), - unref: vi.fn(), - }; - const spawnFake = vi.fn(() => { + const handoff = vi.fn(async () => { spawnSawAttempt = existsSync(attemptsPath); - return child as never; }); - const launched = await launchPackagedPayloadDesktop(runtime, stamp, { spawn: spawnFake as never }); + const launched = await launchPackagedPayloadDesktop(runtime, { handoff }); expect(launched).toBe(true); expect(spawnSawAttempt).toBe(true); @@ -153,6 +129,9 @@ describe("payload desktop delegation", () => { namespace: "release-beta", version: "1.2.3-beta.5", }); + expect(handoff).toHaveBeenCalledWith(expect.objectContaining({ + command: runtime.desktopExecutablePath, + }), { timeoutMs: 60_000 }); } finally { await rm(root, { force: true, recursive: true }); } @@ -175,15 +154,9 @@ describe("payload desktop delegation", () => { selected: true, }, }; - const child = { - once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "spawn") queueMicrotask(callback); - return child; - }), - unref: vi.fn(), - }; - - await launchPackagedPayloadDesktop(runtime, stamp, { spawn: vi.fn(() => child as never) as never }); + await launchPackagedPayloadDesktop(runtime, { + handoff: vi.fn(async () => undefined), + }); expect(existsSync(attemptsPath)).toBe(false); } finally { @@ -192,24 +165,16 @@ describe("payload desktop delegation", () => { }); it("does not delegate once the current process already is the payload desktop", () => { - expect(planPackagedPayloadDesktopDelegation(fakeRuntime(true), stamp, { + expect(planPackagedPayloadDesktopDelegation(fakeRuntime(true), { currentPid: 4321, timeoutMs: 60_000, })).toBeNull(); }); - it("waits for spawn acceptance and detaches the payload child", async () => { + it("delegates the next child to the current sidecar generation", async () => { const root = await mkdtemp(join(tmpdir(), "od-delegated-spawn-")); try { - const once = vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "spawn") queueMicrotask(callback); - return child; - }); - const child = { - once, - unref: vi.fn(), - }; - const spawn = vi.fn(() => child); + const handoff = vi.fn(async () => undefined); const runtime: PackagedLauncherRuntime = { ...fakeRuntime(false), launcherPaths: { @@ -219,21 +184,24 @@ describe("payload desktop delegation", () => { } as PackagedLauncherRuntime["launcherPaths"], }; - const delegated = await launchPackagedPayloadDesktop(runtime, stamp, { + const delegated = await launchPackagedPayloadDesktop(runtime, { currentPid: 4321, - spawn: spawn as never, + handoff, timeoutMs: 60_000, }); expect(delegated).toBe(true); - expect(spawn).toHaveBeenCalledOnce(); - expect(child.unref).toHaveBeenCalledOnce(); + expect(handoff).toHaveBeenCalledWith(expect.objectContaining({ + args: expect.any(Array), + command: runtime.desktopExecutablePath, + cwd: dirname(runtime.desktopExecutablePath ?? ""), + }), { timeoutMs: 60_000 }); } finally { await rm(root, { force: true, recursive: true }); } }); - it("records a failed attempt when the payload executable cannot spawn", async () => { + it("records a failed attempt when the supervisor rejects the handoff", async () => { const root = await mkdtemp(join(tmpdir(), "od-payload-desktop-spawn-failure-")); const runtime = fakeRuntime(false); runtime.launcherPaths = { @@ -241,18 +209,10 @@ describe("payload desktop delegation", () => { channel: "beta", namespace: "release-beta", } as PackagedLauncherRuntime["launcherPaths"]; - const spawnError = new Error("spawn EACCES"); - const child = { - once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "error") queueMicrotask(() => callback(spawnError)); - return child; - }), - unref: vi.fn(), - }; try { - await expect(launchPackagedPayloadDesktop(runtime, stamp, { - spawn: vi.fn(() => child) as never, - })).rejects.toThrow("spawn EACCES"); + await expect(launchPackagedPayloadDesktop(runtime, { + handoff: vi.fn(async () => { throw new Error("handoff rejected"); }), + })).rejects.toThrow("handoff rejected"); expect(JSON.parse(await readFile(runtime.launcherPaths.attemptsPath, "utf8"))).toMatchObject({ channel: "beta", @@ -261,7 +221,6 @@ describe("payload desktop delegation", () => { schemaVersion: 1, version: "1.2.3-beta.5", }); - expect(child.unref).not.toHaveBeenCalled(); } finally { await rm(root, { force: true, recursive: true }); } diff --git a/apps/packaged/tests/sidecars.test.ts b/apps/packaged/tests/sidecars.test.ts index 2df8bf7aba..4a92849a56 100644 --- a/apps/packaged/tests/sidecars.test.ts +++ b/apps/packaged/tests/sidecars.test.ts @@ -15,23 +15,14 @@ * @see apps/daemon/src/legacy-data-migrator.ts * @see https://github.com/nexu-io/open-design/issues/710 */ -import { spawn, type ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { createServer, type Socket } from 'node:net'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { delimiter, dirname, join, posix } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { createProcessStampArgs, isProcessAlive, stopProcesses, waitForProcessExit } from '@open-design/platform'; -import { createJsonIpcServer, resolveAppIpcPath } from '@open-design/sidecar'; -import { - APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, - SIDECAR_ENV, - SIDECAR_MODES, - SIDECAR_SOURCES, -} from '@open-design/sidecar-proto'; +import type { SidecarStamp } from '@open-design/sidecar'; +import { APP_KEYS } from '@open-design/sidecar-proto'; import { buildPackagedDaemonSpawnEnv, @@ -39,12 +30,10 @@ import { createPackagedSidecarSpawnOptions, createRestartPolicy, createWebSidecarSupervisor, - DEFERRED_MANAGED_CHILD_EXIT_GRACE_MS, - MANAGED_CHILD_EXIT_GRACE_MS, openLog, + packagedChildStamp, registerPackagedWebUrl, - resolveManagedChildExitGraceMs, - retireExistingSidecarEndpoint, + retireExistingSidecar, resolveDaemonStatusTimeoutMs, resolvePackagedChildBaseEnv, resolvePackagedElectronNodeCommand, @@ -57,45 +46,66 @@ function slashPath(value: string): string { return value.replaceAll('\\', '/'); } -async function spawnStampedHungWebOwner( - socketPath: string, - ipcPath = socketPath, -): Promise<{ child: ChildProcess; pid: number }> { - const stamp = { - app: APP_KEYS.WEB, - ipc: ipcPath, - mode: SIDECAR_MODES.RUNTIME, - namespace: 'packaged-stale-web-test', - source: SIDECAR_SOURCES.PACKAGED, - }; - const child = spawn( - process.execPath, - [ - '-e', - "process.on('SIGTERM',()=>{});require('net').createServer(s=>s.resume()).listen(process.env.OD_TEST_SOCK,()=>process.stdout.write('ready\\n'));setInterval(()=>{},1000)", - '--', - ...createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT), - ], - { - env: { ...process.env, OD_TEST_SOCK: socketPath }, - stdio: ['ignore', 'pipe', 'pipe'], +function testStamp(app: "daemon" | "web" = APP_KEYS.DAEMON): SidecarStamp { + return { app, channel: "stable", mode: "runtime", namespace: "test", source: "packaged" }; +} + +describe('packaged sidecar shutdown', () => { + it('rejects surviving generation processes and always closes the log handle', async () => { + const root = mkdtempSync(join(tmpdir(), 'od-sidecar-close-')); + const logPath = join(root, 'latest.log'); + const closeLog = vi.fn(async () => undefined); + const stop = vi.fn(async () => ({ + alreadyStopped: false, + forcedPids: [42], + gracefulAccepted: false, + matchedPids: [42], + remainingPids: [42], + stoppedPids: [], + })); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + pid: 42, + signalCode: null, + }); + + try { + await expect(closeManagedChild({ + app: APP_KEYS.DAEMON, + child, + generation: { stop }, + logHandle: { close: closeLog }, + logPath, + stamp: testStamp(), + } as unknown as Parameters[0])).rejects.toThrow( + 'failed to stop packaged daemon sidecar processes: 42', + ); + expect(closeLog).toHaveBeenCalledOnce(); + expect(readFileSync(logPath, 'utf8')).toContain('shutdown requested'); + expect(readFileSync(logPath, 'utf8')).not.toContain('exited app=daemon'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('packaged child stamps', () => { + it.each(['runtime', 'headless'] as const)( + 'propagates the owning %s mode to daemon and web children', + (mode) => { + const runtime = { + app: APP_KEYS.DESKTOP, + base: '/runtime', + mode, + namespace: 'test', + source: 'packaged', + } as const; + + expect(packagedChildStamp(APP_KEYS.DAEMON, 'stable', runtime).mode).toBe(mode); + expect(packagedChildStamp(APP_KEYS.WEB, 'stable', runtime).mode).toBe(mode); }, ); - const pid = child.pid; - if (pid == null) throw new Error('test child did not start'); - await new Promise((resolve, reject) => { - const fail = (error: Error): void => { - child.kill('SIGKILL'); - reject(error); - }; - child.once('error', fail); - child.once('exit', (code, signal) => { - fail(new Error(`stamped hung web owner exited before ready code=${code} signal=${signal}`)); - }); - child.stdout?.once('data', () => resolve()); - }); - return { child, pid }; -} +}); describe('resolveDaemonStatusTimeoutMs', () => { it('uses the 35-second baseline budget on platforms without a known slow-cold-start class', () => { @@ -165,47 +175,93 @@ describe('resolveDaemonStatusTimeoutMs', () => { describe('packaged web URL registration', () => { it('registers the current dynamic web URL with the daemon sidecar and supports a later port', async () => { - const namespace = `web-url-${process.pid}-${Date.now()}`; - const daemonIpc = resolveAppIpcPath({ - app: APP_KEYS.DAEMON, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace, - }); const received: unknown[] = []; - const server = await createJsonIpcServer({ - socketPath: daemonIpc, - handler: async (message) => { - received.push(message); - return { accepted: true }; - }, - }); + const invoke = async (...args: unknown[]) => { + received.push(args); + return { accepted: true }; + }; + const daemonStamp = testStamp(); + await registerPackagedWebUrl(daemonStamp, 'http://127.0.0.1:64248', invoke as never); + await registerPackagedWebUrl(daemonStamp, 'http://127.0.0.1:53421', invoke as never); + expect(received).toEqual([ + [daemonStamp, 'register-web-url', { url: 'http://127.0.0.1:64248' }, { timeoutMs: 1200 }], + [daemonStamp, 'register-web-url', { url: 'http://127.0.0.1:53421' }, { timeoutMs: 1200 }], + ]); + }); +}); +describe('packaged stale sidecar retirement', () => { + const stopped = (overrides: Partial<{ + matchedPids: number[]; + remainingPids: number[]; + staleEndpointRemoved: boolean; + }> = {}) => ({ + alreadyStopped: false, + forcedPids: [], + gracefulAccepted: false, + matchedPids: overrides.matchedPids ?? [4321], + remainingPids: overrides.remainingPids ?? [], + staleEndpointRemoved: overrides.staleEndpointRemoved ?? false, + stoppedPids: overrides.matchedPids ?? [4321], + }); + + async function withLog(run: (logPath: string) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), 'od-packaged-retire-')); try { - await registerPackagedWebUrl(daemonIpc, 'http://127.0.0.1:64248'); - await registerPackagedWebUrl(daemonIpc, 'http://127.0.0.1:53421'); - expect(received).toEqual([ - { - input: { url: 'http://127.0.0.1:64248' }, - type: 'register-web-url', - }, - { - input: { url: 'http://127.0.0.1:53421' }, - type: 'register-web-url', - }, - ]); + await run(join(root, 'latest.log')); } finally { - await server.close(); + rmSync(root, { recursive: true, force: true }); } + } + + it('delegates a clean first boot to the sidecar lifecycle atomic', async () => { + await withLog(async (logPath) => { + const stop = vi.fn(async () => ({ ...stopped({ matchedPids: [] }), alreadyStopped: true })); + await expect(retireExistingSidecar(testStamp(), logPath, { + stop, + })).resolves.toBeUndefined(); + expect(stop).toHaveBeenCalledOnce(); + }); + }); + + it('retires an unresponsive daemon through the sidecar lifecycle atomic', async () => { + await withLog(async (logPath) => { + const stop = vi.fn(async () => stopped()); + await expect(retireExistingSidecar(testStamp(), logPath, { + stop, + })).resolves.toBeUndefined(); + expect(stop).toHaveBeenCalledOnce(); + }); + }); + + it('retires an unresponsive web generation through the same lifecycle atomic', async () => { + await withLog(async (logPath) => { + const stop = vi.fn(async () => stopped()); + await expect(retireExistingSidecar(testStamp(APP_KEYS.WEB), logPath, { + stop, + })).resolves.toBeUndefined(); + expect(stop).toHaveBeenCalledOnce(); + }); + }); + + it('allows recovery when the lifecycle atomic removes only a stale endpoint', async () => { + await withLog(async (logPath) => { + await expect(retireExistingSidecar(testStamp(APP_KEYS.WEB), logPath, { + stop: async () => stopped({ matchedPids: [], staleEndpointRemoved: true }), + })).resolves.toBeUndefined(); + }); + }); + + it('does not relaunch after a healthy generation fails to stop', async () => { + await withLog(async (logPath) => { + await expect(retireExistingSidecar(testStamp(APP_KEYS.WEB), logPath, { + stop: async () => stopped({ remainingPids: [4321] }), + })).rejects.toThrow('generation remains: 4321'); + }); }); }); describe('packaged child Vite+ environment forwarding', () => { - it('pins packaged sidecars to the packaged desktop parent process', () => { - const env = resolvePackagedChildBaseEnv({}, false, {}, false); - - expect(env[SIDECAR_ENV.TOOLS_DEV_PARENT_PID]).toBe(String(process.pid)); - }); - it('forwards CODEX_HOME so isolated and managed Codex installs never fall back to another user config', () => { const env = resolvePackagedChildBaseEnv({ CODEX_HOME: '/tmp/isolated-codex-home', @@ -378,258 +434,6 @@ describe('packaged child Vite+ environment forwarding', () => { }); }); -describe.runIf(process.platform !== 'win32')('packaged stale web endpoint recovery', () => { - it('unlinks a web socket whose owner accepts connections but never answers IPC', async () => { - const root = mkdtempSync(join(tmpdir(), 'od-packaged-stale-web-')); - const socketPath = join(root, 'web.sock'); - const logPath = join(root, 'web.log'); - const sockets = new Set(); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const server = createServer((socket) => { - sockets.add(socket); - socket.once('close', () => sockets.delete(socket)); - socket.resume(); - }); - await new Promise((resolveListen, rejectListen) => { - server.once('error', rejectListen); - server.listen(socketPath, () => { - server.off('error', rejectListen); - resolveListen(); - }); - }); - - try { - await retireExistingSidecarEndpoint(socketPath, logPath, APP_KEYS.WEB); - expect(existsSync(socketPath)).toBe(false); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('unresponsive web sidecar endpoint removed before relaunch'), - ); - } finally { - warn.mockRestore(); - for (const socket of sockets) socket.destroy(); - await new Promise((resolveClose) => server.close(() => resolveClose())); - rmSync(root, { force: true, recursive: true }); - } - }); - - it('stops the stamped web owner before unlinking its unresponsive socket', async () => { - const root = mkdtempSync(join(tmpdir(), 'od-packaged-stale-web-owner-')); - const socketPath = join(root, 'web.sock'); - const logPath = join(root, 'web.log'); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const { child, pid } = await spawnStampedHungWebOwner(socketPath); - - try { - await retireExistingSidecarEndpoint(socketPath, logPath, APP_KEYS.WEB); - expect(isProcessAlive(pid)).toBe(false); - expect(existsSync(socketPath)).toBe(false); - expect(readFileSync(logPath, 'utf8')).toContain( - 'stopping unresponsive stamped web sidecar before socket takeover', - ); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('unresponsive web sidecar endpoint removed before relaunch'), - ); - } finally { - warn.mockRestore(); - child.kill('SIGKILL'); - await waitForProcessExit(pid, 1_000); - rmSync(root, { force: true, recursive: true }); - } - }, 10_000); - - it('does not unlink when the stamped web owner survives stopProcesses', async () => { - const root = mkdtempSync(join(tmpdir(), 'od-packaged-stale-web-remaining-')); - const socketPath = join(root, 'web.sock'); - const logPath = join(root, 'web.log'); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const { child, pid } = await spawnStampedHungWebOwner(socketPath); - const stopProcesses = vi.fn(async () => ({ - alreadyStopped: false, - forcedPids: [], - matchedPids: [pid], - remainingPids: [pid], - stoppedPids: [], - })); - - try { - await retireExistingSidecarEndpoint(socketPath, logPath, APP_KEYS.WEB, { stopProcesses }); - expect(isProcessAlive(pid)).toBe(true); - expect(existsSync(socketPath)).toBe(true); - expect(readFileSync(logPath, 'utf8')).toContain( - 'unresponsive stamped web sidecar still running after stop', - ); - expect(warn).not.toHaveBeenCalled(); - } finally { - warn.mockRestore(); - child.kill('SIGKILL'); - await waitForProcessExit(pid, 1_000); - rmSync(root, { force: true, recursive: true }); - } - }, 10_000); - - it('does not unlink when process discovery returns no snapshots', async () => { - const root = mkdtempSync(join(tmpdir(), 'od-packaged-stale-web-empty-list-')); - const socketPath = join(root, 'web.sock'); - const logPath = join(root, 'web.log'); - const sockets = new Set(); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const server = createServer((socket) => { - sockets.add(socket); - socket.once('close', () => sockets.delete(socket)); - socket.resume(); - }); - await new Promise((resolveListen, rejectListen) => { - server.once('error', rejectListen); - server.listen(socketPath, () => { - server.off('error', rejectListen); - resolveListen(); - }); - }); - const listProcessSnapshots = vi.fn(async () => []); - - try { - await retireExistingSidecarEndpoint(socketPath, logPath, APP_KEYS.WEB, { listProcessSnapshots }); - expect(existsSync(socketPath)).toBe(true); - expect(readFileSync(logPath, 'utf8')).toContain( - 'process discovery failed before web socket takeover', - ); - expect(warn).not.toHaveBeenCalled(); - } finally { - warn.mockRestore(); - for (const socket of sockets) socket.destroy(); - await new Promise((resolveClose) => server.close(() => resolveClose())); - rmSync(root, { force: true, recursive: true }); - } - }); - - it('does not unlink when process discovery throws', async () => { - const root = mkdtempSync(join(tmpdir(), 'od-packaged-stale-web-list-throw-')); - const socketPath = join(root, 'web.sock'); - const logPath = join(root, 'web.log'); - const sockets = new Set(); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const server = createServer((socket) => { - sockets.add(socket); - socket.once('close', () => sockets.delete(socket)); - socket.resume(); - }); - await new Promise((resolveListen, rejectListen) => { - server.once('error', rejectListen); - server.listen(socketPath, () => { - server.off('error', rejectListen); - resolveListen(); - }); - }); - const listProcessSnapshots = vi.fn(async () => { - throw new Error('ps-failed'); - }); - - try { - await retireExistingSidecarEndpoint(socketPath, logPath, APP_KEYS.WEB, { listProcessSnapshots }); - expect(existsSync(socketPath)).toBe(true); - expect(readFileSync(logPath, 'utf8')).toContain( - 'failed to enumerate processes before web socket takeover', - ); - expect(warn).not.toHaveBeenCalled(); - } finally { - warn.mockRestore(); - for (const socket of sockets) socket.destroy(); - await new Promise((resolveClose) => server.close(() => resolveClose())); - rmSync(root, { force: true, recursive: true }); - } - }); - - it('does not unlink when stopping the stamped web owner throws', async () => { - const root = mkdtempSync(join(tmpdir(), 'od-packaged-stale-web-stop-throw-')); - const socketPath = join(root, 'web.sock'); - const logPath = join(root, 'web.log'); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const { child, pid } = await spawnStampedHungWebOwner(socketPath); - const stopProcesses = vi.fn(async () => { - throw new Error('stop-failed'); - }); - - try { - await retireExistingSidecarEndpoint(socketPath, logPath, APP_KEYS.WEB, { stopProcesses }); - expect(isProcessAlive(pid)).toBe(true); - expect(existsSync(socketPath)).toBe(true); - expect(readFileSync(logPath, 'utf8')).toContain( - 'failed to stop unresponsive stamped web sidecar', - ); - expect(warn).not.toHaveBeenCalled(); - } finally { - warn.mockRestore(); - child.kill('SIGKILL'); - await waitForProcessExit(pid, 1_000); - rmSync(root, { force: true, recursive: true }); - } - }, 10_000); - - it('does not stop a stamped web owner of a different ipc path', async () => { - const root = mkdtempSync(join(tmpdir(), 'od-packaged-stale-web-other-')); - const ownerSocketPath = join(root, 'owner.sock'); - const targetSocketPath = join(root, 'web.sock'); - const logPath = join(root, 'web.log'); - const sockets = new Set(); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const { child, pid } = await spawnStampedHungWebOwner(ownerSocketPath); - const server = createServer((socket) => { - sockets.add(socket); - socket.once('close', () => sockets.delete(socket)); - socket.resume(); - }); - await new Promise((resolveListen, rejectListen) => { - server.once('error', rejectListen); - server.listen(targetSocketPath, () => { - server.off('error', rejectListen); - resolveListen(); - }); - }); - - try { - await retireExistingSidecarEndpoint(targetSocketPath, logPath, APP_KEYS.WEB); - expect(isProcessAlive(pid)).toBe(true); - expect(existsSync(ownerSocketPath)).toBe(true); - expect(existsSync(targetSocketPath)).toBe(false); - } finally { - warn.mockRestore(); - child.kill('SIGKILL'); - await waitForProcessExit(pid, 1_000); - for (const socket of sockets) socket.destroy(); - await new Promise((resolveClose) => server.close(() => resolveClose())); - rmSync(root, { force: true, recursive: true }); - } - }, 10_000); - - it('does not unlink an unresponsive daemon socket', async () => { - const root = mkdtempSync(join(tmpdir(), 'od-packaged-stale-daemon-')); - const socketPath = join(root, 'daemon.sock'); - const logPath = join(root, 'daemon.log'); - const sockets = new Set(); - const server = createServer((socket) => { - sockets.add(socket); - socket.once('close', () => sockets.delete(socket)); - socket.resume(); - }); - await new Promise((resolveListen, rejectListen) => { - server.once('error', rejectListen); - server.listen(socketPath, () => { - server.off('error', rejectListen); - resolveListen(); - }); - }); - - try { - await retireExistingSidecarEndpoint(socketPath, logPath, APP_KEYS.DAEMON); - expect(existsSync(socketPath)).toBe(true); - } finally { - for (const socket of sockets) socket.destroy(); - await new Promise((resolveClose) => server.close(() => resolveClose())); - rmSync(root, { force: true, recursive: true }); - } - }); -}); - describe('resolvePackagedElectronNodeCommand', () => { it('uses the hidden Electron helper as the macOS Electron-as-Node command when available', async () => { const root = mkdtempSync(join(tmpdir(), 'od-packaged-electron-helper-')); @@ -719,12 +523,10 @@ describe('buildPackagedDaemonSpawnEnv', () => { return { cacheRoot: '/tmp/od-pkg/cache', dataRoot: '/tmp/od-pkg/data', - desktopIdentityPath: '/tmp/od-pkg/runtime/desktop-root.json', desktopLogPath: '/tmp/od-pkg/logs/desktop/latest.log', desktopLogsRoot: '/tmp/od-pkg/logs/desktop', electronSessionDataRoot: '/tmp/od-pkg/user-data/session', electronUserDataRoot: '/tmp/od-pkg/user-data', - headlessIdentityPath: '/tmp/od-pkg/runtime/headless-root.json', installationRoot: '/tmp/od-pkg/..', installerObservationRoot: '/tmp/od-pkg/data/observations/installer', logsRoot: '/tmp/od-pkg/logs', @@ -732,7 +534,6 @@ describe('buildPackagedDaemonSpawnEnv', () => { resourceRoot: '/tmp/od-pkg/resources', runtimeRoot: '/tmp/od-pkg/runtime', updateRoot: '/tmp/od-pkg/updates', - webIdentityPath: '/tmp/od-pkg/runtime/web-root.json', }; } @@ -1089,7 +890,7 @@ describe('waitForStatus child-exit fast-fail', () => { const startedAt = Date.now(); const promise = waitForStatus<{ url: string | null }>( - ipcPath, + { label: 'daemon', read: async () => { throw new Error(`missing ${ipcPath}`); } }, (status) => status.url != null, 30 * 60 * 1000, { child, logPath }, @@ -1132,7 +933,7 @@ describe('waitForStatus child-exit fast-fail', () => { let captured: unknown; try { await waitForStatus<{ url: string | null }>( - '/tmp/od-test-no-such-ipc-pre-' + Date.now(), + { label: 'daemon', read: async () => { throw new Error('missing'); } }, (status) => status.url != null, 30 * 60 * 1000, { child, logPath: '/tmp/od-test-daemon.log' }, @@ -1148,42 +949,18 @@ describe('waitForStatus child-exit fast-fail', () => { expect(elapsed).toBeLessThan(2_000); }); - it('does not accept ready status from a stale IPC endpoint owned by a different pid', async () => { + it('does not interpret a business status pid as the generation root pid', async () => { const child = fakeChild(); child.pid = 5678; - const ipcPath = resolveAppIpcPath({ - app: APP_KEYS.WEB, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: `stale-ipc-${process.pid}-${Date.now()}`, - }); - const server = await createJsonIpcServer({ - socketPath: ipcPath, - handler: async () => ({ - pid: 1234, - state: 'running', - updatedAt: new Date().toISOString(), - url: 'http://127.0.0.1:1234', - }), - }); - - try { - let captured: unknown; - try { - await waitForStatus<{ pid?: number | null; url: string | null }>( - ipcPath, - (status) => status.url != null, - 250, - { child, logPath: join(tmpdir(), 'od-test-web.log') }, - ); - } catch (err) { - captured = err; - } - - expect(captured).toBeInstanceOf(Error); - expect((captured as Error).message).toContain('sidecar status pid 1234 did not match spawned pid 5678'); - } finally { - await server.close(); - } + await expect(waitForStatus<{ pid: number; url: string | null }>( + { + label: 'web', + read: async () => ({ pid: 1234, url: 'http://127.0.0.1:1234' }), + }, + (status) => status.url != null, + 250, + { child, logPath: join(tmpdir(), 'od-test-web.log') }, + )).resolves.toEqual({ pid: 1234, url: 'http://127.0.0.1:1234' }); }); }); @@ -1468,156 +1245,3 @@ describe('packaged sidecar log rotation', () => { } }); }); - -describe('packaged managed-child shutdown grace', () => { - it('keeps the ordinary 5s grace unless shutdown is deferred', () => { - expect(resolveManagedChildExitGraceMs(undefined)).toBe(MANAGED_CHILD_EXIT_GRACE_MS); - expect(resolveManagedChildExitGraceMs({ accepted: true })).toBe(MANAGED_CHILD_EXIT_GRACE_MS); - expect(resolveManagedChildExitGraceMs({ accepted: true, deferred: false })).toBe( - MANAGED_CHILD_EXIT_GRACE_MS, - ); - expect(resolveManagedChildExitGraceMs({ accepted: true, deferred: true })).toBe( - DEFERRED_MANAGED_CHILD_EXIT_GRACE_MS, - ); - }); -}); - -describe.runIf(process.platform !== 'win32')( - 'packaged deferred shutdown vs stopProcesses SIGKILL', - () => { - const journalNames = ['handoff.json', 'attempts.json', 'runtime.json'] as const; - - async function spawnSlowJournalChild(journalDir: string, writeDelayMs: number): Promise<{ - child: ChildProcess; - pid: number; - }> { - mkdirSync(journalDir, { recursive: true }); - const child = spawn( - process.execPath, - [ - '-e', - [ - "process.on('SIGTERM',()=>{});", - "const fs=require('fs');", - "const path=require('path');", - "const dir=process.env.OD_TEST_JOURNAL_DIR;", - "const delay=Number(process.env.OD_TEST_WRITE_DELAY_MS);", - "const files=['handoff.json','attempts.json','runtime.json'];", - '(async()=>{', - ' for (const file of files) {', - ' await new Promise((resolve)=>setTimeout(resolve, delay));', - " fs.writeFileSync(path.join(dir, file), JSON.stringify({ committed: file }) + '\\n');", - ' }', - '})();', - 'setInterval(()=>{}, 1000);', - ].join(''), - ], - { - env: { - ...process.env, - OD_TEST_JOURNAL_DIR: journalDir, - OD_TEST_WRITE_DELAY_MS: String(writeDelayMs), - }, - stdio: 'ignore', - }, - ); - const pid = child.pid; - if (pid == null) { - child.kill('SIGKILL'); - throw new Error('slow journal child did not start'); - } - return { child, pid }; - } - - function committedJournalCount(journalDir: string): number { - return journalNames.filter((name) => existsSync(join(journalDir, name))).length; - } - - it('lets real stopProcesses SIGKILL truncate a non-deferred journal', async () => { - const root = mkdtempSync(join(tmpdir(), 'od-packaged-sigkill-trunc-')); - const ipcPath = join(root, 'daemon.sock'); - const logPath = join(root, 'daemon.log'); - const journalDir = join(root, 'journal'); - const server = await createJsonIpcServer({ - socketPath: ipcPath, - handler: async () => ({ accepted: true }), - }); - const { child, pid } = await spawnSlowJournalChild(journalDir, 200); - const logHandle = await openLog(logPath); - let stopResult: Awaited> | undefined; - - try { - await closeManagedChild( - { - app: APP_KEYS.DAEMON, - child, - ipcPath, - logHandle, - logPath, - }, - { - exitGraceMs: 0, - stopOptions: { killGraceMs: 1_000, termGraceMs: 0 }, - stopProcesses: async (pids, options) => { - stopResult = await stopProcesses(pids, options); - return stopResult; - }, - }, - ); - - expect(committedJournalCount(journalDir)).toBeLessThan(journalNames.length); - expect(stopResult?.forcedPids).toContain(pid); - expect(isProcessAlive(pid)).toBe(false); - } finally { - child.kill('SIGKILL'); - await waitForProcessExit(pid, 1_000); - await server.close(); - rmSync(root, { recursive: true, force: true }); - } - }, 10_000); - - it('commits all three journal files before deferred stopProcesses SIGKILL', async () => { - const root = mkdtempSync(join(tmpdir(), 'od-packaged-sigkill-hold-')); - const ipcPath = join(root, 'daemon.sock'); - const logPath = join(root, 'daemon.log'); - const journalDir = join(root, 'journal'); - const server = await createJsonIpcServer({ - socketPath: ipcPath, - handler: async () => ({ accepted: true, deferred: true }), - }); - const { child, pid } = await spawnSlowJournalChild(journalDir, 100); - const logHandle = await openLog(logPath); - let stopResult: Awaited> | undefined; - - try { - await closeManagedChild( - { - app: APP_KEYS.DAEMON, - child, - ipcPath, - logHandle, - logPath, - }, - { - deferredExitGraceMs: 1_500, - exitGraceMs: 0, - stopOptions: { killGraceMs: 1_000, termGraceMs: 0 }, - stopProcesses: async (pids, options) => { - stopResult = await stopProcesses(pids, options); - return stopResult; - }, - }, - ); - - expect(committedJournalCount(journalDir)).toBe(journalNames.length); - expect(stopResult?.forcedPids).toContain(pid); - expect(isProcessAlive(pid)).toBe(false); - } finally { - child.kill('SIGKILL'); - await waitForProcessExit(pid, 1_000); - await server.close(); - rmSync(root, { recursive: true, force: true }); - } - }, 10_000); - }, -); diff --git a/apps/web/sidecar/index.ts b/apps/web/sidecar/index.ts index c50dbe41f9..214e175d6c 100644 --- a/apps/web/sidecar/index.ts +++ b/apps/web/sidecar/index.ts @@ -1,21 +1,26 @@ -import { APP_KEYS, OPEN_DESIGN_SIDECAR_CONTRACT } from "@open-design/sidecar-proto"; -import { bootstrapSidecarRuntime } from "@open-design/sidecar"; -import { readProcessStamp } from "@open-design/platform"; +import { APP_KEYS } from "@open-design/sidecar-proto"; +import { SidecarFactory, type SidecarClient } from "@open-design/sidecar"; -import { startWebSidecar } from "./server.js"; +import { startWebSidecar, type WebSidecarHandle } from "./server.js"; async function main(): Promise { - const stamp = readProcessStamp(process.argv.slice(2), OPEN_DESIGN_SIDECAR_CONTRACT); - if (stamp == null) throw new Error("sidecar stamp is required"); - - const runtime = bootstrapSidecarRuntime(stamp, process.env, { - app: APP_KEYS.WEB, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, + let client!: SidecarClient; + client = SidecarFactory.create({ + lifecycle: { + async start(resources) { + if (client.stamp.app !== APP_KEYS.WEB) throw new Error(`web sidecar cannot run stamp app ${client.stamp.app}`); + return await startWebSidecar({ mode: client.stamp.mode }, resources.port); + }, + async status(runtime) { + return await runtime.status(); + }, + async stop(runtime) { + await runtime.stop(); + }, + }, }); - const server = await startWebSidecar(runtime); - - process.stdout.write(`${JSON.stringify(await server.status(), null, 2)}\n`); - await server.waitUntilStopped(); + await client.start(); + await client.waitUntilStopped(); } void main().catch((error: unknown) => { diff --git a/apps/web/sidecar/server.ts b/apps/web/sidecar/server.ts index 8a1fa60fa3..3745e5fd06 100644 --- a/apps/web/sidecar/server.ts +++ b/apps/web/sidecar/server.ts @@ -11,22 +11,20 @@ import { Agent as HttpsAgent, request as createHttpsRequest } from "node:https"; import { existsSync, readFileSync } from "node:fs"; import { readFile, realpath, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { createConnection, createServer as createTcpServer, type AddressInfo, type Server as TcpServer } from "node:net"; +import { + createConnection, + createServer as createTcpServer, + type AddressInfo, + type Server as TcpServer, + type Socket, +} from "node:net"; import { dirname, isAbsolute, join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { SIDECAR_ENV, - SIDECAR_MESSAGES, - normalizeWebSidecarMessage, - type SidecarStamp, type WebStatusSnapshot, } from "@open-design/sidecar-proto"; -import { - createJsonIpcServer, - type JsonIpcServerHandle, - type SidecarRuntimeContext, -} from "@open-design/sidecar"; const HOST = process.env.OD_HOST || "127.0.0.1"; if (process.env.OD_HOST != null && !/^[a-zA-Z0-9._\-:[\]@]+$/.test(process.env.OD_HOST)) { @@ -37,7 +35,6 @@ const STANDALONE_BACKEND_HOST = "127.0.0.1"; const DAEMON_PORT_ENV = SIDECAR_ENV.DAEMON_PORT; const WEB_DIST_DIR_ENV = SIDECAR_ENV.WEB_DIST_DIR; const WEB_PORT_ENV = SIDECAR_ENV.WEB_PORT; -const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID; const WEB_OUTPUT_MODE_ENV = "OD_WEB_OUTPUT_MODE"; const WEB_STANDALONE_ROOT_ENV = "OD_WEB_STANDALONE_ROOT"; const STANDALONE_PARENT_PID_ENV = "OD_STANDALONE_PARENT_PID"; @@ -48,6 +45,7 @@ const STANDALONE_STARTUP_TIMEOUT_ENV = "OD_STANDALONE_STARTUP_TIMEOUT_MS"; const DAEMON_PROXY_UNAVAILABLE_MESSAGE = `connect ECONNREFUSED (${DAEMON_PORT_ENV} is not set; the web runtime has no daemon origin)`; const SHUTDOWN_TIMEOUT_MS = 3000; +const WEB_HTTP_DRAIN_MS = 250; const STANDALONE_READINESS_POLL_MS = 150; const STANDALONE_TCP_READINESS_GRACE_MS = STANDALONE_READINESS_POLL_MS; const require = createRequire(import.meta.url); @@ -214,7 +212,9 @@ export function resolveStandaloneServerEntry( return candidates.find((candidate) => existsSync(candidate)) ?? null; } -function shouldUseStandaloneOutput(runtime: SidecarRuntimeContext): boolean { +export type WebRuntimeContext = { mode: string; [field: string]: unknown }; + +function shouldUseStandaloneOutput(runtime: WebRuntimeContext): boolean { return runtime.mode !== "dev" && process.env[WEB_OUTPUT_MODE_ENV] === "standalone"; } @@ -632,6 +632,67 @@ async function closeServer(server: HttpServer | TcpServer): Promise { }); } +/** + * Own every accepted web-sidecar connection so shutdown cannot be held open by + * a renderer keep-alive, SSE request, or upgraded socket. Short requests get a + * small drain window; the remaining connections are no longer useful after the + * sidecar has entered its stopped state and are closed deterministically. + */ +export function createWebHttpServerShutdown( + server: HttpServer, + drainMs = WEB_HTTP_DRAIN_MS, +): () => Promise { + const sockets = new Set(); + const trackSocket = (socket: Socket): void => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }; + server.on("connection", trackSocket); + + let task: Promise | null = null; + return () => { + task ??= (async () => { + try { + if (!server.listening) { + for (const socket of sockets) socket.destroy(); + return; + } + + const closed = new Promise((resolveClose, rejectClose) => { + server.close((error) => (error == null ? resolveClose() : rejectClose(error))); + }); + server.closeIdleConnections(); + + let timeout: NodeJS.Timeout | undefined; + let drained: boolean; + try { + drained = await Promise.race([ + closed.then(() => true), + new Promise((resolveTimeout) => { + timeout = setTimeout(() => resolveTimeout(false), drainMs); + }), + ]); + } finally { + if (timeout != null) clearTimeout(timeout); + } + + if (!drained) server.closeAllConnections(); + + // server.close and closeAllConnections intentionally exclude upgraded + // sockets. The connection fence covers those and older Node behavior + // as well, including when server.close resolves before the drain timer. + for (const socket of sockets) socket.destroy(); + if (!drained) { + await closed; + } + } finally { + server.off("connection", trackSocket); + } + })(); + return task; + }; +} + async function reserveTcpPort(host = HOST): Promise { const server = createTcpServer(); try { @@ -920,50 +981,20 @@ async function settleShutdownTask(task: Promise | undefined): Promise Promise): void { - const hardExit = setTimeout(() => process.exit(0), SHUTDOWN_TIMEOUT_MS + 1000); - hardExit.unref(); - void stop().finally(() => { - clearTimeout(hardExit); - process.exit(0); - }); -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function attachParentMonitor(stop: () => Promise): void { - const parentPid = Number(process.env[TOOLS_DEV_PARENT_PID_ENV]); - if (!Number.isInteger(parentPid) || parentPid <= 0) return; - - const timer = setInterval(() => { - if (isProcessAlive(parentPid)) return; - clearInterval(timer); - stopThenExit(stop); - }, 1000); - timer.unref(); -} - async function createWebSidecarHandle( - runtime: SidecarRuntimeContext, httpServer: HttpServer, closeRuntime: () => Promise | void, + portRequest: number, isRuntimeRunning?: () => boolean, ): Promise { - const port = await listen(httpServer, parsePort(process.env[WEB_PORT_ENV])); + const closeHttpServer = createWebHttpServerShutdown(httpServer); + const port = await listen(httpServer, portRequest); const state: WebStatusSnapshot = { pid: process.pid, state: "running", updatedAt: new Date().toISOString(), url: `http://${HOST}:${port}`, }; - let ipcServer: JsonIpcServerHandle | null = null; let stopped = false; let resolveStopped!: () => void; const stoppedPromise = new Promise((resolveStop) => { @@ -982,37 +1013,11 @@ async function createWebSidecarHandle( stopped = true; state.state = "stopped"; state.updatedAt = new Date().toISOString(); - await settleShutdownTask(ipcServer?.close()); - await settleShutdownTask(closeServer(httpServer)); + await closeHttpServer(); await settleShutdownTask(Promise.resolve().then(closeRuntime)); resolveStopped(); } - attachParentMonitor(stop); - - ipcServer = await createJsonIpcServer({ - socketPath: runtime.ipc, - handler: async (message: unknown) => { - const request = normalizeWebSidecarMessage(message); - switch (request.type) { - case SIDECAR_MESSAGES.STATUS: - refreshRuntimeState(); - return { ...state }; - case SIDECAR_MESSAGES.SHUTDOWN: - setImmediate(() => { - stopThenExit(stop); - }); - return { accepted: true }; - } - }, - }); - - for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.on(signal, () => { - stopThenExit(stop); - }); - } - return { async status() { refreshRuntimeState(); @@ -1064,8 +1069,9 @@ export function createDaemonProxyHandler( } async function startRegularNextSidecar( - runtime: SidecarRuntimeContext, + runtime: WebRuntimeContext, webRoot: string, + port: number, ): Promise { const dev = process.env.OD_WEB_PROD !== "1" && runtime.mode === "dev"; const app = createNextApp({ dev, dir: webRoot, ...resolveNextBundlerOptions(dev) }); @@ -1075,14 +1081,15 @@ async function startRegularNextSidecar( const handleRequest = app.getRequestHandler(); const httpServer = createHttpServer(createDaemonProxyHandler(daemonOrigin, handleRequest)); - return await createWebSidecarHandle(runtime, httpServer, async () => { + return await createWebSidecarHandle(httpServer, async () => { await app.close?.(); - }); + }, port); } async function startStandaloneNextSidecar( - runtime: SidecarRuntimeContext, + runtime: WebRuntimeContext, webRoot: string | null, + port: number, ): Promise { const daemonOrigin = resolveDaemonOrigin(); const backend = await startStandaloneBackend(webRoot); @@ -1102,19 +1109,19 @@ async function startStandaloneNextSidecar( })); try { - return await createWebSidecarHandle(runtime, httpServer, backend.stop, backend.isRunning); + return await createWebSidecarHandle(httpServer, backend.stop, port, backend.isRunning); } catch (error) { await backend.stop().catch(() => undefined); throw error; } } -export async function startWebSidecar(runtime: SidecarRuntimeContext): Promise { +export async function startWebSidecar(runtime: WebRuntimeContext, port = parsePort(process.env[WEB_PORT_ENV])): Promise { if (shouldUseStandaloneOutput(runtime)) { const webRoot = resolveConfiguredStandaloneRoot() == null ? resolveWebRoot() : null; - return await startStandaloneNextSidecar(runtime, webRoot); + return await startStandaloneNextSidecar(runtime, webRoot, port); } const webRoot = resolveWebRoot(); - return await startRegularNextSidecar(runtime, webRoot); + return await startRegularNextSidecar(runtime, webRoot, port); } diff --git a/apps/web/tests/sidecar-shutdown.test.ts b/apps/web/tests/sidecar-shutdown.test.ts new file mode 100644 index 0000000000..f8329e0d79 --- /dev/null +++ b/apps/web/tests/sidecar-shutdown.test.ts @@ -0,0 +1,90 @@ +import { createServer as createHttpServer } from 'node:http'; +import { createConnection, type AddressInfo } from 'node:net'; + +import { describe, expect, it } from 'vitest'; + +import { createWebHttpServerShutdown } from '../sidecar/server'; + +describe('web sidecar HTTP shutdown', () => { + it('force-closes a long-lived connection after the bounded drain window', async () => { + const server = createHttpServer((_request, response) => { + response.writeHead(200, { + connection: 'keep-alive', + 'content-type': 'text/event-stream', + }); + response.write('event: ready\ndata: {}\n\n'); + }); + const closeServer = createWebHttpServerShutdown(server, 25); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + const socket = createConnection(port, '127.0.0.1'); + await new Promise((resolve, reject) => { + socket.once('error', reject); + socket.once('connect', () => { + socket.write([ + 'GET /events HTTP/1.1', + `Host: 127.0.0.1:${port}`, + 'Connection: keep-alive', + '', + '', + ].join('\r\n')); + }); + socket.once('data', () => resolve()); + }); + + const socketClosed = new Promise((resolve) => socket.once('close', () => resolve())); + await expect(Promise.race([ + closeServer().then(() => 'closed'), + new Promise((resolve) => setTimeout(() => resolve('timed-out'), 500)), + ])).resolves.toBe('closed'); + await expect(socketClosed).resolves.toBeUndefined(); + expect(server.listening).toBe(false); + }); + + it('is idempotent after a fast idle shutdown', async () => { + const server = createHttpServer((_request, response) => response.end('ok')); + const closeServer = createWebHttpServerShutdown(server, 25); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + await closeServer(); + await expect(closeServer()).resolves.toBeUndefined(); + }); + + it('retires upgraded sockets even when the HTTP server closes immediately', async () => { + const server = createHttpServer(); + server.on('upgrade', (_request, socket) => { + socket.write([ + 'HTTP/1.1 101 Switching Protocols', + 'Connection: Upgrade', + 'Upgrade: test', + '', + '', + ].join('\r\n')); + }); + const closeServer = createWebHttpServerShutdown(server, 250); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + const socket = createConnection(port, '127.0.0.1'); + await new Promise((resolve, reject) => { + socket.once('error', reject); + socket.once('connect', () => { + socket.write([ + 'GET /upgrade HTTP/1.1', + `Host: 127.0.0.1:${port}`, + 'Connection: Upgrade', + 'Upgrade: test', + '', + '', + ].join('\r\n')); + }); + socket.once('data', () => resolve()); + }); + + const socketClosed = new Promise((resolve) => socket.once('close', () => resolve())); + await closeServer(); + await expect(socketClosed).resolves.toBeUndefined(); + expect(server.listening).toBe(false); + }); +}); diff --git a/docs/code-review-guidelines.md b/docs/code-review-guidelines.md index 6efd04e069..1d972d5b66 100644 --- a/docs/code-review-guidelines.md +++ b/docs/code-review-guidelines.md @@ -158,7 +158,7 @@ For any change to `packages/contracts`, `packages/sidecar-proto`, persisted SQLi - The contract/protocol/schema change lands **before** consumers wire against it (or in the same PR with both sides updated). - Changes are backwards-compatible, OR there is an explicit migration plan: schema migration script, backfill strategy, or one-release window of compatible reads. - `packages/contracts` stays free of Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, and sidecar control-plane dependencies. -- Sidecar process stamps still have exactly five fields: `app`, `mode`, `namespace`, `ipc`, `source`. +- Sidecar process stamps still have exactly five fields: `channel`, `namespace`, `source`, `mode`, `app`. - Both producers and consumers have type/test coverage of the new shape. **Block when:** @@ -296,7 +296,7 @@ Review in this order. Each priority lists the concrete checks for it. - No committed secrets, API keys, or `media-config.json` content. No widening of credential storage scope without explicit need. - Logs do not leak credentials, tokens, or full prompt payloads. -- Keep control-plane runtime state separate from daemon-owned data. Sidecar and `tools-dev` runtime/log/IPC state follows the namespace-scoped `.tmp///...` contract (with `tools-dev` logs under `.tmp/tools-dev//...`) and POSIX IPC sockets under `/tmp/open-design/ipc//.sock`. +- Keep control-plane runtime state separate from daemon-owned data. Sidecar and `tools-dev` runtime/log state follows the namespace-scoped `.tmp///...` contract (with `tools-dev` logs under `.tmp/tools-dev//...`). Private IPC endpoints are derived by `@open-design/sidecar` from the five-field stamp and current OS principal; POSIX endpoints use a principal-scoped, hashed directory under the OS temporary directory, and callers must treat the concrete path as opaque. - Daemon-owned data must derive from the daemon's already-resolved `RUNTIME_DATA_DIR`; subprocesses inherit that truth through `OD_DATA_DIR`. Review the root **Daemon data directory contract** instead of documenting or recomputing another concrete daemon data path. - For daemon, desktop, sidecar, path, log, or namespace changes, validate the relevant side of that split per `AGENTS.md`: resolved daemon data-root propagation for daemon data; concurrent namespaces, log-path inspection, and desktop `inspect eval`/`inspect screenshot` for control-plane state. diff --git a/e2e/lib/desktop/hyperframes-sidecar.ts b/e2e/lib/desktop/hyperframes-sidecar.ts new file mode 100644 index 0000000000..f2692d3f7a --- /dev/null +++ b/e2e/lib/desktop/hyperframes-sidecar.ts @@ -0,0 +1,90 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + bootstrapSidecarProcess, + isCurrentSidecarLauncher, + readCurrentSidecarStamp, + SidecarFactory, +} from '@open-design/sidecar'; +import { + SIDECAR_MESSAGES, + type DesktopRenderFramesInput, +} from '@open-design/sidecar-proto'; +import { PNG } from 'pngjs'; + +const capturePath = process.env.OD_TEST_CAPTURED_FRAME_DOCUMENT; +const dataRoot = process.env.OD_TEST_SIDECAR_DATA_ROOT; +const runtimeRoot = process.env.OD_TEST_SIDECAR_RUNTIME_ROOT; +if ([capturePath, dataRoot, runtimeRoot].some((value) => value == null || value.length === 0)) { + throw new Error('HyperFrames desktop fixture paths are required'); +} + +const stamp = readCurrentSidecarStamp(); +const resources = { + dataRoot: dataRoot!, + ownerPid: null, + port: 0, + runtimeRoot: runtimeRoot!, +}; +if (isCurrentSidecarLauncher() && await bootstrapSidecarProcess(stamp, resources)) { + process.exit(0); +} + +const framePng = solidPng(320, 180); +const client = SidecarFactory.create({ + handlers: { + async [SIDECAR_MESSAGES.RENDER_FRAMES](rawInput) { + const input = rawInput as DesktopRenderFramesInput; + await Promise.all([ + mkdir(input.outputDir, { recursive: true }), + writeFile(capturePath!, input.html, 'utf8'), + ]); + for (let frame = 0; frame < 3; frame += 1) { + await writeFile( + join(input.outputDir, `frame-${String(frame).padStart(8, '0')}.png`), + framePng, + ); + } + return { + duration: 0.1, + fps: 30, + frameCount: 3, + framePattern: join(input.outputDir, 'frame-%08d.png'), + height: 180, + ok: true, + width: 320, + }; + }, + }, + lifecycle: { + async start() { + return { pid: process.pid }; + }, + status(runtime) { + return { + capabilities: { frameRenderer: true }, + pid: runtime.pid, + state: 'running', + updatedAt: new Date().toISOString(), + url: null, + windowVisible: false, + }; + }, + async stop() {}, + }, +}); + +await client.start(); +await client.waitUntilStopped(); + +function solidPng(width: number, height: number): Buffer { + const image = new PNG({ width, height }); + for (let offset = 0; offset < image.data.length; offset += 4) { + image.data[offset] = 16; + image.data[offset + 1] = 37; + image.data[offset + 2] = 63; + image.data[offset + 3] = 255; + } + return PNG.sync.write(image); +} diff --git a/e2e/specs/linux.spec.ts b/e2e/specs/linux.spec.ts index 26b430b737..5b0d0baf67 100644 --- a/e2e/specs/linux.spec.ts +++ b/e2e/specs/linux.spec.ts @@ -316,12 +316,6 @@ async function startInstalledAppImageHeadlessAndFindRelay( appImagePath: string, expectedRelayUrl: string, ): Promise<{ launchPid: number; relayDaemonPid: number }> { - const markerPath = join(runtimeNamespaceRoot, 'runtime', 'headless-root.json'); - const webMarkerPath = join(runtimeNamespaceRoot, 'runtime', 'web-root.json'); - await Promise.all([ - rm(markerPath, { force: true }), - rm(webMarkerPath, { force: true }), - ]); const env: NodeJS.ProcessEnv = { ...process.env, OD_PACKAGED_NAMESPACE: namespace, @@ -341,21 +335,13 @@ async function startInstalledAppImageHeadlessAndFindRelay( try { const timeoutMs = 90_000; const startedAt = Date.now(); - let lastState = 'headless identity not written'; + let lastState = `no descendant of AppImage launch pid ${launchPid} has the baked relay`; while (Date.now() - startedAt < timeoutMs) { if (child.exitCode != null) { throw new Error(`AppImage --headless exited before relay observation (code ${child.exitCode})`); } - const rootPid = await readHeadlessRootPid(markerPath); - if (rootPid != null) { - if (!await isProcessDescendant(rootPid, launchPid)) { - lastState = `headless marker pid ${rootPid} does not belong to launch pid ${launchPid}`; - } else { - const relayPid = await findDescendantWithRelay(rootPid, expectedRelayUrl); - if (relayPid != null) return { launchPid, relayDaemonPid: relayPid }; - lastState = `headless root pid ${rootPid} is live but no descendant has the baked relay`; - } - } + const relayPid = await findDescendantWithRelay(launchPid, expectedRelayUrl); + if (relayPid != null) return { launchPid, relayDaemonPid: relayPid }; await delay(200); } throw new Error(`AppImage --headless did not pass its baked relay to the daemon: ${lastState}`); @@ -369,15 +355,6 @@ async function startInstalledAppImageHeadlessAndFindRelay( } } -async function readHeadlessRootPid(markerPath: string): Promise { - try { - const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { pid?: unknown }; - return typeof marker.pid === 'number' && Number.isSafeInteger(marker.pid) ? marker.pid : null; - } catch { - return null; - } -} - async function findDescendantWithRelay(rootPid: number, expectedRelayUrl: string): Promise { const entries = await readdir('/proc', { withFileTypes: true }); for (const entry of entries) { diff --git a/e2e/specs/mac.spec.ts b/e2e/specs/mac.spec.ts index 0a38455ef2..40b65f17d9 100644 --- a/e2e/specs/mac.spec.ts +++ b/e2e/specs/mac.spec.ts @@ -219,6 +219,7 @@ const packagedOnboardingExpression = ` `; type DesktopStatus = { + executablePath?: string; pid?: number; state?: string; title?: string | null; @@ -2606,18 +2607,14 @@ async function waitForUpdaterPopupMatching( } async function readDesktopIdentityMarker(): Promise { - const markerPath = join(runtimeNamespaceRoot, 'runtime', 'desktop-root.json'); - const value = JSON.parse(await readFile(markerPath, 'utf8')) as unknown; - if ( - !isRecord(value) || - typeof value.appPath !== 'string' || - typeof value.executablePath !== 'string' || - typeof value.pid !== 'number' || - value.version !== 1 - ) { - throw new Error(`invalid packaged desktop identity at ${markerPath}: ${formatUnknown(value)}`); + const status = (await runToolsPackJson('inspect')).status; + if (typeof status?.executablePath !== 'string' || typeof status.pid !== 'number') { + throw new Error(`invalid packaged desktop sidecar status: ${formatUnknown(status)}`); } - return value as DesktopIdentityMarker; + const marker = '/Contents/MacOS/'; + const markerIndex = status.executablePath.indexOf(marker); + const appPath = markerIndex < 0 ? status.executablePath : status.executablePath.slice(0, markerIndex); + return { appPath, executablePath: status.executablePath, pid: status.pid, version: 1 }; } function assertPayloadDesktopIdentity( @@ -2849,7 +2846,12 @@ async function fileSizeBytes(filePath: string): Promise { } async function seedPackagedOnboardingComplete(): Promise { - await seedPackagedAppConfig({ onboardingCompleted: true }); + // Updater flows need the ordinary signed-out Home shell. Completion alone + // is insufficient when the daemon default selects the AMR cloud agent: the + // product correctly routes that signed-out identity back to Connect even + // though first-run onboarding was completed. Pin a local agent so this + // fixture models the actual post-onboarding state it claims to create. + await seedPackagedAppConfig({ agentId: 'codex', onboardingCompleted: true }); } async function seedPackagedHomeFirstRunConfig( diff --git a/e2e/specs/namespace/main.spec.ts b/e2e/specs/namespace/main.spec.ts index f08589e24c..b428e4bc7e 100644 --- a/e2e/specs/namespace/main.spec.ts +++ b/e2e/specs/namespace/main.spec.ts @@ -40,7 +40,7 @@ type SseEvent = { }; describe('namespace isolation spec', () => { - test('keeps namespace in lifecycle infrastructure while daemon clients use URL or concrete IPC transport', async () => { + test('keeps namespace in lifecycle infrastructure while daemon clients use URL or an opaque sidecar capability', async () => { const suite = await createSmokeSuite('namespace-isolation'); await suite.with.toolsDev(async ({ runtime, status, webUrl }) => { @@ -52,11 +52,14 @@ describe('namespace isolation spec', () => { expect(daemonStatus).not.toHaveProperty('namespace'); const installInfo = await requestJson(webUrl, '/api/mcp/install-info'); - const ipcPath = installInfo.env.OD_SIDECAR_IPC_PATH; - if (typeof ipcPath !== 'string' || ipcPath.length === 0) { - throw new Error('MCP install-info did not include OD_SIDECAR_IPC_PATH'); + const clientEndpoint = installInfo.env.OD_SIDECAR_CLIENT_ENDPOINT; + if (typeof clientEndpoint !== 'string' || clientEndpoint.length === 0) { + throw new Error('MCP install-info did not include the opaque sidecar client capability'); } - expect(ipcPath).toEqual(expect.any(String)); + expect(Object.keys(installInfo.env).sort()).toEqual([ + 'OD_DATA_DIR', + 'OD_SIDECAR_CLIENT_ENDPOINT', + ]); expect(installInfo.args).not.toContain('--daemon-url'); expect(installInfo.env.OD_DATA_DIR).toBe(suite.dataDir); expect(installInfo.env).not.toHaveProperty('OD_NAMESPACE'); @@ -69,7 +72,7 @@ describe('namespace isolation spec', () => { OD_DATA_DIR: suite.dataDir, OD_NAMESPACE: 'wrong-daemon-namespace', OD_SIDECAR_IPC_BASE: path.join(suite.scratchDir, 'wrong-ipc-base'), - OD_SIDECAR_IPC_PATH: ipcPath, + OD_SIDECAR_CLIENT_ENDPOINT: clientEndpoint, OD_SIDECAR_NAMESPACE: 'wrong-sidecar-namespace', }, ); @@ -79,7 +82,7 @@ describe('namespace isolation spec', () => { const rejected = await runDaemonCliExpectFailure( ['daemon', 'status', '--json', '--namespace', 'should-not-parse'], - { OD_SIDECAR_IPC_PATH: ipcPath }, + { OD_SIDECAR_CLIENT_ENDPOINT: clientEndpoint }, ); expect(`${rejected.stdout}\n${rejected.stderr}`).toContain('unknown flag: --namespace'); @@ -110,7 +113,6 @@ describe('namespace isolation spec', () => { installInfo: { args: installInfo.args, envKeys: Object.keys(installInfo.env).sort(), - ipcPath, }, plugin: pluginInfo, runtime, diff --git a/e2e/specs/win.spec.ts b/e2e/specs/win.spec.ts index 6813131673..8894bb21c6 100644 --- a/e2e/specs/win.spec.ts +++ b/e2e/specs/win.spec.ts @@ -320,6 +320,7 @@ const packagedOnboardingExpression = ` `; type DesktopStatus = { + executablePath?: string; pid?: number; state?: string; title?: string | null; @@ -2479,18 +2480,11 @@ async function printLauncherRuntimeSnapshot(): Promise { } async function readDesktopIdentityMarker(): Promise { - const markerPath = join(runtimeNamespaceRoot, 'runtime', 'desktop-root.json'); - const value = JSON.parse(await readFile(markerPath, 'utf8')) as unknown; - if ( - !isRecord(value) || - typeof value.appPath !== 'string' || - typeof value.executablePath !== 'string' || - typeof value.pid !== 'number' || - value.version !== 1 - ) { - throw new Error(`invalid packaged desktop identity at ${markerPath}: ${formatUnknown(value)}`); + const status = (await runToolsPackJson('inspect')).status; + if (typeof status?.executablePath !== 'string' || typeof status.pid !== 'number') { + throw new Error(`invalid packaged desktop sidecar status: ${formatUnknown(status)}`); } - return value as DesktopIdentityMarker; + return { appPath: status.executablePath, executablePath: status.executablePath, pid: status.pid, version: 1 }; } async function assertPayloadDesktopIdentity( @@ -2758,7 +2752,15 @@ async function seedPackagedOnboardingComplete(): Promise { // the macOS smoke's seed, which already writes under runtimeNamespaceRoot. const configPath = join(runtimeNamespaceRoot, 'data', 'app-config.json'); await mkdir(dirname(configPath), { recursive: true }); - await writeFile(configPath, `${JSON.stringify({ onboardingCompleted: true }, null, 2)}\n`, 'utf8'); + // Completion alone is insufficient when the daemon default selects the AMR + // cloud agent: a signed-out cloud identity correctly returns to Connect. + // Updater acceptance needs the ordinary signed-out Home shell, so pin the + // local agent that makes this fixture's postcondition complete. + await writeFile( + configPath, + `${JSON.stringify({ agentId: 'codex', onboardingCompleted: true }, null, 2)}\n`, + 'utf8', + ); } function isPathInside(filePath: string, expectedRoot: string): boolean { diff --git a/e2e/tests/hyperframes-runtime-render.test.ts b/e2e/tests/hyperframes-runtime-render.test.ts index 0dc7e0f277..265201f847 100644 --- a/e2e/tests/hyperframes-runtime-render.test.ts +++ b/e2e/tests/hyperframes-runtime-render.test.ts @@ -7,71 +7,51 @@ import { fileURLToPath } from 'node:url'; import ffmpegInstaller from '@ffmpeg-installer/ffmpeg'; import ffprobeInstaller from '@ffprobe-installer/ffprobe'; -import { PNG } from 'pngjs'; import { - createJsonIpcServer, - resolveAppIpcPath, + convergeSidecarLaunch, + stopSidecar, } from '@open-design/sidecar'; import { APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, - SIDECAR_MESSAGES, - normalizeDesktopSidecarMessage, - type DesktopRenderFramesInput, + SIDECAR_MODES, + SIDECAR_SOURCES, } from '@open-design/sidecar-proto'; import { describe, expect, test } from 'vitest'; import { createSmokeSuite } from '@/vitest/suite'; const odBin = fileURLToPath(new URL('../../apps/daemon/bin/od.mjs', import.meta.url)); +const desktopFixture = fileURLToPath(new URL('../lib/desktop/hyperframes-sidecar.ts', import.meta.url)); describe('HyperFrames bundled runtime end-to-end', () => { test('[P0] real od media generate renders MP4 through the daemon-owned HyperFrames runtime', async () => { const suite = await createSmokeSuite('hyperframes-runtime-render'); - const desktopIpc = resolveAppIpcPath({ + const desktopStamp = { app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, + channel: 'local', + mode: SIDECAR_MODES.DEV, namespace: suite.namespace, - }); - let receivedFrameDocument = ''; - const framePng = solidPng(320, 180); - const desktop = await createJsonIpcServer({ - socketPath: desktopIpc, - handler: async (message) => { - const request = normalizeDesktopSidecarMessage(message); - if (request.type === SIDECAR_MESSAGES.STATUS) { - return { - capabilities: { frameRenderer: true }, - pid: process.pid, - state: 'running', - updatedAt: new Date().toISOString(), - url: null, - windowVisible: false, - }; - } - if (request.type === SIDECAR_MESSAGES.RENDER_FRAMES) { - const input = request.input as DesktopRenderFramesInput; - receivedFrameDocument = input.html; - await mkdir(input.outputDir, { recursive: true }); - for (let frame = 0; frame < 3; frame += 1) { - await writeFile( - join(input.outputDir, `frame-${String(frame).padStart(8, '0')}.png`), - framePng, - ); - } - return { - duration: 0.1, - fps: 30, - frameCount: 3, - framePattern: join(input.outputDir, 'frame-%08d.png'), - height: 180, - ok: true, - width: 320, - }; - } - throw new Error(`unexpected desktop request: ${request.type}`); + source: SIDECAR_SOURCES.TOOLS_DEV, + }; + const capturedFrameDocument = join(suite.dataDir, 'captured-frame-document.html'); + await convergeSidecarLaunch({ + args: ['--import', 'tsx', desktopFixture], + command: process.execPath, + env: { + ...process.env, + OD_TEST_CAPTURED_FRAME_DOCUMENT: capturedFrameDocument, + OD_TEST_SIDECAR_DATA_ROOT: suite.dataDir, + OD_TEST_SIDECAR_RUNTIME_ROOT: join(suite.dataDir, 'runtime'), }, - }); + logFd: null, + resources: { + dataRoot: suite.dataDir, + ownerPid: null, + port: 0, + runtimeRoot: join(suite.dataDir, 'runtime'), + }, + stamp: desktopStamp, + }, { stabilityMs: 100, timeoutMs: 10_000 }); try { await suite.with.env( @@ -148,27 +128,16 @@ window.__timelines.main={duration:function(){return .1},seek:function(){}}; ]); expect(outputStat.size).toBe(payload.file?.size); expect(bytes.subarray(4, 8).toString('ascii')).toBe('ftyp'); - expect(receivedFrameDocument).toContain('window.__odFrameRenderer'); + expect(await readFile(capturedFrameDocument, 'utf8')).toContain('window.__odFrameRenderer'); }); }, ); } finally { - await desktop.close(); + await stopSidecar(desktopStamp); } }, 180_000); }); -function solidPng(width: number, height: number): Buffer { - const image = new PNG({ width, height }); - for (let offset = 0; offset < image.data.length; offset += 4) { - image.data[offset] = 16; - image.data[offset + 1] = 37; - image.data[offset + 2] = 63; - image.data[offset + 3] = 255; - } - return PNG.sync.write(image); -} - async function runOd( daemonUrl: string, args: string[], diff --git a/e2e/tests/packaged-launcher-update-loop.test.ts b/e2e/tests/packaged-launcher-update-loop.test.ts index 165d2dcfeb..c4da9bfbed 100644 --- a/e2e/tests/packaged-launcher-update-loop.test.ts +++ b/e2e/tests/packaged-launcher-update-loop.test.ts @@ -185,55 +185,41 @@ async function loadPackagedSidecarsModule(): Promise { } const FAKE_SIDECAR_SOURCE = String.raw` -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; -const appArgument = process.argv.find((argument) => argument.startsWith("--od-stamp-app=")); -const app = appArgument == null ? null : appArgument.slice("--od-stamp-app=".length); -const socketPath = process.env.OD_SIDECAR_IPC_PATH; -if (app == null || socketPath == null) throw new Error("missing sidecar identity"); -if (app === "daemon") { - const dataRoot = process.env.OD_DATA_DIR; - if (dataRoot == null) throw new Error("missing daemon data root"); - await mkdir(dataRoot, { recursive: true }); - await writeFile( - join(dataRoot, "captured-daemon-env.json"), - JSON.stringify({ telemetryRelayUrl: process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL ?? null }), - ); -} -if (!socketPath.startsWith("\\\\.\\pipe\\")) { - await mkdir(dirname(socketPath), { recursive: true }); - await rm(socketPath, { force: true }); -} -const server = createServer((socket) => { - let buffer = ""; - socket.on("data", (chunk) => { - buffer += chunk.toString("utf8"); - const newline = buffer.indexOf("\n"); - if (newline < 0) return; - const request = JSON.parse(buffer.slice(0, newline)); - const shutdown = request.type === "shutdown"; - const result = request.type === "status" - ? { - pid: process.pid, - state: "running", - updatedAt: new Date().toISOString(), - url: app === "daemon" ? "http://127.0.0.1:43101" : "http://127.0.0.1:43102", - } - : { accepted: true }; - socket.end(JSON.stringify({ ok: true, result }) + "\n", () => { - if (shutdown) server.close(() => process.exit(0)); - }); - }); +import { SidecarFactory } from ${JSON.stringify(new URL("../../packages/sidecar/dist/index.mjs", import.meta.url).href)}; + +const client = SidecarFactory.create({ + handlers: { + "register-web-url": () => ({ accepted: true }), + }, + lifecycle: { + async start(resources) { + const app = client.stamp.app; + if (app === "daemon") { + await mkdir(resources.dataRoot, { recursive: true }); + await writeFile( + join(resources.dataRoot, "captured-daemon-env.json"), + JSON.stringify({ telemetryRelayUrl: process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL ?? null }), + ); + } + return { + pid: process.pid, + state: "running", + updatedAt: new Date().toISOString(), + url: app === "daemon" ? "http://127.0.0.1:43101" : "http://127.0.0.1:43102", + }; + }, + status(runtime) { + return runtime; + }, + async stop() {}, + }, }); -await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(socketPath, resolve); -}); -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => server.close(() => process.exit(0))); -} + +await client.start(); +await client.waitUntilStopped(); `; function fakePackagedConfig(root: string, testCase: PlatformCase): PackagedConfigLike { diff --git a/packages/AGENTS.md b/packages/AGENTS.md index f6ef6c86bf..c174961a89 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -15,10 +15,10 @@ Follow the root `AGENTS.md` first. This file only records module-level boundarie - `packages/plugin-runtime`: pure TypeScript plugin manifest/marketplace parsers, source adapters, merge/ref resolution, validation, digesting, and pipeline-fallback selection. Daemon, web, and CI inject I/O rather than adding filesystem access here. - `packages/registry-protocol`: pure TypeScript plugin-registry backend protocol and schemas. Owns backend list/search/resolve/manifest/doctor plus optional publish/yank interfaces, not concrete network or storage integrations. - `packages/release`: pure release-domain primitives. Owns release channel names, version parsing/formatting, metadata field derivation, storage prefixes, release namespaces, and app identity data. It must not read/write files, call GitHub/R2, spawn build tools, or own workflow execution. -- `packages/sidecar-proto`: OpenDesign sidecar business protocol. Owns app/mode/source constants, namespace validation, stamp descriptor/fields/flags, IPC message schema, status shapes, error semantics, and default product path constants. -- `packages/sidecar`: generic sidecar runtime primitives. Includes bootstrap, IPC transport, path/runtime resolution, launch env, and JSON runtime file helpers; it must not hard-code OpenDesign app keys or IPC business messages. +- `packages/sidecar-proto`: OpenDesign sidecar business protocol. Owns business action names and DTO/status shapes; it does not own process identity, private transport, or lifecycle mechanics. +- `packages/sidecar`: complete business-agnostic sidecar client boundary and protocol implementation. Owns the five-field identity, private transport and endpoint derivation, resource ownership, generation fencing, and lifecycle atomics; it must not hard-code OpenDesign app keys or IPC business messages. - `packages/standalone`: shell-neutral exact distribution protocol and runtime. It owns signed metadata validation, canonical digests, content-addressed materialization, namespace bindings, generation state, required/lazy resolution, and fossil-to-versioned launcher handoff. It must not depend on a product app or shell. -- `packages/platform`: generic OS process primitives. Includes stamp serialization, command parsing, process matching/search, and well-known user-toolchain bin discovery; it must consume the `sidecar-proto` descriptor and must not hard-code `--od-stamp-*` details. The toolchain helper is the single source of truth shared by the daemon runtime executable resolver (`apps/daemon/src/runtimes/executables.ts`) and the packaged sidecar PATH builder (`apps/packaged/src/sidecars.ts`) so neither layer can drift the search list. +- `packages/platform`: generic OS process primitives only. Includes generic process-contract serialization, command parsing, process matching/search, and well-known user-toolchain bin discovery; it must not own sidecar stamp fields or hard-code `--od-stamp-*` details. The toolchain helper is the single source of truth shared by the daemon runtime executable resolver (`apps/daemon/src/runtimes/executables.ts`) and the packaged sidecar PATH builder (`apps/packaged/src/sidecars.ts`) so neither layer can drift the search list. ## Removed directories @@ -31,7 +31,7 @@ Follow the root `AGENTS.md` first. This file only records module-level boundarie - Keep cross-runtime DTO and plugin wire-shape validation schemas in `contracts` when callers need the same runtime parser, while keeping app-specific parsing, I/O, and enforcement in the owning app or package. - Do not let app packages depend directly on sidecar control-plane details. - Do not hard-code OpenDesign app/source/mode constants in `sidecar` or `platform`. -- Keep stamp fields limited to five: `app`, `mode`, `namespace`, `ipc`, and `source`. +- Keep stamp fields limited to five: `channel`, `namespace`, `source`, `mode`, and `app`. IPC is private implementation detail and is never a stamp field. ## Common package commands diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 812dd4d189..6ac5786053 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -32,12 +32,18 @@ export type { ProcessStampField, ProcessStampShape, SpawnProcessRequest, + StampedProcessInvocationSnapshot, + StampedProcessSetInvocationSnapshot, StampedProcessMatchCriteria, StopProcessesOptions, StopProcessesResult, } from "./process.js"; export { collectProcessTreePids, + captureProcessSnapshot, + captureProcessSnapshotsByPids, + captureStampedProcessSnapshot, + captureStampedProcessSetSnapshot, createProcessStampArgs, isProcessAlive, listProcessSnapshots, @@ -47,6 +53,7 @@ export { readFlagValue, readProcessStamp, readProcessStampFromCommand, + signalProcesses, spawnBackgroundProcess, spawnLoggedProcess, stopProcesses, diff --git a/packages/platform/src/process.ts b/packages/platform/src/process.ts index cd38ad9896..5b722f8b8e 100644 --- a/packages/platform/src/process.ts +++ b/packages/platform/src/process.ts @@ -40,6 +40,19 @@ export type ProcessSnapshot = { command: string; pid: number; ppid: number; + /** OS-reported process creation time when the enumeration backend can provide it. */ + startedAtMs?: number; +}; + +export type StampedProcessInvocationSnapshot = { + matches: ProcessSnapshot[]; + processes: ProcessSnapshot[]; + roots: ProcessSnapshot[]; +}; + +export type StampedProcessSetInvocationSnapshot = { + entries: Array; + processes: ProcessSnapshot[]; }; export type StampedProcessMatchCriteria = Partial; @@ -94,6 +107,7 @@ type WindowsProcessRecord = { CommandLine?: string | null; ParentProcessId?: number | string | null; ProcessId?: number | string | null; + StartedAtMs?: number | string | null; }; /** @internal Extract a Node `error.code` as a string, or `null` when the value carries no code. */ @@ -348,7 +362,7 @@ async function listPosixProcessSnapshots(): Promise { async function listWindowsProcessSnapshots(): Promise { const command = [ "$ErrorActionPreference = 'Stop'", - "Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, CommandLine | ConvertTo-Json -Compress", + "Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, CommandLine, @{Name='StartedAtMs';Expression={([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds()}} | ConvertTo-Json -Compress", ].join("; "); const stdout = await new Promise((resolveList, rejectList) => { execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, out) => { @@ -356,6 +370,33 @@ async function listWindowsProcessSnapshots(): Promise { else resolveList(out); }); }); + return parseWindowsProcessSnapshots(stdout); +} + +/** Capture exact PIDs without paying Windows' full Win32_Process enumeration cost. */ +export async function captureProcessSnapshotsByPids(pids: readonly number[]): Promise { + const exactPids = [...new Set(pids.filter((pid) => Number.isSafeInteger(pid) && pid > 0))]; + if (exactPids.length === 0) return []; + if (process.platform !== "win32") { + const wanted = new Set(exactPids); + return (await captureProcessSnapshot()).filter(({ pid }) => wanted.has(pid)); + } + const filter = exactPids.map((pid) => `ProcessId = ${pid}`).join(" OR "); + const command = [ + "$ErrorActionPreference = 'Stop'", + `Get-CimInstance Win32_Process -Filter \"${filter}\" | Select-Object ProcessId, ParentProcessId, CommandLine, @{Name='StartedAtMs';Expression={([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds()}} | ConvertTo-Json -Compress`, + ].join("; "); + const stdout = await new Promise((resolveList, rejectList) => { + execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8", maxBuffer: 1024 * 1024 }, (error, out) => { + if (error) rejectList(error); + else resolveList(out); + }); + }); + return parseWindowsProcessSnapshots(stdout); +} + +/** @internal Parse the JSON emitted by the Windows process enumeration command. */ +export function parseWindowsProcessSnapshots(stdout: string): ProcessSnapshot[] { const payload = stdout.trim(); if (!payload) return []; const records = JSON.parse(payload) as WindowsProcessRecord | WindowsProcessRecord[]; @@ -363,13 +404,30 @@ async function listWindowsProcessSnapshots(): Promise { .map((record) => { const pid = Number(record.ProcessId); const ppid = Number(record.ParentProcessId); + const startedAtMs = Number(record.StartedAtMs); const commandLine = record.CommandLine?.trim(); if (!commandLine || Number.isNaN(pid) || Number.isNaN(ppid)) return null; - return { command: commandLine, pid, ppid }; + return { + command: commandLine, + pid, + ppid, + ...(Number.isSafeInteger(startedAtMs) && startedAtMs > 0 ? { startedAtMs } : {}), + }; }) .filter((snapshot): snapshot is ProcessSnapshot => snapshot != null); } +/** + * Capture the current process table without converting backend failure into an + * empty snapshot. Mutation paths use this strict form so discovery failure can + * never be mistaken for an already-stopped process set. + */ +export async function captureProcessSnapshot(): Promise { + return process.platform === "win32" + ? await listWindowsProcessSnapshots() + : await listPosixProcessSnapshots(); +} + /** * Enumerate all running processes as `{ pid, ppid, command }` snapshots, using * the platform-appropriate backend. Returns an empty list on any failure. @@ -378,14 +436,149 @@ async function listWindowsProcessSnapshots(): Promise { */ export async function listProcessSnapshots(): Promise { try { - return process.platform === "win32" - ? await listWindowsProcessSnapshots() - : await listPosixProcessSnapshots(); + return await captureProcessSnapshot(); } catch { return []; } } +/** + * Select stamped processes that unambiguously existed before an operation's + * invocation boundary. Windows process enumeration is asynchronous, so its + * results are fenced by the OS creation time. A matching record exactly on the + * boundary, or without a creation time, is deliberately rejected instead of + * risking that a newer generation is terminated. + * + * @internal Exported from this module for deterministic boundary tests; the + * package barrel exposes only `captureStampedProcessSnapshot`. + */ +export function selectStampedProcessesAtInvocation< + TStamp extends ProcessStampShape, + TCriteria extends Partial = Partial, +>( + snapshots: ProcessSnapshot[], + criteria: TCriteria | undefined, + contract: ProcessStampContract, + invokedAtMs: number, + platform: NodeJS.Platform = process.platform, +): ProcessSnapshot[] { + const matches = snapshots.filter((snapshot) => matchesStampedProcess(snapshot, criteria, contract)); + if (platform !== "win32") return matches; + return matches.filter((snapshot) => { + if (snapshot.startedAtMs == null || snapshot.startedAtMs === invokedAtMs) { + throw new Error(`cannot establish process generation boundary for pid ${snapshot.pid}`); + } + if (snapshot.startedAtMs > invokedAtMs) return false; + return snapshot.startedAtMs < invokedAtMs; + }); +} + +/** + * Capture the process table and argv-stamped roots that safely belonged to the + * generation visible when this call began. Unlike `listProcessSnapshots`, + * enumeration and boundary failures are surfaced so lifecycle callers can + * quick-fail instead of reporting a false `alreadyStopped` result. + */ +export async function captureStampedProcessSnapshot< + TStamp extends ProcessStampShape, + TCriteria extends Partial = Partial, +>( + criteria: TCriteria | undefined, + contract: ProcessStampContract, +): Promise { + const invokedAtMs = Date.now(); + const processes = await captureProcessSnapshot(); + const matches = selectStampedProcessesAtInvocation(processes, criteria, contract, invokedAtMs); + const matchedPids = new Set(matches.map(({ pid }) => pid)); + return { + matches, + processes, + roots: matches.filter(({ ppid }) => !matchedPids.has(ppid)), + }; +} + +/** + * Capture one invocation-fenced process table for several stamp criteria. + * Lifecycle operations use this form when a logical resource spans multiple + * stamped generations: every member is observed at the same boundary and a + * slow platform backend (notably Windows CIM) is paid only once. + */ +export async function captureStampedProcessSetSnapshot< + TStamp extends ProcessStampShape, + TCriteria extends Partial = Partial, +>( + criteriaSet: readonly TCriteria[], + contract: ProcessStampContract, +): Promise> { + const invokedAtMs = Date.now(); + const processes = process.platform === "win32" + ? await captureWindowsStampedProcessTrees(criteriaSet, contract) + : await captureProcessSnapshot(); + return { + entries: criteriaSet.map((criteria) => { + const matches = selectStampedProcessesAtInvocation( + processes, + criteria, + contract, + invokedAtMs, + ); + const matchedPids = new Set(matches.map(({ pid }) => pid)); + return { + criteria, + matches, + processes, + roots: matches.filter(({ ppid }) => !matchedPids.has(ppid)), + }; + }), + processes, + }; +} + +/** Query stamped Windows roots and only their descendant trees in one shell. */ +async function captureWindowsStampedProcessTrees< + TStamp extends ProcessStampShape, + TCriteria extends Partial, +>( + criteriaSet: readonly TCriteria[], + contract: ProcessStampContract, +): Promise { + if (criteriaSet.length === 0) return []; + const clauses = criteriaSet.map((criteria) => { + const normalized = contract.normalizeStampCriteria(criteria); + const fields = contract.stampFields.flatMap((field) => { + const value = normalized[field]; + if (typeof value !== "string") return []; + const needle = `${contract.stampFlags[field]}=${value}`.replaceAll("'", "''"); + return [`CommandLine LIKE '%${needle}%'`]; + }); + return fields.length === 0 ? "CommandLine IS NOT NULL" : `(${fields.join(" AND ")})`; + }); + const rootFilter = [...new Set(clauses)].join(" OR "); + const powershellRootFilter = `'${rootFilter.replaceAll("'", "''")}'`; + const command = [ + "$ErrorActionPreference = 'Stop'", + `$roots = @(Get-CimInstance Win32_Process -Filter ${powershellRootFilter})`, + "$all = [System.Collections.Generic.List[object]]::new()", + "$seen = @{}", + "$frontier = [System.Collections.Generic.List[int]]::new()", + "foreach ($process in $roots) { $pidValue = [int]$process.ProcessId; if (-not $seen.ContainsKey($pidValue)) { $seen[$pidValue] = $true; $all.Add($process); $frontier.Add($pidValue) } }", + "while ($frontier.Count -gt 0) {", + " $childFilter = (($frontier | ForEach-Object { \"ParentProcessId = $_\" }) -join ' OR ')", + " $frontier = [System.Collections.Generic.List[int]]::new()", + " $children = @(Get-CimInstance Win32_Process -Filter $childFilter)", + " foreach ($process in $children) { $pidValue = [int]$process.ProcessId; if (-not $seen.ContainsKey($pidValue)) { $seen[$pidValue] = $true; $all.Add($process); $frontier.Add($pidValue) } }", + "}", + "$all | Select-Object ProcessId, ParentProcessId, CommandLine, @{Name='StartedAtMs';Expression={([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds()}} | ConvertTo-Json -Compress", + ].join("; "); + const stdout = await new Promise((resolveList, rejectList) => { + execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, out) => { + if (error) rejectList(error); + else resolveList(out); + }); + }); + return parseWindowsProcessSnapshots(stdout); +} + /** * Collect the transitive set of descendant PIDs (including the roots) from a * process snapshot list, returned sorted descending so children precede parents. @@ -417,8 +610,8 @@ export function collectProcessTreePids( return [...visited].sort((left, right) => right - left); } -/** @internal Send a signal to each PID, ignoring `ESRCH` (already-dead) but rethrowing other errors. */ -function signalProcesses(pids: number[], signal: NodeJS.Signals): void { +/** Send a signal to each PID, ignoring `ESRCH` (already-dead) but rethrowing other errors. */ +export function signalProcesses(pids: number[], signal: NodeJS.Signals): void { for (const pid of pids) { try { process.kill(pid, signal); diff --git a/packages/platform/tests/process-tree.test.ts b/packages/platform/tests/process-tree.test.ts index a437585941..510c2ed4ec 100644 --- a/packages/platform/tests/process-tree.test.ts +++ b/packages/platform/tests/process-tree.test.ts @@ -6,14 +6,32 @@ import { collectProcessTreePids, processCommandExactlyRunsExecutable, stopProcesses, + type ProcessStampContract, type ProcessSnapshot, waitForProcessExit, } from "../src/index.js"; +import { parseWindowsProcessSnapshots, selectStampedProcessesAtInvocation } from "../src/process.js"; function snapshot(pid: number, ppid: number, command = `pid-${pid}`): ProcessSnapshot { return { command, pid, ppid }; } +type TestStamp = { namespace: string }; + +const stampContract: ProcessStampContract = { + normalizeStamp(input) { + const namespace = (input as Partial).namespace; + if (typeof namespace !== "string" || namespace.length === 0) throw new Error("invalid namespace"); + return { namespace }; + }, + normalizeStampCriteria(input = {}) { + const namespace = (input as Partial).namespace; + return namespace == null ? {} : { namespace }; + }, + stampFields: ["namespace"], + stampFlags: { namespace: "--test-namespace" }, +}; + describe("collectProcessTreePids", () => { it("returns an empty array when no roots are supplied", () => { expect(collectProcessTreePids([snapshot(100, 1), snapshot(101, 100)], [])).toEqual([]); @@ -48,6 +66,66 @@ describe("collectProcessTreePids", () => { }); }); +describe("selectStampedProcessesAtInvocation", () => { + const command = "node fixture.js --test-namespace=alpha"; + + it("keeps only matching Windows processes created before the invocation boundary", () => { + expect(selectStampedProcessesAtInvocation([ + { command, pid: 100, ppid: 1, startedAtMs: 900 }, + { command, pid: 200, ppid: 1, startedAtMs: 1_100 }, + { command: "node unrelated.js", pid: 300, ppid: 1 }, + ], { namespace: "alpha" }, stampContract, 1_000, "win32")).toEqual([ + { command, pid: 100, ppid: 1, startedAtMs: 900 }, + ]); + }); + + it("quick-fails when a matching Windows process has an ambiguous creation boundary", () => { + expect(() => selectStampedProcessesAtInvocation([ + { command, pid: 100, ppid: 1 }, + ], { namespace: "alpha" }, stampContract, 1_000, "win32")).toThrow( + "cannot establish process generation boundary for pid 100", + ); + + expect(() => selectStampedProcessesAtInvocation([ + { command, pid: 200, ppid: 1, startedAtMs: 1_000 }, + ], { namespace: "alpha" }, stampContract, 1_000, "win32")).toThrow( + "cannot establish process generation boundary for pid 200", + ); + }); + + it("does not require Windows creation metadata from unrelated processes", () => { + expect(selectStampedProcessesAtInvocation([ + { command: "node unrelated.js", pid: 300, ppid: 1 }, + ], { namespace: "alpha" }, stampContract, 1_000, "win32")).toEqual([]); + }); + +}); + +describe("parseWindowsProcessSnapshots", () => { + it("retains the OS creation time used by the generation boundary", () => { + expect(parseWindowsProcessSnapshots(JSON.stringify({ + CommandLine: "node fixture.js", + ParentProcessId: 10, + ProcessId: 20, + StartedAtMs: "1724490000123", + }))).toEqual([{ + command: "node fixture.js", + pid: 20, + ppid: 10, + startedAtMs: 1_724_490_000_123, + }]); + }); + + it("leaves invalid creation metadata explicit for boundary quick-fail", () => { + expect(parseWindowsProcessSnapshots(JSON.stringify({ + CommandLine: "node fixture.js", + ParentProcessId: 10, + ProcessId: 20, + StartedAtMs: null, + }))).toEqual([{ command: "node fixture.js", pid: 20, ppid: 10 }]); + }); +}); + describe("processCommandExactlyRunsExecutable", () => { it("accepts exact POSIX and quoted Windows executable commands", () => { expect(processCommandExactlyRunsExecutable( diff --git a/packages/sidecar-proto/src/index.ts b/packages/sidecar-proto/src/index.ts index 53c6db2abc..b0491cdf84 100644 --- a/packages/sidecar-proto/src/index.ts +++ b/packages/sidecar-proto/src/index.ts @@ -186,6 +186,7 @@ export type WebStatusSnapshot = { export type DesktopRuntimeState = "idle" | "running" | "unknown"; export type DesktopStatusSnapshot = { + executablePath?: string; capabilities?: { /** Hidden Electron Chromium can deterministically capture authored frame timelines. */ frameRenderer?: boolean; @@ -638,15 +639,14 @@ export type DesktopSidecarMessage = export type ShutdownResult = { accepted: true; - /** - * When true, the sidecar accepted shutdown but is holding process exit for - * critical work (for example a handoff journal commit). The owner should - * wait a longer bounded grace for self-exit before force-stopping. - */ - deferred?: boolean; }; -export type SidecarStamp = { +/** + * Legacy runtime-layout descriptor retained for the generic path/bootstrap + * contract. This is not sidecar process identity: `@open-design/sidecar` owns + * the authoritative five-field argv stamp, and IPC is private transport state. + */ +export type LegacySidecarRuntimeLayout = { app: AppKey; ipc: string; mode: SidecarMode; @@ -654,8 +654,8 @@ export type SidecarStamp = { source: SidecarSource; }; -export type SidecarStampInput = Partial>; -export type SidecarStampCriteria = Partial; +type LegacySidecarRuntimeLayoutInput = Partial>; +type LegacySidecarRuntimeLayoutCriteria = Partial; export type OpenDesignSidecarContract = { appKeys: typeof APP_KEYS; @@ -667,8 +667,8 @@ export type OpenDesignSidecarContract = { normalizeApp: typeof normalizeAppKey; normalizeNamespace: typeof normalizeNamespace; normalizeSource: typeof normalizeSidecarSource; - normalizeStamp: typeof normalizeSidecarStamp; - normalizeStampCriteria: typeof normalizeSidecarStampCriteria; + normalizeStamp: typeof normalizeSidecarRuntimeLayout; + normalizeStampCriteria: typeof normalizeSidecarRuntimeLayoutCriteria; sources: typeof SIDECAR_SOURCES; stampFields: typeof SIDECAR_STAMP_FIELDS; stampFlags: typeof SIDECAR_STAMP_FLAGS; @@ -762,7 +762,7 @@ function assertKnownStampKeys(value: Record, label: string): vo assertKnownKeys(value, SIDECAR_STAMP_FIELDS, label); } -export function normalizeSidecarStamp(input: unknown): SidecarStamp { +export function normalizeSidecarRuntimeLayout(input: unknown): LegacySidecarRuntimeLayout { const value = assertObject(input, "sidecar stamp"); assertKnownStampKeys(value, "sidecar stamp"); return { @@ -774,7 +774,7 @@ export function normalizeSidecarStamp(input: unknown): SidecarStamp { }; } -export function normalizeSidecarStampCriteria(input: unknown = {}): SidecarStampCriteria { +export function normalizeSidecarRuntimeLayoutCriteria(input: unknown = {}): LegacySidecarRuntimeLayoutCriteria { const value = assertObject(input, "sidecar stamp criteria"); assertKnownStampKeys(value, "sidecar stamp criteria"); return { @@ -786,8 +786,8 @@ export function normalizeSidecarStampCriteria(input: unknown = {}): SidecarStamp }; } -export function assertSidecarStamp(input: unknown): asserts input is SidecarStamp { - normalizeSidecarStamp(input); +function assertSidecarRuntimeLayout(input: unknown): asserts input is LegacySidecarRuntimeLayout { + normalizeSidecarRuntimeLayout(input); } function normalizeDesktopEvalInput(input: unknown): DesktopEvalInput { @@ -1097,8 +1097,8 @@ export const OPEN_DESIGN_SIDECAR_CONTRACT = Object.freeze({ normalizeApp: normalizeAppKey, normalizeNamespace, normalizeSource: normalizeSidecarSource, - normalizeStamp: normalizeSidecarStamp, - normalizeStampCriteria: normalizeSidecarStampCriteria, + normalizeStamp: normalizeSidecarRuntimeLayout, + normalizeStampCriteria: normalizeSidecarRuntimeLayoutCriteria, sources: SIDECAR_SOURCES, stampFields: SIDECAR_STAMP_FIELDS, stampFlags: SIDECAR_STAMP_FLAGS, diff --git a/packages/sidecar-proto/tests/index.test.ts b/packages/sidecar-proto/tests/index.test.ts index 14ccbefaf9..f9bb928be5 100644 --- a/packages/sidecar-proto/tests/index.test.ts +++ b/packages/sidecar-proto/tests/index.test.ts @@ -9,7 +9,7 @@ import { normalizeDaemonSidecarMessage, normalizeDesktopSidecarMessage, normalizeNamespace, - normalizeSidecarStamp, + normalizeSidecarRuntimeLayout, OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_MESSAGES, SIDECAR_SOURCES, @@ -58,16 +58,16 @@ describe("open-design sidecar contract", () => { }); it("accepts exactly app, mode, namespace, ipc, and source", () => { - expect(normalizeSidecarStamp(validStamp)).toEqual(validStamp); + expect(normalizeSidecarRuntimeLayout(validStamp)).toEqual(validStamp); }); it("rejects legacy or extra stamp fields", () => { - expect(() => normalizeSidecarStamp({ ...validStamp, runtimeToken: "legacy" })).toThrow(); - expect(() => normalizeSidecarStamp({ ...validStamp, role: "web-sidecar" })).toThrow(); + expect(() => normalizeSidecarRuntimeLayout({ ...validStamp, runtimeToken: "legacy" })).toThrow(); + expect(() => normalizeSidecarRuntimeLayout({ ...validStamp, role: "web-sidecar" })).toThrow(); }); it("rejects non-contract sidecar sources", () => { - expect(() => normalizeSidecarStamp({ ...validStamp, source: "custom-script" })).toThrow(); + expect(() => normalizeSidecarRuntimeLayout({ ...validStamp, source: "custom-script" })).toThrow(); }); it("validates daemon IPC messages", () => { diff --git a/packages/sidecar/esbuild.config.mjs b/packages/sidecar/esbuild.config.mjs index 8998b10490..3d39fe51dd 100644 --- a/packages/sidecar/esbuild.config.mjs +++ b/packages/sidecar/esbuild.config.mjs @@ -1,11 +1,14 @@ import { build } from "esbuild"; -await build({ +const shared = { bundle: true, - entryPoints: ["./src/index.ts"], format: "esm", - outfile: "./dist/index.mjs", packages: "external", platform: "node", target: "node24", -}); +}; + +await Promise.all([ + build({ ...shared, entryPoints: ["./src/index.ts"], outfile: "./dist/index.mjs" }), + build({ ...shared, entryPoints: ["./src/supervisor.ts"], outfile: "./dist/supervisor.mjs" }), +]); diff --git a/packages/sidecar/package.json b/packages/sidecar/package.json index c536b5c909..d8d75888aa 100644 --- a/packages/sidecar/package.json +++ b/packages/sidecar/package.json @@ -19,6 +19,9 @@ "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit" }, + "dependencies": { + "@open-design/platform": "workspace:*" + }, "devDependencies": { "@types/node": "24.12.2", "esbuild": "0.28.0", diff --git a/packages/sidecar/src/bootstrap.ts b/packages/sidecar/src/bootstrap.ts index 30cdfb498f..ecbc46d643 100644 --- a/packages/sidecar/src/bootstrap.ts +++ b/packages/sidecar/src/bootstrap.ts @@ -13,7 +13,7 @@ import type { BootstrapSidecarRuntimeOptions, SidecarLaunchEnvRequest, SidecarRuntimeContext, - SidecarStampShape, + RuntimeLayoutStampShape, } from "./types.js"; /** @@ -21,7 +21,7 @@ import type { * the stamp's ipc path, namespace, and source, layered over `extraEnv`. * @returns The launch environment. */ -export function createSidecarLaunchEnv({ +export function createSidecarLaunchEnv({ base, contract, extraEnv = process.env, @@ -54,7 +54,7 @@ function assertMatchingEnv(env: NodeJS.ProcessEnv, key: string, expected: string * the resolved live runtime context. * @returns The resolved {@link SidecarRuntimeContext}. */ -export function bootstrapSidecarRuntime( +export function bootstrapSidecarRuntime( stampInput: unknown, env: NodeJS.ProcessEnv, options: BootstrapSidecarRuntimeOptions, @@ -88,7 +88,6 @@ export function bootstrapSidecarRuntime( return { app: stamp.app, base, - ipc, mode: stamp.mode, namespace: stamp.namespace, source: stamp.source, diff --git a/packages/sidecar/src/client.ts b/packages/sidecar/src/client.ts new file mode 100644 index 0000000000..3f1d4daa8e --- /dev/null +++ b/packages/sidecar/src/client.ts @@ -0,0 +1,414 @@ +import { createJsonIpcServer, requestJsonIpc } from "./json-ipc.js"; +import { + isCurrentSidecarLauncher, + normalizeSidecarStamp, + readSupervisedSidecarContext, + resolvePrivateIpcPath, + SIDECAR_SUPERVISED_CONTEXT_ENV, + type SidecarStamp, + type SupervisedSidecarContext, +} from "./stamp.js"; + +const RESOURCES_ENV = "OD_SIDECAR_RESOURCES"; +const CONTROL_STATUS = "sidecar:status"; +const CONTROL_STOP = "sidecar:stop"; +const CONTROL_DESCRIBE = "sidecar:describe"; +const BUSINESS_INVOKE = "sidecar:invoke"; +const INHERITED_ENDPOINT_ENV = "OD_SIDECAR_CLIENT_ENDPOINT"; +const SUPERVISOR_HANDOFF = "sidecar:supervisor-handoff"; +const SUPERVISOR_HANDOFF_ACCEPTED = "sidecar:supervisor-handoff-accepted"; +export const SIDECAR_SUPERVISOR_TARGET_ENV = "OD_SIDECAR_SUPERVISOR_TARGET"; + +export type SidecarResources = Readonly<{ + dataRoot: string | null; + ownerPid: number | null; + pid: number; + port: number; + runtimeRoot: string; +}>; + +export function prepareSidecarLaunchEnvironment( + env: NodeJS.ProcessEnv, + resources: Omit, +): NodeJS.ProcessEnv { + const launchEnv: NodeJS.ProcessEnv = { + ...env, + [RESOURCES_ENV]: JSON.stringify({ + dataRoot: resources.dataRoot, + ownerPid: resources.ownerPid, + port: resources.port, + runtimeRoot: resources.runtimeRoot, + }), + }; + delete launchEnv[INHERITED_ENDPOINT_ENV]; + delete launchEnv[SIDECAR_SUPERVISED_CONTEXT_ENV]; + delete launchEnv[SIDECAR_SUPERVISOR_TARGET_ENV]; + return launchEnv; +} + +function newSidecarGenerationEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const launchEnv = { ...env }; + delete launchEnv[INHERITED_ENDPOINT_ENV]; + delete launchEnv[RESOURCES_ENV]; + delete launchEnv[SIDECAR_SUPERVISED_CONTEXT_ENV]; + delete launchEnv[SIDECAR_SUPERVISOR_TARGET_ENV]; + return launchEnv; +} + +export type SidecarHandler = (input: unknown) => unknown | Promise; +export type SidecarHandlers = Readonly>; + +export type SidecarLifecycle = { + start(resources: SidecarResources): Promise; + status(runtime: TRuntime): unknown | Promise; + stop(runtime: TRuntime): Promise; +}; + +export type SidecarClientOptions = { + handlers?: SidecarHandlers; + lifecycle: SidecarLifecycle; +}; + +export type SidecarGenerationHandoffRequest = { + args?: readonly string[]; + command: string; + cwd?: string; + env?: NodeJS.ProcessEnv; +}; + +type SupervisorHandoffEnvelope = { + request: SidecarGenerationHandoffRequest; + requestId: string; + type: typeof SUPERVISOR_HANDOFF; +}; + +type SupervisorHandoffAcceptedEnvelope = { + requestId: string; + type: typeof SUPERVISOR_HANDOFF_ACCEPTED; +}; + +/** Queue the next child under the current supervisor without changing generation identity. */ +export async function handoffCurrentSidecarGeneration( + request: SidecarGenerationHandoffRequest, + options: { timeoutMs?: number } = {}, +): Promise { + if (typeof process.send !== "function") { + throw new Error("current sidecar generation has no supervisor control channel"); + } + if (typeof request.command !== "string" || request.command.length === 0) { + throw new Error("sidecar generation handoff command must be a non-empty string"); + } + const requestId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const timeoutMs = options.timeoutMs ?? 5_000; + await new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + clearTimeout(timer); + process.off("message", onMessage); + }; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + cleanup(); + if (error == null) resolve(); + else reject(error); + }; + const onMessage = (message: unknown) => { + const response = message as Partial | null; + if (response?.type === SUPERVISOR_HANDOFF_ACCEPTED && response.requestId === requestId) finish(); + }; + const timer = setTimeout( + () => finish(new Error(`sidecar supervisor did not accept generation handoff within ${timeoutMs}ms`)), + timeoutMs, + ); + process.on("message", onMessage); + process.send?.({ request, requestId, type: SUPERVISOR_HANDOFF } satisfies SupervisorHandoffEnvelope, (error) => { + if (error != null) finish(error); + }); + }); +} + +export const sidecarSupervisorProtocol = Object.freeze({ + handoff: SUPERVISOR_HANDOFF, + handoffAccepted: SUPERVISOR_HANDOFF_ACCEPTED, +}); + +export type SidecarConnection = { + invoke(app: string, action: string, input: unknown, options?: { timeoutMs?: number }): Promise; + requestStop(app: string, options?: { timeoutMs?: number }): Promise<{ accepted?: unknown }>; + status(app: string, options?: { timeoutMs?: number }): Promise; +}; + +export type SidecarDescription = Readonly<{ + ready: boolean; + resources: SidecarResources; + stamp: SidecarStamp; +}>; + +type InvokeEnvelope = { action: string; app: string; input: unknown; type: typeof BUSINESS_INVOKE }; +type ControlEnvelope = + | { type: typeof CONTROL_DESCRIBE } + | { targetPid: number | null; type: typeof CONTROL_STATUS } + | { targetPids: readonly number[] | null; type: typeof CONTROL_STOP }; + +export function normalizeSidecarLaunchResources(input: unknown): Omit { + if (typeof input !== "object" || input == null || Array.isArray(input)) { + throw new Error("sidecar launch resources must contain a resource object"); + } + const value = input as Record; + if (value.dataRoot != null && (typeof value.dataRoot !== "string" || value.dataRoot.length === 0)) { + throw new Error("sidecar dataRoot must be null or a non-empty string"); + } + if (typeof value.runtimeRoot !== "string" || value.runtimeRoot.length === 0) { + throw new Error("sidecar runtimeRoot must be a non-empty string"); + } + const port = Number(value.port); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error("sidecar port must be an integer between 0 and 65535"); + } + const ownerPid = value.ownerPid == null ? null : Number(value.ownerPid); + if (ownerPid != null && (!Number.isSafeInteger(ownerPid) || ownerPid <= 0)) { + throw new Error("sidecar ownerPid must be null or a positive safe integer"); + } + return Object.freeze({ dataRoot: value.dataRoot == null ? null : value.dataRoot, ownerPid, port, runtimeRoot: value.runtimeRoot }); +} + +export function readSidecarLaunchResources(env: NodeJS.ProcessEnv = process.env): Omit { + const serialized = env[RESOURCES_ENV]; + if (serialized == null) throw new Error(`${RESOURCES_ENV} is required`); + try { + return normalizeSidecarLaunchResources(JSON.parse(serialized)); + } catch (error) { + if (error instanceof SyntaxError) throw new Error(`${RESOURCES_ENV} must contain valid JSON`, { cause: error }); + throw error; + } +} + +function readCurrentResources(context: SupervisedSidecarContext): SidecarResources { + const resources = normalizeSidecarLaunchResources(context.resources); + return Object.freeze({ ...resources, pid: context.generationPid }); +} + +function assertEnvelope(message: unknown): InvokeEnvelope | ControlEnvelope { + if (typeof message !== "object" || message == null || Array.isArray(message)) { + throw new Error("invalid sidecar request"); + } + const request = message as Record; + if (request.type === CONTROL_DESCRIBE) return { type: CONTROL_DESCRIBE }; + if (request.type === CONTROL_STATUS) { + if (request.targetPid != null && (!Number.isSafeInteger(request.targetPid) || Number(request.targetPid) <= 0)) { + throw new Error("invalid sidecar status target"); + } + return { targetPid: request.targetPid == null ? null : Number(request.targetPid), type: CONTROL_STATUS }; + } + if (request.type === CONTROL_STOP) { + if (request.targetPids != null && ( + !Array.isArray(request.targetPids) || + !request.targetPids.every((pid) => Number.isSafeInteger(pid) && Number(pid) > 0) + )) throw new Error("invalid sidecar stop targets"); + const targetPids = request.targetPids == null ? null : request.targetPids.map(Number); + return { targetPids, type: CONTROL_STOP }; + } + if (request.type !== BUSINESS_INVOKE || typeof request.app !== "string" || typeof request.action !== "string") { + throw new Error("invalid sidecar request"); + } + return { action: request.action, app: request.app, input: request.input, type: BUSINESS_INVOKE }; +} + +export class SidecarClient { + readonly resources: SidecarResources; + readonly stamp: SidecarStamp; + + readonly #handlers: SidecarHandlers; + readonly #lifecycle: SidecarLifecycle; + #ipcServer: Awaited> | null = null; + #runtime: TRuntime | null = null; + #startPromise: Promise | null = null; + #stopPromise: Promise | null = null; + #supervisorTimer: NodeJS.Timeout | null = null; + readonly #signalHandler = () => { this.#stopAndExit(); }; + #resolveStopped!: () => void; + readonly #stopped = new Promise((resolve) => { this.#resolveStopped = resolve; }); + + constructor(options: SidecarClientOptions) { + const context = readSupervisedSidecarContext(); + if (context == null) throw new Error("SidecarFactory.create() requires a supervised sidecar context"); + this.stamp = context.stamp; + this.resources = readCurrentResources(context); + this.#handlers = options.handlers ?? {}; + this.#lifecycle = options.lifecycle; + } + + start(): Promise { + if (this.#stopPromise != null) return Promise.reject(new Error("sidecar client is stopping")); + if (this.#startPromise != null) return this.#startPromise; + const attempt = this.#start(); + this.#startPromise = attempt; + void attempt.catch(() => { + if (this.#startPromise === attempt) this.#startPromise = null; + }); + return attempt; + } + + async #start(): Promise { + if (isCurrentSidecarLauncher()) { + throw new Error("a sidecar launcher must bootstrap a generation before starting server lifecycle"); + } + let runtime!: TRuntime; + let runtimeStarted = false; + try { + this.#ipcServer = await createJsonIpcServer({ + socketPath: resolvePrivateIpcPath(this.stamp), + handler: async (message) => { + const request = assertEnvelope(message); + if (request.type === CONTROL_DESCRIBE) { + return { ready: runtimeStarted, resources: this.resources, stamp: this.stamp } satisfies SidecarDescription; + } + if (!runtimeStarted) throw new Error("sidecar runtime is starting"); + if (request.type === CONTROL_STATUS) { + if (request.targetPid != null && request.targetPid !== this.resources.pid) { + throw new Error( + `sidecar endpoint belongs to generation ${this.resources.pid}, expected ${request.targetPid}`, + ); + } + return await this.#lifecycle.status(runtime); + } + if (request.type === CONTROL_STOP) { + if (request.targetPids != null && !request.targetPids.includes(this.resources.pid)) { + return { accepted: false }; + } + setImmediate(() => { this.#stopAndExit(); }); + return { accepted: true }; + } + if (request.type !== BUSINESS_INVOKE) throw new Error("invalid sidecar request"); + if (request.app !== this.stamp.app) throw new Error(`sidecar request targets ${request.app}, not ${this.stamp.app}`); + const handler = this.#handlers[request.action]; + if (handler == null) throw new Error(`unknown ${this.stamp.app} action: ${request.action}`); + return await handler(request.input); + }, + }); + runtime = await this.#lifecycle.start(this.resources); + runtimeStarted = true; + this.#runtime = runtime; + for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, this.#signalHandler); + // The supervisor is the durable generation root. If it disappears + // unexpectedly, the inner runtime quick-fails instead of surviving as + // an ownerless process whose argv may no longer expose any identity. + this.#supervisorTimer = setInterval(() => { + try { + process.kill(this.resources.pid, 0); + } catch { + if (this.#supervisorTimer != null) clearInterval(this.#supervisorTimer); + this.#supervisorTimer = null; + this.#stopAndExit(); + } + }, 1_000); + this.#supervisorTimer.unref(); + this.#publishEndpoint(); + } catch (error) { + this.#runtime = null; + if (runtimeStarted) await this.#lifecycle.stop(runtime).catch(() => undefined); + await this.#ipcServer?.close().catch(() => undefined); + this.#ipcServer = null; + this.#clearEndpoint(); + throw error; + } + } + + #publishEndpoint(): void { + process.env[INHERITED_ENDPOINT_ENV] = resolvePrivateIpcPath(this.stamp); + } + + #clearEndpoint(): void { + if (process.env[INHERITED_ENDPOINT_ENV] === resolvePrivateIpcPath(this.stamp)) { + delete process.env[INHERITED_ENDPOINT_ENV]; + } + } + + stop(): Promise { + this.#stopPromise ??= this.#stop(); + return this.#stopPromise; + } + + #stopAndExit(): void { + void this.stop().finally(() => process.exit(0)); + } + + async #stop(): Promise { + try { + await this.#startPromise?.catch(() => undefined); + for (const signal of ["SIGINT", "SIGTERM"] as const) process.off(signal, this.#signalHandler); + if (this.#supervisorTimer != null) clearInterval(this.#supervisorTimer); + this.#supervisorTimer = null; + await this.#ipcServer?.close(); + this.#ipcServer = null; + if (this.#runtime != null) await this.#lifecycle.stop(this.#runtime); + this.#runtime = null; + } finally { + this.#clearEndpoint(); + this.#resolveStopped(); + } + } + + waitUntilStopped(): Promise { + return this.#stopped; + } + + async invoke(app: string, action: string, input: unknown, options?: { timeoutMs?: number }): Promise { + const target = normalizeSidecarStamp({ ...this.stamp, app }); + return await requestJsonIpc( + resolvePrivateIpcPath(target), + { action, app: target.app, input, type: BUSINESS_INVOKE }, + options, + ); + } + + async status(app: string, options?: { timeoutMs?: number }): Promise { + const target = normalizeSidecarStamp({ ...this.stamp, app }); + return await requestJsonIpc(resolvePrivateIpcPath(target), { type: CONTROL_STATUS }, options); + } + + async requestStop(app: string, options?: { timeoutMs?: number }): Promise<{ accepted?: unknown }> { + const target = normalizeSidecarStamp({ ...this.stamp, app }); + return await requestJsonIpc(resolvePrivateIpcPath(target), { type: CONTROL_STOP }, options); + } +} + +export const SidecarFactory = Object.freeze({ + connectInherited(env: NodeJS.ProcessEnv = process.env): SidecarConnection | null { + const endpoint = env[INHERITED_ENDPOINT_ENV]; + if (endpoint == null || endpoint.length === 0) return null; + return { + async invoke(app: string, action: string, input: unknown, options?: { timeoutMs?: number }) { + return await requestJsonIpc(endpoint, { action, app, input, type: BUSINESS_INVOKE }, options); + }, + async requestStop(_app: string, options?: { timeoutMs?: number }) { + return await requestJsonIpc<{ accepted?: unknown }>(endpoint, { type: CONTROL_STOP }, options); + }, + async status(_app: string, options?: { timeoutMs?: number }) { + return await requestJsonIpc(endpoint, { type: CONTROL_STATUS }, options); + }, + }; + }, + create(options: SidecarClientOptions): SidecarClient { + return new SidecarClient(options); + }, + inheritedEnvironment(env: NodeJS.ProcessEnv = process.env): Record { + const endpoint = env[INHERITED_ENDPOINT_ENV]; + return endpoint == null || endpoint.length === 0 ? {} : { [INHERITED_ENDPOINT_ENV]: endpoint }; + }, + /** + * Creates the environment for an entrypoint that must bootstrap a new + * generation instead of remaining attached to the caller's supervisor. + */ + newGenerationEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return newSidecarGenerationEnvironment(env); + }, +}); + +export const sidecarProtocol = Object.freeze({ + describe: CONTROL_DESCRIBE, + resourcesEnv: RESOURCES_ENV, + status: CONTROL_STATUS, + stop: CONTROL_STOP, +}); diff --git a/packages/sidecar/src/generation.ts b/packages/sidecar/src/generation.ts new file mode 100644 index 0000000000..99c104021d --- /dev/null +++ b/packages/sidecar/src/generation.ts @@ -0,0 +1,376 @@ +import { lstat, rm } from "node:fs/promises"; +import { createConnection } from "node:net"; + +import type { ProcessSnapshot, StopProcessesOptions, StopProcessesResult } from "@open-design/platform"; +import { + captureProcessSnapshot, + isProcessAlive, + matchesStampedProcess, +} from "@open-design/platform"; + +import { type SidecarDescription, sidecarProtocol } from "./client.js"; +import { requestJsonIpc } from "./json-ipc.js"; +import { + captureSidecarGenerationSetSnapshot, + captureSidecarGenerationSnapshot, +} from "./process-tree.js"; +import { retireFencedSidecarProcessTree } from "./process-retirement.js"; +import { + normalizeSidecarStamp, + resolvePrivateIpcPath, + SIDECAR_STAMP_CONTRACT, + type SidecarStamp, +} from "./stamp.js"; + +export type SidecarStopResult = StopProcessesResult & { + staleEndpointRemoved?: boolean; + gracefulAccepted: boolean; +}; + +export type SidecarStopOptions = StopProcessesOptions & { + /** Bound the graceful lifecycle request before process retirement takes over. */ + gracefulRequestTimeoutMs?: number; +}; + +export type SidecarStopRequest = Readonly<{ + options?: SidecarStopOptions; + stamp: SidecarStamp; +}>; + +export type SidecarStopSetResult = SidecarStopResult & { + results: Array>; +}; + +/** Authority to mutate one concrete supervisor generation of a stamped resource. */ +export type SidecarGenerationRef = Readonly<{ + rootPid: number; + startedAtMs?: number; + stamp: SidecarStamp; +}>; + +class AmbiguousSidecarGenerationsError extends Error { + readonly rootPids: readonly number[]; + + constructor(rootPids: readonly number[]) { + super(`cannot mutate sidecar with multiple stamped generation roots: ${rootPids.join(", ")}`); + this.name = "AmbiguousSidecarGenerationsError"; + this.rootPids = Object.freeze([...rootPids]); + } +} + +type PrivateEndpointIdentity = Readonly<{ dev: number; ino: number }>; + +/** One invocation-fenced observation used by discovery-based mutation paths. */ +export type ObservedSidecarGeneration = Readonly<{ + description: SidecarDescription | null; + endpoint: PrivateEndpointIdentity | null; + processes: ProcessSnapshot[]; + ref: SidecarGenerationRef | null; + stamp: SidecarStamp; +}>; + +export function sidecarGenerationRef( + stampInput: SidecarStamp, + rootPid: number, + startedAtMs?: number, +): SidecarGenerationRef { + if (!Number.isSafeInteger(rootPid) || rootPid <= 0) { + throw new Error("sidecar generation root pid must be a positive safe integer"); + } + return Object.freeze({ + rootPid, + ...(Number.isSafeInteger(startedAtMs) && (startedAtMs ?? 0) > 0 ? { startedAtMs } : {}), + stamp: normalizeSidecarStamp(stampInput), + }); +} + +export async function describeSidecarGeneration( + stampInput: SidecarStamp, + timeoutMs = 2_000, +): Promise { + const stamp = normalizeSidecarStamp(stampInput); + let description: SidecarDescription; + try { + description = await requestJsonIpc( + resolvePrivateIpcPath(stamp), + { type: sidecarProtocol.describe }, + { timeoutMs }, + ); + } catch { + return null; + } + const describedStamp = normalizeSidecarStamp(description.stamp); + if (JSON.stringify(describedStamp) !== JSON.stringify(stamp)) { + throw new Error("sidecar endpoint described a different stamp"); + } + if (!Number.isSafeInteger(description.resources.pid) || description.resources.pid <= 0) { + throw new Error("sidecar endpoint described an invalid pid"); + } + if (!Number.isInteger(description.resources.port) || description.resources.port < 0 || description.resources.port > 65535) { + throw new Error("sidecar endpoint described an invalid port"); + } + if (typeof description.ready !== "boolean") throw new Error("sidecar endpoint described invalid readiness"); + return description; +} + +export async function observeSidecarGeneration( + stampInput: SidecarStamp, + descriptionTimeoutMs = 2_000, +): Promise { + return (await observeSidecarGenerations([stampInput], descriptionTimeoutMs))[0]!; +} + +/** Observe several resources at one process-table boundary. */ +export async function observeSidecarGenerations( + stampInputs: readonly SidecarStamp[], + descriptionTimeoutMs: number | readonly number[] = 2_000, +): Promise { + const stamps = uniqueNormalizedStamps(stampInputs); + const descriptionTimeouts = stamps.map((_, index) => normalizeGracefulRequestTimeoutMs( + typeof descriptionTimeoutMs === "number" ? descriptionTimeoutMs : descriptionTimeoutMs[index], + )); + let retryDeadline: number | null = null; + let ambiguousReobservationUsed = false; + while (true) { + try { + return await observeSidecarGenerationsOnce(stamps, descriptionTimeouts); + } catch (error) { + if (error instanceof AmbiguousSidecarGenerationsError) { + if (ambiguousReobservationUsed) throw error; + const transitioned = await waitForAmbiguousRootExit(error.rootPids, 1_000); + if (!transitioned) throw error; + ambiguousReobservationUsed = true; + continue; + } + // Guarantee one real re-observation even when the first Windows CIM + // capture itself takes longer than the old one-second retry deadline. + if (!isTransientGenerationObservation(error)) throw error; + if (retryDeadline == null) retryDeadline = Date.now() + 1_000; + else if (Date.now() >= retryDeadline) throw error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } +} + +async function observeSidecarGenerationsOnce( + stamps: readonly SidecarStamp[], + descriptionTimeoutMs: readonly number[], +): Promise { + const endpoints = await Promise.all(stamps.map(readPrivateEndpointIdentity)); + const snapshots = await captureSidecarGenerationSetSnapshot(stamps); + const endpointsAfterCapture = await Promise.all(stamps.map(readPrivateEndpointIdentity)); + const descriptions = await Promise.all( + stamps.map(async (stamp, index) => await describeSidecarGeneration(stamp, descriptionTimeoutMs[index])), + ); + const endpointsAfterDescribe = await Promise.all(stamps.map(readPrivateEndpointIdentity)); + + return stamps.map((stamp, index) => { + const endpoint = endpoints[index] ?? null; + if (!samePrivateEndpointIdentity(endpoint, endpointsAfterCapture[index] ?? null)) { + throw new Error("cannot mutate sidecar because endpoint ownership changed during process discovery"); + } + if (!samePrivateEndpointIdentity(endpoint, endpointsAfterDescribe[index] ?? null)) { + throw new Error("cannot mutate sidecar because endpoint ownership changed during description"); + } + const snapshot = snapshots[index]!; + if (snapshot.roots.length > 1) { + throw new AmbiguousSidecarGenerationsError(snapshot.roots.map(({ pid }) => pid)); + } + const description = descriptions[index] ?? null; + const rootPid = snapshot.roots[0]?.pid ?? null; + if (description != null && rootPid !== description.resources.pid) { + throw new Error( + `cannot mutate sidecar because endpoint pid ${description.resources.pid} is not the stamped generation root`, + ); + } + return { + description, + endpoint, + processes: snapshot.processes, + ref: rootPid == null ? null : sidecarGenerationRef(stamp, rootPid, snapshot.roots[0]?.startedAtMs), + stamp, + }; + }); +} + +function isTransientGenerationObservation(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return error.message.startsWith("cannot mutate sidecar because endpoint"); +} + +async function waitForAmbiguousRootExit(rootPids: readonly number[], timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (rootPids.some((pid) => !isProcessAlive(pid))) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return rootPids.some((pid) => !isProcessAlive(pid)); +} + +export async function retireObservedSidecarGeneration( + observation: ObservedSidecarGeneration, + options: SidecarStopOptions = {}, +): Promise { + return (await retireObservedSidecarGenerations([{ observation, options }]))[0]!; +} + +/** Retire an invocation-fenced resource set and verify replacements once. */ +export async function retireObservedSidecarGenerations( + requests: readonly Readonly<{ + observation: ObservedSidecarGeneration; + options?: SidecarStopOptions; + }>[], +): Promise { + const baseResults = await Promise.all(requests.map(async ({ observation, options = {} }) => + observation.ref == null + ? alreadyStoppedResult() + : await retireSidecarGeneration(observation.ref, options, observation.processes))); + const replacementSnapshots = await captureSidecarGenerationSetSnapshot( + requests.map(({ observation }) => observation.stamp), + ); + + return await Promise.all(requests.map(async ({ observation }, index) => { + const base = baseResults[index]!; + const replacements = replacementSnapshots[index]!.roots.filter(({ pid }) => isProcessAlive(pid)); + const remainingPids = [...new Set([ + ...base.remainingPids, + ...replacements.map(({ pid }) => pid), + ])]; + let staleEndpointRemoved = false; + if ( + remainingPids.length === 0 && + (observation.ref != null || await privateEndpointRefusesConnections(observation.stamp)) + ) { + staleEndpointRemoved = await removeOwnedPrivateEndpoint(observation.stamp, observation.endpoint); + } + return { + ...base, + alreadyStopped: base.alreadyStopped && remainingPids.length === 0, + remainingPids, + staleEndpointRemoved, + }; + })); +} + +/** Retire a generation already owned by the caller without adopting a replacement. */ +export async function retireKnownSidecarGeneration( + ref: SidecarGenerationRef, + options: SidecarStopOptions = {}, +): Promise { + const endpoint = await readPrivateEndpointIdentity(ref.stamp); + const description = await describeSidecarGeneration(ref.stamp); + const endpointAfterDescribe = await readPrivateEndpointIdentity(ref.stamp); + if (!samePrivateEndpointIdentity(endpoint, endpointAfterDescribe)) { + throw new Error("cannot retire sidecar generation because endpoint ownership changed during description"); + } + const ownsEndpoint = description?.resources.pid === ref.rootPid; + const result = await retireSidecarGeneration(ref, options); + const replacementExists = (await captureSidecarGenerationSnapshot(ref.stamp)).roots + .some(({ pid }) => isProcessAlive(pid)); + let staleEndpointRemoved = false; + if ( + result.remainingPids.length === 0 && + !replacementExists && + (ownsEndpoint || (description == null && await privateEndpointRefusesConnections(ref.stamp))) + ) { + staleEndpointRemoved = await removeOwnedPrivateEndpoint(ref.stamp, endpoint); + } + return { ...result, staleEndpointRemoved }; +} + +/** Retire exactly the generation named by the stable supervisor root. */ +export async function retireSidecarGeneration( + ref: SidecarGenerationRef, + options: SidecarStopOptions = {}, + knownSnapshots?: ProcessSnapshot[], +): Promise { + const snapshots = knownSnapshots ?? await captureProcessSnapshot(); + const rootOwnedAtEntry = snapshots.some((processInfo) => + processInfo.pid === ref.rootPid && + (ref.startedAtMs == null || processInfo.startedAtMs === ref.startedAtMs) && + matchesStampedProcess(processInfo, ref.stamp, SIDECAR_STAMP_CONTRACT), + ); + if (!rootOwnedAtEntry) return alreadyStoppedResult(); + let gracefulAccepted = false; + try { + const response = await requestJsonIpc<{ accepted?: unknown }>( + resolvePrivateIpcPath(ref.stamp), + { targetPids: [ref.rootPid], type: sidecarProtocol.stop }, + { timeoutMs: normalizeGracefulRequestTimeoutMs(options.gracefulRequestTimeoutMs) }, + ); + gracefulAccepted = response.accepted === true; + } catch { + // An absent, stale, or replacement endpoint is resolved by the owned root tree. + } + + const result = await retireFencedSidecarProcessTree(ref, { + gracefulStopInitiated: gracefulAccepted, + knownSnapshots: snapshots, + stopOptions: options, + }); + return { ...result, gracefulAccepted }; +} + +function normalizeGracefulRequestTimeoutMs(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : 2_000; +} + +function alreadyStoppedResult(): SidecarStopResult { + return { + alreadyStopped: true, + forcedPids: [], + gracefulAccepted: false, + matchedPids: [], + remainingPids: [], + stoppedPids: [], + }; +} + +function uniqueNormalizedStamps(stampInputs: readonly SidecarStamp[]): SidecarStamp[] { + const unique = new Map(); + for (const input of stampInputs) { + const stamp = normalizeSidecarStamp(input); + unique.set(JSON.stringify(stamp), stamp); + } + return [...unique.values()]; +} + +function samePrivateEndpointIdentity( + left: PrivateEndpointIdentity | null, + right: PrivateEndpointIdentity | null, +): boolean { + return left == null || right == null + ? left === right + : left.dev === right.dev && left.ino === right.ino; +} + +async function readPrivateEndpointIdentity(stamp: SidecarStamp): Promise { + if (process.platform === "win32") return null; + const entry = await lstat(resolvePrivateIpcPath(stamp)).catch(() => null); + return entry?.isSocket() ? { dev: entry.dev, ino: entry.ino } : null; +} + +async function removeOwnedPrivateEndpoint(stamp: SidecarStamp, owned: PrivateEndpointIdentity | null): Promise { + if (owned == null) return false; + const current = await readPrivateEndpointIdentity(stamp); + if (current?.dev !== owned.dev || current.ino !== owned.ino) return false; + await rm(resolvePrivateIpcPath(stamp), { force: true }); + return true; +} + +async function privateEndpointRefusesConnections(stamp: SidecarStamp): Promise { + if (process.platform === "win32") return false; + return await new Promise((resolveProbe) => { + const socket = createConnection(resolvePrivateIpcPath(stamp)); + socket.once("connect", () => { + socket.destroy(); + resolveProbe(false); + }); + socket.once("error", (error: NodeJS.ErrnoException) => { + socket.destroy(); + resolveProbe(error.code === "ECONNREFUSED"); + }); + }); +} diff --git a/packages/sidecar/src/index.ts b/packages/sidecar/src/index.ts index b3690bdd07..7f8960158d 100644 --- a/packages/sidecar/src/index.ts +++ b/packages/sidecar/src/index.ts @@ -1,34 +1,23 @@ /** * @module @open-design/sidecar * - * Public barrel for the generic sidecar runtime package. Re-exports the exact - * prior public surface — the data types, IPC-path validators, namespace/runtime - * path resolvers, launch-env + bootstrap, port allocation, JSON file helpers, and - * the JSON-IPC server/client. This file contains no logic; every implementation - * lives in a concern module beside it. + * Public boundary for sidecar clients and server-side process atomics. Transport, + * endpoint derivation, and OS-visible identity are deliberately private package + * details; callers share only the five-field resource identity and these operations. */ export type { - AppIpcPathRequest, AppRuntimePathRequest, BaseResolutionOptions, - BootstrapSidecarRuntimeOptions, - JsonIpcHandler, - JsonIpcServerHandle, NamespaceResolutionOptions, PortAllocation, PortRequest, ProjectRuntimePathRequest, RuntimePathRequest, RuntimeRootRequest, - SidecarContractDescriptor, - SidecarLaunchEnvRequest, SidecarRuntimeContext, - SidecarStampShape, } from "./types.js"; -export { isWindowsNamedPipePath, normalizeIpcPath } from "./ipc-path.js"; export { - resolveAppIpcPath, resolveAppRuntimeDir, resolveAppRuntimePath, resolveLogFilePath, @@ -44,7 +33,52 @@ export { resolveSidecarBase, resolveSourceRuntimeRoot, } from "./paths.js"; -export { bootstrapSidecarRuntime, createSidecarLaunchEnv } from "./bootstrap.js"; export { allocatePort } from "./port.js"; -export { readJsonFile, removeFile, removePointerIfCurrent, writeJsonFile } from "./json-file.js"; -export { createJsonIpcServer, requestJsonIpc } from "./json-ipc.js"; +export type { + SidecarClientOptions, + SidecarConnection, + SidecarHandler, + SidecarHandlers, + SidecarGenerationHandoffRequest, + SidecarLifecycle, + SidecarResources, +} from "./client.js"; +export { handoffCurrentSidecarGeneration, SidecarClient, SidecarFactory } from "./client.js"; +export type { SidecarLifecycleLockOptions } from "./lifecycle-lock.js"; +export { withSidecarLifecycleLock } from "./lifecycle-lock.js"; +export type { SidecarStamp, SidecarStampField } from "./stamp.js"; +export { + normalizeSidecarStamp, + isCurrentSidecarLauncher, + readCurrentSidecarStamp, + SIDECAR_STAMP_FIELDS, + SIDECAR_STAMP_FLAGS, +} from "./stamp.js"; +export type { + SidecarLaunchRequest, + SidecarLaunchConvergenceOptions, + SidecarLaunchConvergenceResult, + SidecarRestartOptions, + SidecarRestartResult, + SidecarStopOptions, + SidecarStopResult, + SidecarStopRequest, + SidecarStopSetResult, + SpawnedSidecar, +} from "./operations.js"; +export { + bootstrapSidecarProcess, + convergeSidecarLaunch, + findSidecarProcesses, + getSidecarStatus, + invokeSidecar, + launchSidecar, + registerSidecarProcess, + restartSidecar, + resolveSidecarLauncherExitCode, + SidecarLaunchConvergenceError, + spawnSidecarLauncher, + spawnSidecar, + stopSidecar, + stopSidecars, +} from "./operations.js"; diff --git a/packages/sidecar/src/json-ipc.ts b/packages/sidecar/src/json-ipc.ts index 49d3d8a2d0..f105cfd402 100644 --- a/packages/sidecar/src/json-ipc.ts +++ b/packages/sidecar/src/json-ipc.ts @@ -11,7 +11,7 @@ * the IPC-path recognizer, and the public IPC types. */ -import { lstat, mkdir, rm } from "node:fs/promises"; +import { chmod, lstat, mkdir, rm } from "node:fs/promises"; import { createConnection } from "node:net"; import { createServer as createNetServer } from "node:net"; import { dirname } from "node:path"; @@ -21,6 +21,20 @@ import { isWindowsNamedPipePath } from "./ipc-path.js"; import { closeServer } from "./net.js"; import type { JsonIpcHandler, JsonIpcServerHandle } from "./types.js"; +type UnixSocketIdentity = Readonly<{ dev: number; ino: number }>; + +async function readUnixSocketIdentity(socketPath: string): Promise { + if (isWindowsNamedPipePath(socketPath)) return null; + const entry = await lstat(socketPath).catch(() => null); + return entry?.isSocket() ? { dev: entry.dev, ino: entry.ino } : null; +} + +async function removeOwnedUnixSocket(socketPath: string, owned: UnixSocketIdentity | null): Promise { + if (owned == null) return; + const current = await readUnixSocketIdentity(socketPath); + if (current?.dev === owned.dev && current.ino === owned.ino) await rm(socketPath, { force: true }); +} + let jsonIpcTraceSeq = 0; /** @@ -140,7 +154,8 @@ async function staleUnixSocketExists(socketPath: string): Promise { */ async function prepareIpcPath(socketPath: string): Promise { if (isWindowsNamedPipePath(socketPath)) return; - await mkdir(dirname(socketPath), { recursive: true }); + await mkdir(dirname(socketPath), { mode: 0o700, recursive: true }); + await chmod(dirname(socketPath), 0o700); if (await staleUnixSocketExists(socketPath)) await rm(socketPath, { force: true }); } @@ -263,11 +278,13 @@ export async function createJsonIpcServer({ resolveListen(); }); }); + if (!isWindowsNamedPipePath(socketPath)) await chmod(socketPath, 0o600); + const ownedSocket = await readUnixSocketIdentity(socketPath); return { async close() { + await removeOwnedUnixSocket(socketPath, ownedSocket); await closeServer(server); - if (!isWindowsNamedPipePath(socketPath)) await rm(socketPath, { force: true }); }, }; } diff --git a/packages/sidecar/src/lifecycle-lock.ts b/packages/sidecar/src/lifecycle-lock.ts new file mode 100644 index 0000000000..a404da59a2 --- /dev/null +++ b/packages/sidecar/src/lifecycle-lock.ts @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import { createServer, type Server } from "node:net"; +import { userInfo } from "node:os"; + +import { normalizeSidecarStamp, sidecarStampKey, type SidecarStamp } from "./stamp.js"; + +export type SidecarLifecycleLockOptions = Readonly<{ + timeoutMs?: number; +}>; + +/** + * Serialize one Windows lifecycle resource set across independent clients. + * + * The named pipe is an ephemeral kernel lock, not a second resource identity: + * callers still declare the exact five-field stamps they intend to coordinate. + */ +export async function withSidecarLifecycleLock( + stampInputs: readonly SidecarStamp[], + operation: () => Promise, + options: SidecarLifecycleLockOptions = {}, +): Promise { + if (stampInputs.length === 0) return await operation(); + if (process.platform !== "win32") return await operation(); + + const lockPath = resolveWindowsLifecycleLockPath(stampInputs); + const timeoutMs = normalizeTimeoutMs(options.timeoutMs); + const deadline = Date.now() + timeoutMs; + let server: Server | null = null; + while (server == null) { + server = await tryListen(lockPath); + if (server != null) break; + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for sidecar lifecycle lock after ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + try { + return await operation(); + } finally { + await closeServer(server); + } +} + +function resolveWindowsLifecycleLockPath(stampInputs: readonly SidecarStamp[]): string { + const principal = (() => { + try { return userInfo().username; } catch { return process.env.USERNAME ?? "unknown"; } + })(); + const resourceSet = [...new Set(stampInputs.map((stamp) => sidecarStampKey(normalizeSidecarStamp(stamp))))] + .sort() + .join("\n---\n"); + const digest = createHash("sha256").update(`${principal}\n${resourceSet}`).digest("hex").slice(0, 32); + return `\\\\.\\pipe\\open-design-sidecar-lifecycle-${digest}`; +} + +async function tryListen(path: string): Promise { + const server = createServer((socket) => socket.destroy()); + return await new Promise((resolve, reject) => { + const onError = (error: NodeJS.ErrnoException): void => { + server.removeListener("listening", onListening); + if (error.code === "EADDRINUSE") resolve(null); + else reject(error); + }; + const onListening = (): void => { + server.removeListener("error", onError); + resolve(server); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen({ exclusive: true, path }); + }); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => error == null ? resolve() : reject(error)); + }); +} + +function normalizeTimeoutMs(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : 120_000; +} diff --git a/packages/sidecar/src/operations.ts b/packages/sidecar/src/operations.ts new file mode 100644 index 0000000000..facfcca6b7 --- /dev/null +++ b/packages/sidecar/src/operations.ts @@ -0,0 +1,578 @@ +import type { ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import type { SpawnProcessRequest } from "@open-design/platform"; +import { + captureProcessSnapshotsByPids, + createProcessStampArgs, + isProcessAlive, + listProcessSnapshots, + matchesStampedProcess, + spawnBackgroundProcess, + spawnLoggedProcess, + stopProcesses, +} from "@open-design/platform"; + +import { requestJsonIpc } from "./json-ipc.js"; +import { + prepareSidecarLaunchEnvironment, + SIDECAR_SUPERVISOR_TARGET_ENV, + sidecarProtocol, + type SidecarDescription, + type SidecarResources, +} from "./client.js"; +import { + describeSidecarGeneration, + observeSidecarGeneration, + observeSidecarGenerations, + retireKnownSidecarGeneration, + retireObservedSidecarGeneration, + retireObservedSidecarGenerations, + sidecarGenerationRef, + type SidecarStopOptions, + type SidecarStopRequest, + type SidecarStopSetResult, + type SidecarStopResult, +} from "./generation.js"; +import { captureSidecarGenerationSnapshot } from "./process-tree.js"; +import { + createSidecarLauncherArgs, + isSidecarLauncherCommand, + normalizeSidecarStamp, + readSupervisedSidecarContext, + removeSidecarLauncherArgs, + resolvePrivateIpcPath, + serializeSupervisedSidecarContext, + SIDECAR_SUPERVISED_CONTEXT_ENV, + SIDECAR_STAMP_CONTRACT, + type SidecarStamp, +} from "./stamp.js"; + +export type { + SidecarStopOptions, + SidecarStopRequest, + SidecarStopResult, + SidecarStopSetResult, +} from "./generation.js"; + +export type SidecarLaunchRequest = Omit & { + args?: readonly string[]; + env?: NodeJS.ProcessEnv; + resources: Omit; + stamp: SidecarStamp; +}; + +export type SidecarRestartOptions = { + requireConcretePort?: boolean; + reuseKnownPort?: boolean; + stop?: SidecarStopOptions; +}; + +export type SidecarLaunchConvergenceOptions = { + ownerStamps?: readonly SidecarStamp[]; + retryDelayMs?: number; + stabilityMs?: number; + timeoutMs?: number; +}; + +export type SidecarLaunchConvergenceResult = { + attempts: number; + description: SidecarDescription; + launcherProcess: ChildProcess & { pid: number }; +}; + +export class SidecarLaunchConvergenceError extends Error { + readonly launcherPid: number; + + constructor(message: string, launcherPid: number) { + super(message); + this.name = "SidecarLaunchConvergenceError"; + this.launcherPid = launcherPid; + } +} + +const RESTART_READY_TIMEOUT_MS = 30_000; +const BOOTSTRAP_READY_TIMEOUT_MS = 90_000; +const EXISTING_GENERATION_STABILITY_MS = 750; +const SIDECAR_LAUNCHER_RETRY_EXIT_CODE = 75; +const restartTails = new Map>(); + +class SidecarBootstrapGenerationRetiredError extends Error { + constructor(pid: number) { + super(`sidecar bootstrap generation ${pid} exited before a generation became ready`); + this.name = "SidecarBootstrapGenerationRetiredError"; + } +} + +/** Map sidecar lifecycle failures to the private launcher process protocol. */ +export function resolveSidecarLauncherExitCode(error: unknown): number { + return error instanceof SidecarBootstrapGenerationRetiredError + ? SIDECAR_LAUNCHER_RETRY_EXIT_CODE + : 1; +} + +export type SidecarRestartResult = { + pid: number; + reusedPort: boolean; + stop: SidecarStopResult; +}; + +/** + * One concrete process generation created for a five-field sidecar resource. + * The stamp identifies the resource across processes; this handle retains the + * root process identity needed to retire this generation even if its runtime + * later rewrites the argv visible to the operating system. + */ +export type SpawnedSidecar = { + readonly process: ChildProcess & { pid: number }; + readonly stamp: SidecarStamp; + stop(options?: SidecarStopOptions): Promise; +}; + +export function registerSidecarProcess( + stampInput: SidecarStamp, + resources: Omit, +): SidecarStamp { + const stamp = normalizeSidecarStamp(stampInput); + const context = readSupervisedSidecarContext(); + if (context == null) throw new Error("current process is missing its supervised sidecar context"); + if (JSON.stringify(context.stamp) !== JSON.stringify(stamp)) { + throw new Error("current process carries a different sidecar resource identity"); + } + process.env[SIDECAR_SUPERVISED_CONTEXT_ENV] = serializeSupervisedSidecarContext( + stamp, + context.generationPid, + resources, + ); + return stamp; +} + +export async function bootstrapSidecarProcess( + stampInput: SidecarStamp, + resources: Omit, + options: { + args?: readonly string[]; + command?: string; + cwd?: string; + env?: NodeJS.ProcessEnv; + launch?: typeof launchSidecar; + waitUntilReady?: (stamp: SidecarStamp, pid: number) => Promise; + } = {}, +): Promise { + const stamp = normalizeSidecarStamp(stampInput); + if (readSupervisedSidecarContext() != null) { + registerSidecarProcess(stamp, resources); + return false; + } + const launched = await (options.launch ?? launchSidecar)({ + args: removeSidecarLauncherArgs(options.args ?? process.argv.slice(1)), + command: options.command ?? process.execPath, + cwd: options.cwd ?? process.cwd(), + env: options.env ?? process.env, + logFd: null, + resources, + stamp, + }); + try { + await (options.waitUntilReady ?? waitForBootstrappedSidecarReady)(stamp, launched.pid, BOOTSTRAP_READY_TIMEOUT_MS); + } catch (error) { + const cleanup = await retireKnownSidecarGeneration(sidecarGenerationRef(stamp, launched.pid), { termGraceMs: 0 }); + if (cleanup.remainingPids.length > 0) { + throw new AggregateError( + [error, new Error(`failed to retire rejected bootstrap generation: ${cleanup.remainingPids.join(", ")}`)], + "sidecar bootstrap failed and cleanup was incomplete", + ); + } + throw error; + } + return true; +} + +export async function launchSidecar(request: SidecarLaunchRequest): Promise<{ pid: number }> { + return await spawnBackgroundProcess(sidecarSpawnRequest(request)); +} + +/** Spawn an uncommitted client-side launcher; it is stamped but is not a generation root. */ +export async function spawnSidecarLauncher( + request: SidecarLaunchRequest, +): Promise { + const stamp = normalizeSidecarStamp(request.stamp); + const { args = [], command, env, resources, ...spawnRequest } = request; + const child = await spawnLoggedProcess({ + ...spawnRequest, + args: [ + ...removeSidecarLauncherArgs(args), + ...createSidecarLauncherArgs(stamp), + ], + command, + env: prepareSidecarLaunchEnvironment(env ?? process.env, resources), + }); + if (child.pid == null) throw new Error("spawned sidecar launcher has no pid"); + return child as ChildProcess & { pid: number }; +} + +/** + * Launch a client-side entrypoint until it leaves one stable, ready generation. + * A clean launcher exit is not success by itself: endpoint ownership and the + * stamped generation root must agree for a stability window. + */ +export async function convergeSidecarLaunch( + request: SidecarLaunchRequest, + options: SidecarLaunchConvergenceOptions = {}, +): Promise { + const stamp = normalizeSidecarStamp(request.stamp); + const ownerStamps = (options.ownerStamps ?? [stamp]).map(normalizeSidecarStamp); + const timeoutMs = normalizeDuration(options.timeoutMs, 45_000); + const stabilityMs = normalizeDuration(options.stabilityMs, EXISTING_GENERATION_STABILITY_MS); + const retryDelayMs = normalizeDuration(options.retryDelayMs, 250); + const deadline = Date.now() + timeoutMs; + let attempts = 0; + let lastLauncher: (ChildProcess & { pid: number }) | null = null; + let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + + while (Date.now() < deadline) { + attempts += 1; + const launcher = await spawnSidecarLauncher({ ...request, stamp }); + lastLauncher = launcher; + const readExit = observeChildExit(launcher); + let stableOwnerPid: number | null = null; + let stableSince = 0; + let exitedAt: number | null = null; + + while (Date.now() < deadline) { + const exit = readExit(); + if (exit != null && exitedAt == null) { + exitedAt = Date.now(); + lastExit = exit; + } + if (exit != null && ( + (exit.code !== 0 && exit.code !== SIDECAR_LAUNCHER_RETRY_EXIT_CODE) + || exit.signal != null + )) { + throw new SidecarLaunchConvergenceError( + `sidecar launcher exited before convergence code=${exit.code ?? "null"} signal=${exit.signal ?? "null"}`, + launcher.pid, + ); + } + + const descriptions = await Promise.all(ownerStamps.map(async (candidate) => + await describeSidecarGeneration(candidate), + )); + const description = descriptions.find((candidate) => candidate?.ready === true) ?? null; + const ownerPid = description?.resources.pid ?? null; + const ownerSnapshot = ownerPid == null + ? null + : (await captureProcessSnapshotsByPids([ownerPid]))[0] ?? null; + const generationStable = ownerSnapshot != null && + description != null && matchesStampedProcess(ownerSnapshot, description.stamp, SIDECAR_STAMP_CONTRACT) && + !isSidecarLauncherCommand(ownerSnapshot.command); + const launcherConverged = exit != null && exit.signal == null && ( + exit.code === 0 || exit.code === SIDECAR_LAUNCHER_RETRY_EXIT_CODE + ); + if (launcherConverged && generationStable && description != null && ownerPid != null) { + if (stableOwnerPid !== ownerPid) { + stableOwnerPid = ownerPid; + stableSince = Date.now(); + } + if (Date.now() - stableSince >= stabilityMs) { + return { attempts, description, launcherProcess: launcher }; + } + } else { + stableOwnerPid = null; + stableSince = 0; + } + + if (exitedAt != null && !generationStable && Date.now() - exitedAt >= retryDelayMs) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + if (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + + if (lastLauncher != null && isProcessAlive(lastLauncher.pid)) { + await stopProcesses([lastLauncher.pid], { killGraceMs: 1_000, termGraceMs: 1_000 }); + } + const exitSuffix = lastExit == null + ? "" + : `; last launcher exit code=${lastExit.code ?? "null"} signal=${lastExit.signal ?? "null"}`; + throw new SidecarLaunchConvergenceError( + `sidecar launcher did not leave one stable ready generation after ${attempts} attempt(s) within ${timeoutMs}ms${exitSuffix}`, + lastLauncher?.pid ?? 0, + ); +} + +function observeChildExit(child: ChildProcess): () => { code: number | null; signal: NodeJS.Signals | null } | null { + let exit = child.exitCode != null || child.signalCode != null + ? { code: child.exitCode, signal: child.signalCode } + : null; + child.once("exit", (code, signal) => { exit = { code, signal }; }); + return () => exit; +} + +function normalizeDuration(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : fallback; +} + +function sidecarSpawnRequest(request: SidecarLaunchRequest): SpawnProcessRequest { + const stamp = normalizeSidecarStamp(request.stamp); + const { args = [], command, env, resources, ...spawnRequest } = request; + const preparedEnv = prepareSidecarLaunchEnvironment(env ?? process.env, resources); + const supervisorEntry = import.meta.url.endsWith(".ts") + ? fileURLToPath(new URL("./supervisor.ts", import.meta.url)) + : fileURLToPath(new URL("./supervisor.mjs", import.meta.url)); + return { + ...spawnRequest, + args: [ + ...(import.meta.url.endsWith(".ts") ? ["--import", "tsx"] : []), + supervisorEntry, + ...createProcessStampArgs(stamp, SIDECAR_STAMP_CONTRACT), + ], + command: process.execPath, + env: { + ...preparedEnv, + ELECTRON_RUN_AS_NODE: "1", + [SIDECAR_SUPERVISOR_TARGET_ENV]: JSON.stringify({ + args: removeSidecarLauncherArgs(args), + command, + electronRunAsNode: preparedEnv.ELECTRON_RUN_AS_NODE ?? null, + }), + }, + }; +} + +export async function spawnSidecar(request: SidecarLaunchRequest): Promise { + const stamp = normalizeSidecarStamp(request.stamp); + const child = await spawnLoggedProcess(sidecarSpawnRequest({ + ...request, + // A Windows generation must have its own process group so tree discovery + // and retirement cannot cross into the launching shell. This is a + // platform invariant, not a caller preference. + detached: process.platform === "win32" ? true : request.detached, + stamp, + })); + if (child.pid == null) throw new Error("spawned sidecar process has no pid"); + const rootPid = child.pid; + const rootSnapshot = process.platform === "win32" + ? (await captureProcessSnapshotsByPids([rootPid]))[0] ?? null + : null; + const ref = sidecarGenerationRef(stamp, rootPid, rootSnapshot?.startedAtMs); + const childProcess = child as ChildProcess & { pid: number }; + let stopTask: Promise | null = null; + return { + process: childProcess, + stamp, + stop(options = {}) { + stopTask ??= retireKnownSidecarGeneration(ref, options).finally(() => { + stopTask = null; + }); + return stopTask; + }, + }; +} + +export async function findSidecarProcesses(stamp: SidecarStamp) { + const exact = normalizeSidecarStamp(stamp); + const matches = (await listProcessSnapshots()).filter((processInfo) => + matchesStampedProcess(processInfo, exact, SIDECAR_STAMP_CONTRACT) && + !isSidecarLauncherCommand(processInfo.command), + ); + const matchedPids = new Set(matches.map(({ pid }) => pid)); + return matches.filter(({ ppid }) => !matchedPids.has(ppid)); +} + +export async function getSidecarStatus( + stamp: SidecarStamp, + options?: { generationPid?: number; timeoutMs?: number }, +): Promise { + const exact = normalizeSidecarStamp(stamp); + return await requestJsonIpc( + resolvePrivateIpcPath(exact), + { targetPid: options?.generationPid, type: sidecarProtocol.status }, + options == null ? undefined : { timeoutMs: options.timeoutMs }, + ); +} + +export async function invokeSidecar( + stamp: SidecarStamp, + action: string, + input: unknown, + options?: { timeoutMs?: number }, +): Promise { + const exact = normalizeSidecarStamp(stamp); + return await requestJsonIpc( + resolvePrivateIpcPath(exact), + { action, app: exact.app, input, type: "sidecar:invoke" }, + options, + ); +} + +export async function stopSidecar(stamp: SidecarStamp, options: SidecarStopOptions = {}): Promise { + const exact = normalizeSidecarStamp(stamp); + return await retireObservedSidecarGeneration( + await observeSidecarGeneration(exact, options.gracefulRequestTimeoutMs), + options, + ); +} + +/** Stop a logical resource set against one shared generation boundary. */ +export async function stopSidecars(requests: readonly SidecarStopRequest[]): Promise { + if (requests.length === 0) { + return { ...emptyStopSetResult(), results: [] }; + } + const normalized = new Map(); + for (const request of requests) { + const stamp = normalizeSidecarStamp(request.stamp); + normalized.set(JSON.stringify(stamp), { options: request.options, stamp }); + } + const unique = [...normalized.values()]; + const observations = await observeSidecarGenerations( + unique.map(({ stamp }) => stamp), + unique.map(({ options }) => options?.gracefulRequestTimeoutMs ?? 2_000), + ); + const stopped = await retireObservedSidecarGenerations(observations.map((observation, index) => ({ + observation, + options: unique[index]!.options, + }))); + const results = stopped.map((result, index) => ({ result, stamp: unique[index]!.stamp })); + const matchedPids = [...new Set(stopped.flatMap(({ matchedPids: pids }) => pids))]; + const remainingPids = [...new Set(stopped.flatMap(({ remainingPids: pids }) => pids))]; + const stoppedPids = [...new Set(stopped.flatMap(({ stoppedPids: pids }) => pids))]; + const forcedPids = [...new Set(stopped.flatMap(({ forcedPids: pids }) => pids))]; + return { + alreadyStopped: stopped.every(({ alreadyStopped }) => alreadyStopped), + forcedPids, + gracefulAccepted: stopped.some(({ gracefulAccepted }) => gracefulAccepted), + matchedPids, + remainingPids, + results, + stoppedPids, + }; +} + +function emptyStopSetResult(): SidecarStopResult { + return { + alreadyStopped: true, + forcedPids: [], + gracefulAccepted: false, + matchedPids: [], + remainingPids: [], + stoppedPids: [], + }; +} + +/** + * Replace one exact five-field sidecar resource while preserving concrete OS + * resources known by the prior generation. A requested non-zero port remains + * authoritative; zero means dynamic and inherits a known concrete predecessor + * port unless the caller explicitly asks for a fresh allocation. + */ +export async function restartSidecar( + request: SidecarLaunchRequest, + options: SidecarRestartOptions = {}, +): Promise { + const stamp = normalizeSidecarStamp(request.stamp); + return await serializeRestart(stamp, async () => await restartSidecarGeneration({ ...request, stamp }, options)); +} + +async function restartSidecarGeneration( + request: SidecarLaunchRequest, + options: SidecarRestartOptions, +): Promise { + const stamp = normalizeSidecarStamp(request.stamp); + const inspected = await observeSidecarGeneration(stamp); + const previous = inspected.description; + const requestedPort = request.resources.port; + const knownPort = previous?.resources.port ?? 0; + if (options.requireConcretePort === true && requestedPort === 0 && knownPort === 0) { + throw new Error("cannot restart sidecar without a concrete port"); + } + const stop = await retireObservedSidecarGeneration(inspected, options.stop ?? {}); + if (stop.remainingPids.length > 0) { + throw new Error(`cannot restart sidecar while prior generation remains: ${stop.remainingPids.join(", ")}`); + } + + const replacements = (await captureSidecarGenerationSnapshot(stamp)).roots; + if (replacements.length > 0) { + throw new Error(`cannot restart sidecar because another generation appeared: ${replacements.map(({ pid }) => pid).join(", ")}`); + } + + const reusedPort = options.reuseKnownPort !== false && requestedPort === 0 && knownPort > 0; + const launched = await launchSidecar({ + ...request, + resources: { + ...request.resources, + port: reusedPort ? knownPort : requestedPort, + }, + stamp, + }); + try { + await waitForOwnedSidecarReady(stamp, launched.pid); + } catch (error) { + const cleanup = await retireKnownSidecarGeneration(sidecarGenerationRef(stamp, launched.pid), { termGraceMs: 0 }); + if (cleanup.remainingPids.length > 0) { + throw new AggregateError( + [error, new Error(`failed to retire rejected restart generation: ${cleanup.remainingPids.join(", ")}`)], + "sidecar restart failed and cleanup was incomplete", + ); + } + throw error; + } + return { pid: launched.pid, reusedPort, stop }; +} + +async function serializeRestart(stamp: SidecarStamp, operation: () => Promise): Promise { + const key = JSON.stringify(normalizeSidecarStamp(stamp)); + const previous = restartTails.get(key) ?? Promise.resolve(); + let release!: () => void; + const tail = new Promise((resolve) => { release = resolve; }); + restartTails.set(key, tail); + await previous; + try { + return await operation(); + } finally { + release(); + if (restartTails.get(key) === tail) restartTails.delete(key); + } +} + +async function waitForOwnedSidecarReady(stamp: SidecarStamp, pid: number, timeoutMs = RESTART_READY_TIMEOUT_MS): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const description = await describeSidecarGeneration(stamp); + if (description?.resources.pid === pid && description.ready) return; + if (description != null && description.resources.pid !== pid) { + throw new Error(`sidecar restart lost endpoint ownership to pid ${description.resources.pid}`); + } + if (!isProcessAlive(pid)) throw new Error(`sidecar restart generation ${pid} exited before becoming ready`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`sidecar restart did not acquire endpoint ownership for pid ${pid}`); +} + +async function waitForBootstrappedSidecarReady(stamp: SidecarStamp, pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let stableExisting: { pid: number; since: number } | null = null; + while (Date.now() < deadline) { + const description = await describeSidecarGeneration(stamp); + if (description?.resources.pid === pid && description.ready) return; + if (description?.ready === true && description.resources.pid !== pid && !isProcessAlive(pid)) { + const observedExisting = stableExisting as { pid: number; since: number } | null; + if (observedExisting == null || observedExisting.pid !== description.resources.pid) { + stableExisting = { pid: description.resources.pid, since: Date.now() }; + } else if (Date.now() - observedExisting.since >= EXISTING_GENERATION_STABILITY_MS) { + return; + } + } else { + stableExisting = null; + } + if (!isProcessAlive(pid) && description == null) { + throw new SidecarBootstrapGenerationRetiredError(pid); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`sidecar bootstrap did not leave a ready generation for pid ${pid}`); +} diff --git a/packages/sidecar/src/paths.ts b/packages/sidecar/src/paths.ts index 2835ddde15..bdb1fed7d0 100644 --- a/packages/sidecar/src/paths.ts +++ b/packages/sidecar/src/paths.ts @@ -21,7 +21,7 @@ import type { RuntimeRootRequest, SidecarContractDescriptor, SidecarRuntimeContext, - SidecarStampShape, + RuntimeLayoutStampShape, } from "./types.js"; /** @@ -29,7 +29,7 @@ import type { * the contract, or the contract default — then normalize it. * @returns The normalized namespace. */ -export function resolveNamespace(options: NamespaceResolutionOptions): string { +export function resolveNamespace(options: NamespaceResolutionOptions): string { return options.contract.normalizeNamespace( options.namespace ?? options.env?.[options.contract.env.namespace] ?? @@ -52,7 +52,7 @@ export function resolveProjectRoot(projectRoot: string): string { * Resolve the project's tmp root (`/`). * @returns The resolved tmp root path. */ -export function resolveProjectTmpRoot({ +export function resolveProjectTmpRoot({ contract, projectRoot, }: { @@ -66,7 +66,7 @@ export function resolveProjectTmpRoot({ * Resolve the source-scoped runtime root (`/`). * @returns The resolved source runtime root path. */ -export function resolveSourceRuntimeRoot({ +export function resolveSourceRuntimeRoot({ contract, projectRoot, source, @@ -79,7 +79,7 @@ export function resolveSourceRuntimeRoot({ * contract, or the source runtime root. * @returns The resolved absolute base path. */ -export function resolveSidecarBase({ +export function resolveSidecarBase({ base, contract, env = process.env, @@ -100,7 +100,7 @@ export function resolveSidecarBase({ * Resolve the namespace root (`/`). * @returns The resolved namespace root path. */ -export function resolveNamespaceRoot({ +export function resolveNamespaceRoot({ base, contract, namespace, @@ -132,7 +132,7 @@ export function resolveNamespaceRoot({ * `SIDECAR_MODES.RUNTIME`) so this generic helper does not have to hardcode * OpenDesign's mode strings. */ -export function resolveRuntimeNamespaceRoot({ +export function resolveRuntimeNamespaceRoot({ contract, runtime, runtimeMode, @@ -154,7 +154,7 @@ export function resolveRuntimeNamespaceRoot({ * Resolve a per-run runtime root (`/runs/`). * @returns The resolved run runtime root path. */ -export function resolveRuntimeRoot({ +export function resolveRuntimeRoot({ base, contract, namespace, @@ -167,7 +167,7 @@ export function resolveRuntimeRoot({ * Resolve the namespace pointer file (`/current.json`). * @returns The resolved pointer path. */ -export function resolvePointerPath({ base, contract, namespace }: RuntimePathRequest): string { +export function resolvePointerPath({ base, contract, namespace }: RuntimePathRequest): string { return join(resolveNamespaceRoot({ base, contract, namespace }), "current.json"); } @@ -183,7 +183,7 @@ export function resolveManifestPath({ runtimeRoot }: { runtimeRoot: string }): s * Resolve an app's logs directory (`/logs/`). * @returns The resolved logs directory path. */ -export function resolveLogsDir({ +export function resolveLogsDir({ app, contract, runtimeRoot, @@ -199,7 +199,7 @@ export function resolveLogsDir({ * Resolve an app's log file (`/`, default `latest.log`). * @returns The resolved log file path. */ -export function resolveLogFilePath({ +export function resolveLogFilePath({ app, contract, fileName = "latest.log", @@ -217,7 +217,7 @@ export function resolveLogFilePath({ * Resolve an app's runtime dir under a namespace root (`/`). * @returns The resolved app runtime directory path. */ -export function resolveAppRuntimeDir({ +export function resolveAppRuntimeDir({ app, contract, namespaceRoot, @@ -229,7 +229,7 @@ export function resolveAppRuntimeDir({ * Resolve a file inside an app's runtime dir, rejecting non-simple file names. * @returns The resolved app runtime file path. */ -export function resolveAppRuntimePath({ +export function resolveAppRuntimePath({ app, contract, fileName, @@ -246,7 +246,7 @@ export function resolveAppRuntimePath({ * `//.sock`. * @returns The resolved IPC socket/pipe path. */ -export function resolveAppIpcPath({ +export function resolveAppIpcPath({ app, contract, env = process.env, diff --git a/packages/sidecar/src/process-retirement.ts b/packages/sidecar/src/process-retirement.ts new file mode 100644 index 0000000000..3639a05406 --- /dev/null +++ b/packages/sidecar/src/process-retirement.ts @@ -0,0 +1,121 @@ +import type { ProcessSnapshot, StopProcessesOptions, StopProcessesResult } from "@open-design/platform"; +import { + captureProcessSnapshot, + isProcessAlive, + matchesStampedProcess, + signalProcesses, + stopProcesses, +} from "@open-design/platform"; + +import { collectSidecarGenerationPids } from "./process-tree.js"; +import { SIDECAR_STAMP_CONTRACT, type SidecarStamp } from "./stamp.js"; + +type FencedGenerationRef = Readonly<{ rootPid: number; startedAtMs?: number; stamp: SidecarStamp }>; +type SupervisedTargetRef = Readonly<{ rootPid: number; stamp: SidecarStamp; supervisorPid: number }>; + +type FencedRetirementOptions = Readonly<{ + gracefulStopInitiated: boolean; + knownSnapshots?: ProcessSnapshot[]; + stopOptions?: StopProcessesOptions; +}>; + +/** Retire one already-fenced generation without ever adopting a replacement root. */ +export async function retireFencedSidecarProcessTree( + ref: FencedGenerationRef, + options: FencedRetirementOptions, +): Promise { + return await retireSidecarProcessTree( + ref, + options, + (processInfo) => processInfo.pid === ref.rootPid && + (ref.startedAtMs == null || processInfo.startedAtMs === ref.startedAtMs) && + matchesStampedProcess(processInfo, ref.stamp, SIDECAR_STAMP_CONTRACT), + (initialPids) => initialPids, + ); +} + +/** Retire the target process tree directly owned by a live supervisor. */ +export async function retireSupervisedSidecarTargetTree( + ref: SupervisedTargetRef, + options: FencedRetirementOptions, +): Promise { + return await retireSidecarProcessTree( + ref, + options, + (processInfo) => processInfo.pid === ref.rootPid && processInfo.ppid === ref.supervisorPid, + () => [ref.rootPid], + ); +} + +async function retireSidecarProcessTree( + ref: FencedGenerationRef, + options: FencedRetirementOptions, + ownsRoot: (processInfo: ProcessSnapshot) => boolean, + termTargets: (initialPids: number[]) => number[], +): Promise { + const snapshots = options.knownSnapshots ?? await captureProcessSnapshot(); + const rootOwnedAtEntry = snapshots.some(ownsRoot); + if (!rootOwnedAtEntry) return alreadyStoppedResult(); + + const initialPids = collectSidecarGenerationPids(snapshots, [ref.rootPid], ref.stamp); + if (!options.gracefulStopInitiated) signalProcesses(termTargets(initialPids), "SIGTERM"); + + const graceMs = normalizeGraceMs(options.stopOptions?.termGraceMs, 5_000); + const deadline = Date.now() + graceMs; + const remainingInitialPids = (): number[] => initialPids.filter(isProcessAlive); + let remaining = remainingInitialPids(); + while (remaining.length > 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + remaining = remainingInitialPids(); + } + if (remaining.length === 0) return stoppedResult(initialPids); + + // Refresh only while the same root fence still holds. If it vanished while + // descendants survived, fall back to the entry snapshot instead of adopting + // processes now owned by another generation. + const latestSnapshots = await captureProcessSnapshot(); + const rootStillOwned = latestSnapshots.some(ownsRoot); + const forceTargets = rootStillOwned + ? collectSidecarGenerationPids(latestSnapshots, [ref.rootPid], ref.stamp) + : remainingInitialPids(); + if (forceTargets.length === 0) return stoppedResult(initialPids); + + const forced = await stopProcesses(forceTargets, { + killGraceMs: options.stopOptions?.killGraceMs, + termGraceMs: 0, + }); + const matchedPids = [...new Set([...initialPids, ...forceTargets])]; + return { + alreadyStopped: false, + forcedPids: forced.forcedPids, + matchedPids, + remainingPids: forced.remainingPids, + stoppedPids: matchedPids.filter((pid) => !forced.remainingPids.includes(pid)), + }; +} + +function normalizeGraceMs(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : fallback; +} + +function alreadyStoppedResult(): StopProcessesResult { + return { + alreadyStopped: true, + forcedPids: [], + matchedPids: [], + remainingPids: [], + stoppedPids: [], + }; +} + +function stoppedResult(matchedPids: number[]): StopProcessesResult { + return { + alreadyStopped: false, + forcedPids: [], + matchedPids, + remainingPids: [], + stoppedPids: matchedPids, + }; +} diff --git a/packages/sidecar/src/process-tree.ts b/packages/sidecar/src/process-tree.ts new file mode 100644 index 0000000000..78db705a3b --- /dev/null +++ b/packages/sidecar/src/process-tree.ts @@ -0,0 +1,68 @@ +import { + captureStampedProcessSnapshot, + captureStampedProcessSetSnapshot, + collectProcessTreePids, + readProcessStampFromCommand, + type ProcessSnapshot, + type StampedProcessInvocationSnapshot, +} from "@open-design/platform"; + +import { + isSidecarLauncherCommand, + sidecarStampKey, + SIDECAR_STAMP_CONTRACT, + type SidecarStamp, +} from "./stamp.js"; + +/** Capture stamped generation roots while excluding uncommitted launcher contenders. */ +export async function captureSidecarGenerationSnapshot( + stamp: SidecarStamp, +): Promise { + const snapshot = await captureStampedProcessSnapshot(stamp, SIDECAR_STAMP_CONTRACT); + const matches = snapshot.matches.filter(({ command }) => !isSidecarLauncherCommand(command)); + const matchedPids = new Set(matches.map(({ pid }) => pid)); + return { + matches, + processes: snapshot.processes, + roots: matches.filter(({ ppid }) => !matchedPids.has(ppid)), + }; +} + +/** Capture several sidecar resources against one OS process-table boundary. */ +export async function captureSidecarGenerationSetSnapshot( + stamps: readonly SidecarStamp[], +): Promise> { + const snapshot = await captureStampedProcessSetSnapshot(stamps, SIDECAR_STAMP_CONTRACT); + return snapshot.entries.map((entry) => { + const matches = entry.matches.filter(({ command }) => !isSidecarLauncherCommand(command)); + const matchedPids = new Set(matches.map(({ pid }) => pid)); + return { + matches, + processes: snapshot.processes, + roots: matches.filter(({ ppid }) => !matchedPids.has(ppid)), + stamp: entry.criteria, + }; + }); +} + +/** + * Collect one sidecar generation without crossing into a descendant resource. + * + * The generation owns ordinary descendants, including targets whose visible + * argv is later rewritten. A descendant carrying a different complete + * five-field stamp is another sidecar resource root and owns its own subtree. + */ +export function collectSidecarGenerationPids( + processes: ProcessSnapshot[], + rootPids: Array, + stampInput: SidecarStamp, +): number[] { + const stampKey = sidecarStampKey(stampInput); + const roots = new Set(rootPids.filter((pid): pid is number => typeof pid === "number")); + const ownedProcesses = processes.filter((processInfo) => { + if (roots.has(processInfo.pid)) return true; + const nestedStamp = readProcessStampFromCommand(processInfo.command, SIDECAR_STAMP_CONTRACT); + return nestedStamp == null || sidecarStampKey(nestedStamp) === stampKey; + }); + return collectProcessTreePids(ownedProcesses, [...roots]); +} diff --git a/packages/sidecar/src/stamp.ts b/packages/sidecar/src/stamp.ts new file mode 100644 index 0000000000..f3bc004939 --- /dev/null +++ b/packages/sidecar/src/stamp.ts @@ -0,0 +1,160 @@ +import { createHash } from "node:crypto"; +import { tmpdir, userInfo } from "node:os"; +import { join } from "node:path"; + +import type { ProcessStampContract } from "@open-design/platform"; +import { readProcessStamp } from "@open-design/platform"; + +export const SIDECAR_STAMP_FIELDS = ["channel", "namespace", "source", "mode", "app"] as const; + +export type SidecarStampField = (typeof SIDECAR_STAMP_FIELDS)[number]; + +export type SidecarStamp = { + channel: string; + namespace: string; + source: string; + mode: string; + app: string; +}; + +export const SIDECAR_STAMP_FLAGS: Readonly> = Object.freeze({ + channel: "--od-stamp-channel", + namespace: "--od-stamp-namespace", + source: "--od-stamp-source", + mode: "--od-stamp-mode", + app: "--od-stamp-app", +}); + +/** Internal argv marker for a client-side launch attempt that has not claimed generation ownership. */ +export const SIDECAR_LIFECYCLE_FLAG = "--od-sidecar-lifecycle"; +export const SIDECAR_LAUNCHER_LIFECYCLE = "launcher"; +const SIDECAR_LAUNCHER_ARG = `${SIDECAR_LIFECYCLE_FLAG}=${SIDECAR_LAUNCHER_LIFECYCLE}`; +export const SIDECAR_SUPERVISED_CONTEXT_ENV = "OD_SIDECAR_SUPERVISED_CONTEXT"; + +export type SupervisedSidecarContext = Readonly<{ + generationPid: number; + resources: unknown; + stamp: SidecarStamp; +}>; + +function normalizeStampToken(value: unknown, field: SidecarStampField): string { + if (typeof value !== "string" || value.length === 0 || value.trim() !== value) { + throw new Error(`sidecar stamp ${field} must be a non-empty string without surrounding whitespace`); + } + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) { + throw new Error(`sidecar stamp ${field} contains unsupported characters: ${value}`); + } + return value; +} + +export function normalizeSidecarStamp(input: unknown): SidecarStamp { + if (typeof input !== "object" || input == null || Array.isArray(input)) { + throw new Error("sidecar stamp must be an object"); + } + const value = input as Record; + const unexpected = Object.keys(value).filter((field) => !SIDECAR_STAMP_FIELDS.includes(field as SidecarStampField)); + if (unexpected.length > 0) throw new Error(`sidecar stamp contains unsupported fields: ${unexpected.join(", ")}`); + return { + channel: normalizeStampToken(value.channel, "channel"), + namespace: normalizeStampToken(value.namespace, "namespace"), + source: normalizeStampToken(value.source, "source"), + mode: normalizeStampToken(value.mode, "mode"), + app: normalizeStampToken(value.app, "app"), + }; +} + +export const SIDECAR_STAMP_CONTRACT: ProcessStampContract = Object.freeze({ + normalizeStamp: normalizeSidecarStamp, + normalizeStampCriteria: normalizeSidecarStamp, + stampFields: SIDECAR_STAMP_FIELDS, + stampFlags: SIDECAR_STAMP_FLAGS, +}); + +export function readCurrentSidecarArgvStamp(): SidecarStamp { + const stamp = readProcessStamp(process.argv.slice(1), SIDECAR_STAMP_CONTRACT); + if (stamp == null) throw new Error("the five-field sidecar argv stamp is required"); + return stamp; +} + +/** Read the resource identity supplied by either a launcher argv or its committed supervisor. */ +export function readCurrentSidecarStamp(): SidecarStamp { + return readSupervisedSidecarContext()?.stamp ?? readCurrentSidecarArgvStamp(); +} + +export function serializeSupervisedSidecarContext( + stampInput: SidecarStamp, + generationPid: number, + resources: unknown, +): string { + if (!Number.isSafeInteger(generationPid) || generationPid <= 0) { + throw new Error("sidecar generation pid must be a positive safe integer"); + } + return JSON.stringify({ generationPid, resources, stamp: normalizeSidecarStamp(stampInput) }); +} + +export function readSupervisedSidecarContext( + env: NodeJS.ProcessEnv = process.env, +): SupervisedSidecarContext | null { + const serialized = env[SIDECAR_SUPERVISED_CONTEXT_ENV]; + if (serialized == null) return null; + let value: unknown; + try { + value = JSON.parse(serialized); + } catch (error) { + throw new Error(`${SIDECAR_SUPERVISED_CONTEXT_ENV} must contain valid JSON`, { cause: error }); + } + if (typeof value !== "object" || value == null || Array.isArray(value)) { + throw new Error(`${SIDECAR_SUPERVISED_CONTEXT_ENV} must contain a context object`); + } + const context = value as Record; + const generationPid = Number(context.generationPid); + if (!Number.isSafeInteger(generationPid) || generationPid <= 0) { + throw new Error(`${SIDECAR_SUPERVISED_CONTEXT_ENV} contains an invalid generation pid`); + } + return Object.freeze({ + generationPid, + resources: context.resources, + stamp: normalizeSidecarStamp(context.stamp), + }); +} + +export function isCurrentSidecarLauncher(): boolean { + return process.argv.slice(1).includes(SIDECAR_LAUNCHER_ARG); +} + +export function isSidecarLauncherCommand(command: string): boolean { + return command.split(/\s+/).some((token) => token.replace(/^['"]|['"]$/g, "") === SIDECAR_LAUNCHER_ARG); +} + +export function createSidecarLauncherArgs(stamp: SidecarStamp): string[] { + const normalized = normalizeSidecarStamp(stamp); + return [ + ...SIDECAR_STAMP_FIELDS.map((field) => `${SIDECAR_STAMP_FLAGS[field]}=${normalized[field]}`), + SIDECAR_LAUNCHER_ARG, + ]; +} + +export function removeSidecarLauncherArgs(args: readonly string[]): string[] { + const stampPrefixes = SIDECAR_STAMP_FIELDS.map((field) => `${SIDECAR_STAMP_FLAGS[field]}=`); + return args.filter((argument) => + argument !== SIDECAR_LAUNCHER_ARG && + !stampPrefixes.some((prefix) => argument.startsWith(prefix)), + ); +} + +export function sidecarStampKey(stamp: SidecarStamp): string { + const value = normalizeSidecarStamp(stamp); + return SIDECAR_STAMP_FIELDS.map((field) => `${field}=${value[field]}`).join("\n"); +} + +export function resolvePrivateIpcPath(stamp: SidecarStamp, platform: NodeJS.Platform = process.platform): string { + const principal = platform === "win32" + ? (() => { + try { return userInfo().username; } catch { return process.env.USERNAME ?? process.env.USER ?? "unknown"; } + })() + : String(process.getuid?.() ?? process.env.USER ?? "unknown"); + const digest = createHash("sha256").update(`${principal}\n${sidecarStampKey(stamp)}`).digest("hex").slice(0, 32); + return platform === "win32" + ? `\\\\.\\pipe\\open-design-sidecar-${digest}` + : join(tmpdir(), `od-sidecar-${principal}`, `${digest}.sock`); +} diff --git a/packages/sidecar/src/supervisor.ts b/packages/sidecar/src/supervisor.ts new file mode 100644 index 0000000000..d4ee801611 --- /dev/null +++ b/packages/sidecar/src/supervisor.ts @@ -0,0 +1,188 @@ +import { spawn } from "node:child_process"; + +import { + captureProcessSnapshot, + stopProcesses, +} from "@open-design/platform"; + +import { + readSidecarLaunchResources, + sidecarSupervisorProtocol, + sidecarProtocol, + SIDECAR_SUPERVISOR_TARGET_ENV, +} from "./client.js"; +import type { SidecarGenerationHandoffRequest } from "./client.js"; +import { requestJsonIpc } from "./json-ipc.js"; +import { retireSupervisedSidecarTargetTree } from "./process-retirement.js"; +import { + readCurrentSidecarArgvStamp, + resolvePrivateIpcPath, + serializeSupervisedSidecarContext, + SIDECAR_SUPERVISED_CONTEXT_ENV, +} from "./stamp.js"; + +type SupervisorTarget = { + args: string[]; + command: string; + electronRunAsNode: string | null; +}; + +function readTarget(): SupervisorTarget { + const serialized = process.env[SIDECAR_SUPERVISOR_TARGET_ENV]; + if (serialized == null) throw new Error(`${SIDECAR_SUPERVISOR_TARGET_ENV} is required`); + const value = JSON.parse(serialized) as Partial; + if (typeof value.command !== "string" || !Array.isArray(value.args) || !value.args.every((arg) => typeof arg === "string")) { + throw new Error(`${SIDECAR_SUPERVISOR_TARGET_ENV} is invalid`); + } + return { + args: value.args, + command: value.command, + electronRunAsNode: typeof value.electronRunAsNode === "string" ? value.electronRunAsNode : null, + }; +} + +const target = readTarget(); +const stamp = readCurrentSidecarArgvStamp(); +const resources = readSidecarLaunchResources(); +function supervisedEnvironment(env: NodeJS.ProcessEnv, electronRunAsNode: string | null): NodeJS.ProcessEnv { + const childEnv: NodeJS.ProcessEnv = { + ...env, + [SIDECAR_SUPERVISED_CONTEXT_ENV]: serializeSupervisedSidecarContext(stamp, process.pid, resources), + }; + delete childEnv[SIDECAR_SUPERVISOR_TARGET_ENV]; + delete childEnv[sidecarProtocol.resourcesEnv]; + if (electronRunAsNode == null) delete childEnv.ELECTRON_RUN_AS_NODE; + else childEnv.ELECTRON_RUN_AS_NODE = electronRunAsNode; + return childEnv; +} + +function spawnTarget(next: SupervisorTarget & { cwd?: string; env?: NodeJS.ProcessEnv }) { + return spawn(next.command, next.args, { + cwd: next.cwd ?? process.cwd(), + env: supervisedEnvironment(next.env ?? process.env, next.electronRunAsNode), + stdio: ["inherit", "inherit", "inherit", "ipc"], + windowsHide: true, + }); +} + +let child = spawnTarget(target); +let pendingHandoff: SidecarGenerationHandoffRequest | null = null; + +function acceptChildMessage(message: unknown): void { + const envelope = message as { + request?: SidecarGenerationHandoffRequest; + requestId?: unknown; + type?: unknown; + } | null; + if (envelope?.type !== sidecarSupervisorProtocol.handoff || typeof envelope.requestId !== "string") return; + const request = envelope.request; + if (request == null || typeof request.command !== "string" || request.command.length === 0 || pendingHandoff != null) return; + pendingHandoff = request; + child.send?.({ requestId: envelope.requestId, type: sidecarSupervisorProtocol.handoffAccepted }); +} + +const ownerPid = resources.ownerPid; +let ownerShutdownTask: Promise | null = null; +let ownerExpired = false; +async function stopTargetAfterOwnerDeath(): Promise { + ownerExpired = true; + if (child.pid == null) return; + const rootPid = child.pid; + let snapshots; + try { + snapshots = await captureProcessSnapshot(); + } catch { + // The exact child pid remains a safe fallback when process discovery fails. + const gracefulStopInitiated = await requestTargetStop(); + if (!gracefulStopInitiated) { + try { child.kill("SIGTERM"); } catch {} + } + const stopped = await stopProcesses([rootPid], { killGraceMs: 1_000, termGraceMs: 5_000 }); + reportOwnerlessSurvivors(stopped.remainingPids); + return; + } + // Fence the live child tree before asking it to stop. A fast graceful exit + // may reparent resistant descendants, but the entry snapshot still retains + // the exact processes this supervisor owned at owner-death time. + const gracefulStopInitiated = await requestTargetStop(); + const stopped = await retireSupervisedSidecarTargetTree({ + rootPid, + stamp, + supervisorPid: process.pid, + }, { + gracefulStopInitiated, + knownSnapshots: snapshots, + stopOptions: { killGraceMs: 1_000, termGraceMs: 5_000 }, + }); + reportOwnerlessSurvivors(stopped.remainingPids); +} + +async function requestTargetStop(): Promise { + try { + const response = await requestJsonIpc<{ accepted?: unknown }>( + resolvePrivateIpcPath(stamp), + { targetPids: [process.pid], type: sidecarProtocol.stop }, + { timeoutMs: 750 }, + ); + return response.accepted === true; + } catch { + return false; + } +} + +function reportOwnerlessSurvivors(remainingPids: number[]): void { + if (remainingPids.length === 0) return; + console.error(`sidecar supervisor could not stop ownerless target: ${remainingPids.join(", ")}`); + process.exitCode = 1; +} + +const ownerTimer = ownerPid == null ? null : setInterval(() => { + try { + process.kill(ownerPid, 0); + } catch { + if (ownerTimer != null) clearInterval(ownerTimer); + ownerShutdownTask ??= stopTargetAfterOwnerDeath().catch((error) => { + console.error("sidecar supervisor failed to stop ownerless target", error); + process.exitCode = 1; + }); + } +}, 1_000); +ownerTimer?.unref(); + +let forwardedSignal: NodeJS.Signals | null = null; +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + forwardedSignal = signal; + if (child.pid != null) { + try { child.kill(signal); } catch {} + } + }); +} + +function observeTarget(): void { + child.on("message", acceptChildMessage); + child.once("error", (error) => { + console.error("sidecar supervisor failed to spawn target", error); + process.exitCode = 1; + }); + child.once("exit", (code, signal) => { + const handoff = pendingHandoff; + pendingHandoff = null; + if (handoff != null && forwardedSignal == null && !ownerExpired) { + child = spawnTarget({ + args: [...(handoff.args ?? [])], + command: handoff.command, + cwd: handoff.cwd, + electronRunAsNode: handoff.env?.ELECTRON_RUN_AS_NODE ?? null, + env: handoff.env, + }); + observeTarget(); + return; + } + if (ownerTimer != null) clearInterval(ownerTimer); + if (signal != null || forwardedSignal != null) process.exitCode = 0; + else process.exitCode = code ?? 1; + }); +} + +observeTarget(); diff --git a/packages/sidecar/src/types.ts b/packages/sidecar/src/types.ts index ca3a329e83..fc1cf6f144 100644 --- a/packages/sidecar/src/types.ts +++ b/packages/sidecar/src/types.ts @@ -8,20 +8,24 @@ * Pure type declarations with no runtime code and no dependency on sibling files. */ -/** Minimal shape of a sidecar process stamp: the five identity fields. */ -export type SidecarStampShape = { +/** Identity fields needed by runtime/path consumers, without transport. */ +export type RuntimeIdentityShape = { app: string; - ipc: string; mode: string; - namespace: string; source: string; }; +/** Legacy layout-contract input retained privately for path compatibility. */ +export type RuntimeLayoutStampShape = RuntimeIdentityShape & { + ipc: string; + namespace: string; +}; + /** * Host-supplied contract: default path/namespace values, env var names, and the * normalizers that validate/canonicalize app, namespace, source, and full stamp. */ -export type SidecarContractDescriptor = { +export type SidecarContractDescriptor = { defaults: { host: string; ipcBase: string; @@ -43,21 +47,21 @@ export type SidecarContractDescriptor = { +export type NamespaceResolutionOptions = { contract: SidecarContractDescriptor; env?: NodeJS.ProcessEnv; namespace?: string | null; }; /** Inputs for resolving a source-scoped runtime root under a project. */ -export type ProjectRuntimePathRequest = { +export type ProjectRuntimePathRequest = { contract: SidecarContractDescriptor; projectRoot: string; source: TStamp["source"] | string; }; /** Inputs for resolving the sidecar base (explicit, env, or project-derived). */ -export type BaseResolutionOptions = { +export type BaseResolutionOptions = { base?: string | null; contract: SidecarContractDescriptor; env?: NodeJS.ProcessEnv; @@ -66,19 +70,19 @@ export type BaseResolutionOptions = { +export type RuntimePathRequest = { base: string; contract: SidecarContractDescriptor; namespace: string; }; /** {@link RuntimePathRequest} plus a run id, for a per-run runtime root. */ -export type RuntimeRootRequest = RuntimePathRequest & { +export type RuntimeRootRequest = RuntimePathRequest & { runId: string; }; /** Inputs for resolving an app's IPC socket/pipe path. */ -export type AppIpcPathRequest = { +export type AppIpcPathRequest = { app: TStamp["app"] | string; contract: SidecarContractDescriptor; env?: NodeJS.ProcessEnv; @@ -86,24 +90,23 @@ export type AppIpcPathRequest = { +export type AppRuntimePathRequest = { app: TStamp["app"] | string; contract: SidecarContractDescriptor; namespaceRoot: string; }; /** The resolved live runtime context of a bootstrapped sidecar process. */ -export type SidecarRuntimeContext = { +export type SidecarRuntimeContext = { app: TStamp["app"]; base: string; - ipc: string; mode: TStamp["mode"]; namespace: string; source: TStamp["source"]; }; /** Inputs for composing the env a child sidecar is launched with. */ -export type SidecarLaunchEnvRequest = { +export type SidecarLaunchEnvRequest = { base: string; contract: SidecarContractDescriptor; extraEnv?: NodeJS.ProcessEnv; @@ -111,7 +114,7 @@ export type SidecarLaunchEnvRequest = { +export type BootstrapSidecarRuntimeOptions = { app: TStamp["app"] | string; base?: string | null; contract: SidecarContractDescriptor; diff --git a/packages/sidecar/tests/convergence.test.ts b/packages/sidecar/tests/convergence.test.ts new file mode 100644 index 0000000000..713f4ea77b --- /dev/null +++ b/packages/sidecar/tests/convergence.test.ts @@ -0,0 +1,1384 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { spawn } from "node:child_process"; +import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { captureProcessSnapshot, collectProcessTreePids, isProcessAlive, waitForProcessExit } from "@open-design/platform"; +import { + normalizeSidecarStamp, + allocatePort, + bootstrapSidecarProcess, + convergeSidecarLaunch, + findSidecarProcesses, + getSidecarStatus, + launchSidecar, + registerSidecarProcess, + readCurrentSidecarStamp, + restartSidecar, + SidecarFactory, + SIDECAR_STAMP_FIELDS, + SIDECAR_STAMP_FLAGS, + spawnSidecar, + spawnSidecarLauncher, + stopSidecar, + stopSidecars, + type SidecarResources, + type SidecarStamp, +} from "../src/index.js"; +import { retireSidecarGeneration, sidecarGenerationRef } from "../src/generation.js"; +import { collectSidecarGenerationPids } from "../src/process-tree.js"; +import { resolvePrivateIpcPath } from "../src/stamp.js"; + +const originalArgv = [...process.argv]; +const originalEndpoint = process.env.OD_SIDECAR_CLIENT_ENDPOINT; +const originalSupervisedContext = process.env.OD_SIDECAR_SUPERVISED_CONTEXT; +const FIXTURE_READY_TIMEOUT_MS = 10_000; + +const stamp: SidecarStamp = { + channel: "local", + namespace: `test-${process.pid}`, + source: "tools-dev", + mode: "dev", + app: "daemon", +}; + +function installCurrentProcess(stampValue: SidecarStamp, resources = { + dataRoot: "/tmp/open-design-test-data", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-test-runtime", +}): void { + process.argv = [process.execPath, "/tmp/sidecar-entry.js"]; + process.env.OD_SIDECAR_SUPERVISED_CONTEXT = JSON.stringify({ + generationPid: process.pid, + resources, + stamp: stampValue, + }); +} + +afterEach(() => { + process.argv = [...originalArgv]; + if (originalEndpoint == null) delete process.env.OD_SIDECAR_CLIENT_ENDPOINT; + else process.env.OD_SIDECAR_CLIENT_ENDPOINT = originalEndpoint; + if (originalSupervisedContext == null) delete process.env.OD_SIDECAR_SUPERVISED_CONTEXT; + else process.env.OD_SIDECAR_SUPERVISED_CONTEXT = originalSupervisedContext; +}); + +describe("five-field argv identity", () => { + it("contains channel rather than an IPC implementation field", () => { + expect(SIDECAR_STAMP_FIELDS).toEqual(["channel", "namespace", "source", "mode", "app"]); + expect(SIDECAR_STAMP_FIELDS).not.toContain("ipc"); + }); + + it("reads the complete stamp internally from current argv", () => { + delete process.env.OD_SIDECAR_SUPERVISED_CONTEXT; + process.argv = [ + process.execPath, + "/tmp/sidecar-entry.js", + ...SIDECAR_STAMP_FIELDS.map((field) => `${SIDECAR_STAMP_FLAGS[field]}=${stamp[field]}`), + ]; + expect(readCurrentSidecarStamp()).toEqual(stamp); + + process.argv = process.argv.filter((argument) => !argument.startsWith(`${SIDECAR_STAMP_FLAGS.channel}=`)); + expect(() => readCurrentSidecarStamp()).toThrow(/five-field sidecar argv stamp/); + }); + + it("refuses to register an unsupervised business process", () => { + delete process.env.OD_SIDECAR_SUPERVISED_CONTEXT; + process.argv = [process.execPath, "/tmp/packaged-entry.js"]; + expect(() => registerSidecarProcess(stamp, { + dataRoot: "/tmp/data", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/runtime", + })).toThrow("current process is missing its supervised sidecar context"); + expect(process.argv).toEqual([process.execPath, "/tmp/packaged-entry.js"]); + }); + + it("bootstraps an unstamped root through the launch atomic", async () => { + delete process.env.OD_SIDECAR_SUPERVISED_CONTEXT; + process.argv = [process.execPath, "/tmp/packaged-entry.js", "--headless"]; + const launch = vi.fn(async () => ({ pid: 4321 })); + const resources = { dataRoot: "/tmp/data", ownerPid: null, port: 0, runtimeRoot: "/tmp/runtime" }; + const waitUntilReady = vi.fn(async () => undefined); + await expect(bootstrapSidecarProcess(stamp, resources, { launch, waitUntilReady })).resolves.toBe(true); + expect(launch).toHaveBeenCalledWith(expect.objectContaining({ + args: ["/tmp/packaged-entry.js", "--headless"], + command: process.execPath, + resources, + stamp, + })); + expect(waitUntilReady).toHaveBeenCalledWith(stamp, 4321, 90_000); + }); + + it("enters server lifecycle from supervisor context without relaunching", async () => { + installCurrentProcess(stamp); + const launch = vi.fn(async () => ({ pid: 4321 })); + const resources = { dataRoot: "/tmp/final-data", ownerPid: null, port: 4173, runtimeRoot: "/tmp/final-runtime" }; + + await expect(bootstrapSidecarProcess(stamp, resources, { launch })).resolves.toBe(false); + + expect(launch).not.toHaveBeenCalled(); + expect(JSON.parse(process.env.OD_SIDECAR_SUPERVISED_CONTEXT ?? "null")?.resources).toEqual(resources); + expect(readCurrentSidecarStamp()).toEqual(stamp); + expect(process.argv).toEqual([process.execPath, "/tmp/sidecar-entry.js"]); + }); + + it("rejects partial matching and derived identity fields", () => { + expect(() => normalizeSidecarStamp({ ...stamp, ipc: "/tmp/not-identity.sock" })).toThrow(/unsupported fields: ipc/); + expect(() => normalizeSidecarStamp({ app: stamp.app, namespace: stamp.namespace })).toThrow(/channel/); + }); +}); + +describe("sidecar generation process trees", () => { + it("owns ordinary and same-stamp descendants but stops at a different stamped resource root", () => { + const nestedStamp = { ...stamp, app: "desktop" }; + const stampArgs = (stampValue: SidecarStamp) => SIDECAR_STAMP_FIELDS + .map((field) => `${SIDECAR_STAMP_FLAGS[field]}=${stampValue[field]}`) + .join(" "); + const processes = [ + { command: `node supervisor ${stampArgs(stamp)}`, pid: 10, ppid: 1 }, + { command: "node target", pid: 11, ppid: 10 }, + { command: "next-server", pid: 12, ppid: 11 }, + { command: `node supervisor ${stampArgs(nestedStamp)}`, pid: 20, ppid: 11 }, + { command: `node target ${stampArgs(nestedStamp)}`, pid: 21, ppid: 20 }, + ]; + + expect(collectSidecarGenerationPids(processes, [10], stamp)).toEqual([12, 11, 10]); + }); + + it("keeps a nested stamped resource alive when its ancestor generation stops", async () => { + const fixture = fileURLToPath(new URL("./fixtures/nested-sidecar.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-sidecar-nested-")); + const readyPath = join(root, "ready.json"); + const parentStamp = { ...stamp, app: "daemon", namespace: `nested-parent-${process.pid}` }; + const childStamp = { ...parentStamp, app: "desktop" }; + const parent = await spawnSidecar({ + args: ["--import", "tsx", fixture], + command: process.execPath, + env: { + ...process.env, + OD_TEST_NESTED_SIDECAR_READY: readyPath, + OD_TEST_NESTED_SIDECAR_STAMP: JSON.stringify(childStamp), + }, + resources: { + dataRoot: join(root, "parent-data"), + ownerPid: null, + port: 0, + runtimeRoot: join(root, "parent-runtime"), + }, + stamp: parentStamp, + }); + let childPid: number | null = null; + + try { + await vi.waitFor(async () => { + const ready = await readFile(readyPath, "utf8").then((value) => JSON.parse(value)).catch(() => null); + expect(ready).not.toBeNull(); + childPid = Number(ready.pid); + expect(childPid).toBeGreaterThan(0); + expect((await findSidecarProcesses(childStamp)).map(({ pid }) => pid)).toContain(childPid); + }, { interval: 100, timeout: process.platform === "win32" ? 15_000 : 5_000 }); + const nestedPid = childPid; + if (nestedPid == null) throw new Error("nested sidecar fixture did not report a pid"); + const genericTree = collectProcessTreePids(await captureProcessSnapshot(), [parent.process.pid]); + expect(genericTree).toContain(nestedPid); + + const result = await parent.stop({ killGraceMs: 2_000, termGraceMs: 0 }); + + expect(result.matchedPids).toContain(parent.process.pid); + expect(result.matchedPids).not.toContain(nestedPid); + expect(result.forcedPids).not.toContain(nestedPid); + await expect(waitForProcessExit(parent.process.pid, 2_000)).resolves.toBe(true); + expect(isProcessAlive(nestedPid)).toBe(true); + expect((await findSidecarProcesses(childStamp)).map(({ pid }) => pid)).toContain(nestedPid); + } finally { + await parent.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await stopSidecar(childStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }, process.platform === "win32" ? 45_000 : 15_000); +}); + +describe("normalized sidecar client", () => { + it("is the only layer that receives OS resources and implements IPC/lifecycle", async () => { + installCurrentProcess(stamp, { + dataRoot: "/tmp/open-design-data", + ownerPid: null, + port: 4173, + runtimeRoot: "/tmp/open-design-runtime", + }); + const events: string[] = []; + let inheritedDuringStart: Record | null = null; + let receivedResources: SidecarResources | null = null; + const client = SidecarFactory.create({ + handlers: { + echo(input) { + events.push("handler"); + return input; + }, + }, + lifecycle: { + async start(resources) { + events.push("start"); + inheritedDuringStart = SidecarFactory.inheritedEnvironment(); + receivedResources = resources; + return { ready: true }; + }, + status(runtime) { + return runtime; + }, + async stop() { + events.push("stop"); + }, + }, + }); + + expect(SidecarFactory.inheritedEnvironment()).toEqual({}); + expect(client.resources).toEqual({ + dataRoot: "/tmp/open-design-data", + ownerPid: null, + pid: process.pid, + port: 4173, + runtimeRoot: "/tmp/open-design-runtime", + }); + await client.start(); + const inheritedEnv = SidecarFactory.inheritedEnvironment(); + expect(Object.keys(inheritedEnv)).toHaveLength(1); + const inherited = SidecarFactory.connectInherited(inheritedEnv); + expect(inherited).not.toBeNull(); + await expect(inherited?.status("daemon")).resolves.toEqual({ ready: true }); + await expect(inherited?.invoke("daemon", "echo", { inherited: true })).resolves.toEqual({ inherited: true }); + await expect(client.invoke("daemon", "echo", { ok: true })).resolves.toEqual({ ok: true }); + await client.stop(); + await client.waitUntilStopped(); + expect(SidecarFactory.inheritedEnvironment()).toEqual({}); + + expect(receivedResources).toEqual(client.resources); + expect(inheritedDuringStart).toEqual({}); + expect(events).toEqual(["start", "handler", "handler", "stop"]); + }); + + it("hands off the supervised child without creating a second generation root", async () => { + const fixture = fileURLToPath(new URL("./fixtures/handoff-entry.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-sidecar-handoff-")); + const handoffStamp = { ...stamp, app: "desktop", namespace: `handoff-${process.pid}` }; + const spawned = await spawnSidecar({ + args: ["--import", "tsx", fixture], + command: process.execPath, + resources: { + dataRoot: join(root, "data"), + ownerPid: null, + port: 0, + runtimeRoot: join(root, "runtime"), + }, + stamp: handoffStamp, + }); + + try { + await vi.waitFor(async () => { + await expect(getSidecarStatus(handoffStamp, { generationPid: spawned.process.pid })) + .resolves.toEqual({ generationPid: spawned.process.pid, phase: "successor" }); + await expect(findSidecarProcesses(handoffStamp)).resolves.toEqual([ + expect.objectContaining({ pid: spawned.process.pid }), + ]); + }, { interval: 100, timeout: FIXTURE_READY_TIMEOUT_MS }); + } finally { + await spawned.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }, 15_000); + + it("does not accept argv, socket paths, or capability declarations", () => { + installCurrentProcess(stamp); + const options = { + handlers: {}, + lifecycle: { + async start() { return {}; }, + status() { return {}; }, + async stop() {}, + }, + }; + SidecarFactory.create(options); + expect(Object.keys(options).sort()).toEqual(["handlers", "lifecycle"]); + }); + + it("removes inherited generation state from a new generation environment", () => { + expect(SidecarFactory.newGenerationEnvironment({ + KEEP_ME: "yes", + OD_SIDECAR_CLIENT_ENDPOINT: "/private/old.sock", + OD_SIDECAR_RESOURCES: '{"runtimeRoot":"/old"}', + OD_SIDECAR_SUPERVISED_CONTEXT: '{"generationPid":42}', + OD_SIDECAR_SUPERVISOR_TARGET: '{"command":"old"}', + })).toEqual({ KEEP_ME: "yes" }); + }); + + it("retries startup after a transient IPC bind failure", async () => { + installCurrentProcess(stamp); + const blocker = SidecarFactory.create({ + lifecycle: { + async start() { return {}; }, + status() { return {}; }, + async stop() {}, + }, + }); + await blocker.start(); + + const events: string[] = []; + const client = SidecarFactory.create({ + lifecycle: { + async start() { + events.push("start"); + return {}; + }, + status() { return {}; }, + async stop() { events.push("stop"); }, + }, + }); + await expect(client.start()).rejects.toThrow(); + expect(events).toEqual([]); + expect(SidecarFactory.inheritedEnvironment()).toEqual({}); + + await blocker.stop(); + await expect(client.start()).resolves.toBeUndefined(); + expect(events).toEqual(["start"]); + expect(Object.keys(SidecarFactory.inheritedEnvironment())).toEqual(["OD_SIDECAR_CLIENT_ENDPOINT"]); + await client.stop(); + }); + + it("releases endpoint ownership when business lifecycle startup fails", async () => { + installCurrentProcess(stamp); + let attempts = 0; + const client = SidecarFactory.create({ + lifecycle: { + async start() { + attempts += 1; + if (attempts === 1) throw new Error("transient business startup failure"); + return { ready: true }; + }, + status(runtime) { return runtime; }, + async stop() {}, + }, + }); + + await expect(client.start()).rejects.toThrow("transient business startup failure"); + await expect(client.start()).resolves.toBeUndefined(); + await expect(client.status("daemon")).resolves.toEqual({ ready: true }); + await client.stop(); + }); +}); + +describe("server-side atomic operations", () => { + it("keeps an uncommitted launcher outside generation discovery", async () => { + const fixture = fileURLToPath(new URL("./fixtures/stamped-child.ts", import.meta.url)); + const launcherStamp = { ...stamp, namespace: `launcher-role-${process.pid}` }; + const launcher = await spawnSidecarLauncher({ + args: [fixture], + command: process.execPath, + resources: { + dataRoot: "/tmp/open-design-launcher-role", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-launcher-role-runtime", + }, + stamp: launcherStamp, + }); + try { + await vi.waitFor(() => expect(isProcessAlive(launcher.pid)).toBe(true)); + await expect(findSidecarProcesses(launcherStamp)).resolves.toEqual([]); + await expect(stopSidecar(launcherStamp)).resolves.toMatchObject({ alreadyStopped: true }); + expect(isProcessAlive(launcher.pid)).toBe(true); + } finally { + try { process.kill(launcher.pid, "SIGKILL"); } catch {} + await waitForProcessExit(launcher.pid, 2_000); + } + }); + + it("retries a clean launcher exit until one ready generation is stable", async () => { + const fixture = fileURLToPath(new URL("./fixtures/converging-launcher.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-converging-launcher-")); + const attemptPath = join(root, "attempt.txt"); + const launchStamp = { ...stamp, namespace: `launcher-convergence-${process.pid}` }; + await writeFile(attemptPath, "0"); + try { + const result = await convergeSidecarLaunch({ + args: ["--import", "tsx", fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_LAUNCH_ATTEMPT: attemptPath }, + resources: { + dataRoot: join(root, "data"), + ownerPid: null, + port: 0, + runtimeRoot: join(root, "runtime"), + }, + stamp: launchStamp, + }, { stabilityMs: 100, timeoutMs: 10_000 }); + + expect(result.attempts).toBe(2); + expect(result.description).toMatchObject({ ready: true, stamp: launchStamp }); + await expect(findSidecarProcesses(launchStamp)).resolves.toHaveLength(1); + } finally { + await stopSidecar(launchStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }, 15_000); + + it.each(["runtime", "headless"] as const)("converges a tools-pack %s launch on an existing packaged-source owner", async (mode) => { + const fixture = fileURLToPath(new URL("./fixtures/converging-launcher.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-cross-source-convergence-")); + const ownerAttemptPath = join(root, "owner-attempt.txt"); + const launcherAttemptPath = join(root, "launcher-attempt.txt"); + const requestedStamp = { + ...stamp, + app: "desktop", + mode, + namespace: `cross-source-${mode}-${process.pid}`, + source: "tools-pack", + }; + const ownerStamp = { ...requestedStamp, source: "packaged" }; + await writeFile(ownerAttemptPath, "1"); + await writeFile(launcherAttemptPath, "0"); + try { + await launchSidecar({ + args: ["--import", "tsx", fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_LAUNCH_ATTEMPT: ownerAttemptPath }, + resources: { dataRoot: join(root, "data"), ownerPid: null, port: 0, runtimeRoot: join(root, "runtime") }, + stamp: ownerStamp, + }); + const result = await convergeSidecarLaunch({ + args: ["--import", "tsx", fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_LAUNCH_ATTEMPT: launcherAttemptPath }, + resources: { dataRoot: join(root, "data"), ownerPid: null, port: 0, runtimeRoot: join(root, "runtime") }, + stamp: requestedStamp, + }, { ownerStamps: [requestedStamp, ownerStamp], stabilityMs: 100, timeoutMs: 10_000 }); + + expect(result.attempts).toBe(1); + expect(result.description).toMatchObject({ ready: true, stamp: ownerStamp }); + } finally { + await stopSidecars([{ stamp: requestedStamp }, { stamp: ownerStamp }]).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }, 15_000); + + it("retries a launcher generation retired by a competing lifecycle operation", async () => { + const fixture = fileURLToPath(new URL("./fixtures/converging-launcher.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-contended-launcher-")); + const attemptPath = join(root, "attempt.txt"); + const launchStamp = { ...stamp, namespace: `launcher-contention-${process.pid}` }; + await writeFile(attemptPath, "0"); + try { + const result = await convergeSidecarLaunch({ + args: ["--import", "tsx", fixture], + command: process.execPath, + env: { + ...process.env, + OD_TEST_FIRST_LAUNCH_EXIT: "75", + OD_TEST_LAUNCH_ATTEMPT: attemptPath, + }, + resources: { + dataRoot: join(root, "data"), + ownerPid: null, + port: 0, + runtimeRoot: join(root, "runtime"), + }, + stamp: launchStamp, + }, { stabilityMs: 100, timeoutMs: 10_000 }); + + expect(result.attempts).toBe(2); + expect(result.description).toMatchObject({ ready: true, stamp: launchStamp }); + await expect(findSidecarProcesses(launchStamp)).resolves.toHaveLength(1); + } finally { + await stopSidecar(launchStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }, 15_000); + + it("quick-fails a non-retriable launcher error", async () => { + const fixture = fileURLToPath(new URL("./fixtures/converging-launcher.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-rejected-launcher-")); + const attemptPath = join(root, "attempt.txt"); + const launchStamp = { ...stamp, namespace: `launcher-rejected-${process.pid}` }; + await writeFile(attemptPath, "0"); + try { + await expect(convergeSidecarLaunch({ + args: ["--import", "tsx", fixture], + command: process.execPath, + env: { + ...process.env, + OD_TEST_FIRST_LAUNCH_EXIT: "1", + OD_TEST_LAUNCH_ATTEMPT: attemptPath, + }, + resources: { + dataRoot: join(root, "data"), + ownerPid: null, + port: 0, + runtimeRoot: join(root, "runtime"), + }, + stamp: launchStamp, + }, { stabilityMs: 100, timeoutMs: 10_000 })).rejects.toThrow( + "sidecar launcher exited before convergence code=1 signal=null", + ); + await expect(readFile(attemptPath, "utf8")).resolves.toBe("1"); + } finally { + await stopSidecar(launchStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }, 15_000); + + it("restarts a sidecar on its known concrete port by default", async () => { + const fixture = fileURLToPath(new URL("./fixtures/managed-child.ts", import.meta.url)); + const restartStamp = { ...stamp, namespace: `restart-port-${process.pid}` }; + const port = (await allocatePort()).port; + const resources = { + dataRoot: "/tmp/open-design-restart", + ownerPid: null, + port, + runtimeRoot: "/tmp/open-design-restart-runtime", + }; + const first = await launchSidecar({ args: ["--import", "tsx", fixture], command: process.execPath, resources, stamp: restartStamp }); + + try { + await vi.waitFor(async () => { + expect(await getSidecarStatus(restartStamp)).toEqual({ pid: first.pid, port }); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + await expect(getSidecarStatus(restartStamp, { generationPid: first.pid + 1 })) + .rejects.toThrow(`sidecar endpoint belongs to generation ${first.pid}, expected ${first.pid + 1}`); + await expect(getSidecarStatus(restartStamp, { generationPid: first.pid })) + .resolves.toEqual({ pid: first.pid, port }); + const restarted = await restartSidecar({ + args: ["--import", "tsx", fixture], + command: process.execPath, + resources: { ...resources, port: 0 }, + stamp: restartStamp, + }); + + expect(restarted.pid).not.toBe(first.pid); + expect(restarted.reusedPort).toBe(true); + expect(restarted.stop.stoppedPids).toContain(first.pid); + await vi.waitFor(async () => { + expect(await getSidecarStatus(restartStamp)).toEqual({ pid: restarted.pid, port }); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + } finally { + await stopSidecar(restartStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + } + }, 30_000); + + it("serializes two concurrent restarts into one healthy generation", async () => { + const fixture = fileURLToPath(new URL("./fixtures/managed-child.ts", import.meta.url)); + const restartStamp = { ...stamp, namespace: `restart-concurrent-${process.pid}` }; + const resources = { + dataRoot: "/tmp/open-design-restart-concurrent", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-restart-concurrent-runtime", + }; + + try { + const results = await Promise.allSettled([ + restartSidecar({ args: ["--import", "tsx", fixture], command: process.execPath, resources, stamp: restartStamp }), + restartSidecar({ args: ["--import", "tsx", fixture], command: process.execPath, resources, stamp: restartStamp }), + ]); + const fulfilled = results + .filter((result): result is PromiseFulfilledResult>> => result.status === "fulfilled") + .map(({ value }) => value); + expect(fulfilled.length).toBeGreaterThanOrEqual(1); + const live = await findSidecarProcesses(restartStamp); + expect(live).toHaveLength(1); + const status = await getSidecarStatus<{ pid: number }>(restartStamp); + expect(status.pid).toBe(fulfilled.at(-1)?.pid); + } finally { + await stopSidecar(restartStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + } + }, 15_000); + + it("keeps an adjacent proxy and namespace healthy across a daemon restart (TD-06)", async () => { + const fixture = fileURLToPath(new URL("./fixtures/managed-child.ts", import.meta.url)); + const adjacentDaemonPort = (await allocatePort()).port; + const webPort = (await allocatePort({ reserved: new Set([adjacentDaemonPort]) })).port; + const daemonStamp = { ...stamp, namespace: `td06-a-${process.pid}` }; + const adjacentStamp = { ...stamp, namespace: `td06-b-${process.pid}` }; + const resources = { + dataRoot: "/tmp/open-design-td06-a", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-td06-a-runtime", + }; + const adjacentResources = { + dataRoot: "/tmp/open-design-td06-b", + ownerPid: null, + port: adjacentDaemonPort, + runtimeRoot: "/tmp/open-design-td06-b-runtime", + }; + const daemon = await launchSidecar({ + args: ["--import", "tsx", fixture], + command: process.execPath, + resources, + stamp: daemonStamp, + }); + const adjacent = await launchSidecar({ + args: ["--import", "tsx", fixture], + command: process.execPath, + resources: adjacentResources, + stamp: adjacentStamp, + }); + let daemonPort: number | null = null; + const web = createServer(async (request, response) => { + try { + if (daemonPort == null) throw new Error("daemon port is not resolved"); + const upstream = await fetch(`http://127.0.0.1:${daemonPort}${request.url ?? "/"}`); + response.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/json" }); + response.end(await upstream.text()); + } catch (error) { + response.writeHead(502, { "content-type": "text/plain" }); + response.end(error instanceof Error ? error.message : String(error)); + } + }); + + try { + let daemonStatus!: { pid: number; port: number }; + await vi.waitFor(async () => { + daemonStatus = await getSidecarStatus(daemonStamp); + expect(daemonStatus.pid).toBe(daemon.pid); + expect(daemonStatus.port).toBeGreaterThan(0); + await expect(getSidecarStatus(adjacentStamp)).resolves.toEqual({ pid: adjacent.pid, port: adjacentDaemonPort }); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + const resolvedDaemonPort = daemonStatus.port; + daemonPort = resolvedDaemonPort; + await new Promise((resolve, reject) => { + web.once("error", reject); + web.listen(webPort, "127.0.0.1", resolve); + }); + await expect(fetch(`http://127.0.0.1:${webPort}/api/projects`).then(({ status }) => status)).resolves.toBe(200); + await expect(fetch(`http://127.0.0.1:${adjacentDaemonPort}/api/projects`).then(({ status }) => status)).resolves.toBe(200); + + const restarted = await restartSidecar({ + args: ["--import", "tsx", fixture], + command: process.execPath, + resources: { ...resources, port: resolvedDaemonPort }, + stamp: daemonStamp, + }, { requireConcretePort: true }); + expect(restarted.reusedPort).toBe(false); + await vi.waitFor(async () => { + await expect(getSidecarStatus(daemonStamp)).resolves.toEqual({ pid: restarted.pid, port: resolvedDaemonPort }); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + + expect(web.address()).toEqual(expect.objectContaining({ port: webPort })); + await expect(fetch(`http://127.0.0.1:${webPort}/api/projects`).then(({ status }) => status)).resolves.toBe(200); + await expect(getSidecarStatus(adjacentStamp)).resolves.toEqual({ pid: adjacent.pid, port: adjacentDaemonPort }); + await expect(fetch(`http://127.0.0.1:${adjacentDaemonPort}/api/projects`).then(({ status }) => status)).resolves.toBe(200); + } finally { + await new Promise((resolve) => web.close(() => resolve())); + await Promise.all([ + stopSidecar(daemonStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined), + stopSidecar(adjacentStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined), + ]); + } + }, 30_000); + + it("keeps explicit and fresh restart port requests authoritative", async () => { + const fixture = fileURLToPath(new URL("./fixtures/managed-child.ts", import.meta.url)); + const restartStamp = { ...stamp, namespace: `restart-override-${process.pid}` }; + const firstPort = (await allocatePort()).port; + const explicitPort = (await allocatePort({ reserved: new Set([firstPort]) })).port; + const request = { + args: ["--import", "tsx", fixture], + command: process.execPath, + resources: { + dataRoot: "/tmp/open-design-restart-override", + ownerPid: null, + port: firstPort, + runtimeRoot: "/tmp/open-design-restart-override-runtime", + }, + stamp: restartStamp, + }; + await launchSidecar(request); + + try { + await vi.waitFor(async () => { + expect(await getSidecarStatus(restartStamp)).toEqual(expect.objectContaining({ port: firstPort })); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + const explicit = await restartSidecar({ ...request, resources: { ...request.resources, port: explicitPort } }); + expect(explicit.reusedPort).toBe(false); + await vi.waitFor(async () => { + expect(await getSidecarStatus(restartStamp)).toEqual(expect.objectContaining({ port: explicitPort })); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + + const fresh = await restartSidecar( + { ...request, resources: { ...request.resources, port: 0 } }, + { reuseKnownPort: false }, + ); + expect(fresh.reusedPort).toBe(false); + let freshStatus!: { pid: number; port: number }; + await vi.waitFor(async () => { + freshStatus = await getSidecarStatus(restartStamp); + expect(freshStatus.pid).toBe(fresh.pid); + expect(freshStatus.port).toBeGreaterThan(0); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + await expect(restartSidecar( + { ...request, resources: { ...request.resources, port: 0 } }, + { requireConcretePort: true }, + )).rejects.toThrow("without a concrete port"); + await expect(getSidecarStatus(restartStamp)).resolves.toEqual(freshStatus); + } finally { + await stopSidecar(restartStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + } + }, 30_000); + + it("does not leak a parent client capability into an independently stamped sidecar", async () => { + const fixture = fileURLToPath(new URL("./fixtures/stamped-child.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-sidecar-env-")); + const capturePath = join(root, "env.json"); + const childStamp = { ...stamp, namespace: `env-${process.pid}` }; + try { + await launchSidecar({ + args: [fixture], + command: process.execPath, + env: { + ...process.env, + OD_SIDECAR_CLIENT_ENDPOINT: "/tmp/open-design/ipc/parent.sock", + OD_SIDECAR_RESOURCES: JSON.stringify({ dataRoot: "/wrong" }), + OD_TEST_SIDECAR_ENV_CAPTURE: capturePath, + }, + resources: { dataRoot: "/tmp/open-design-child", ownerPid: null, port: 0, runtimeRoot: "/tmp/open-design-child-runtime" }, + stamp: childStamp, + }); + let captured: { argv: string[]; endpoint: string | null; resources: string | null } | null = null; + for (let attempt = 0; attempt < 50 && captured == null; attempt += 1) { + captured = await readFile(capturePath, "utf8").then((value) => JSON.parse(value)).catch(() => null); + if (captured == null) await new Promise((resolveWait) => setTimeout(resolveWait, 20)); + } + expect(captured?.endpoint).toBeNull(); + expect(captured?.resources).toBeNull(); + for (const flag of Object.values(SIDECAR_STAMP_FLAGS)) { + expect(captured?.argv.some((argument) => argument.startsWith(`${flag}=`))).toBe(false); + } + } finally { + await stopSidecar(childStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }); + + it("keeps distribution channels isolated and force-stops only an exact argv stamp", async () => { + const fixture = fileURLToPath(new URL("./fixtures/stamped-child.ts", import.meta.url)); + const stable = { ...stamp, channel: "stable", namespace: `isolation-${process.pid}` }; + const beta = { ...stable, channel: "beta" }; + await launchSidecar({ + args: [fixture], + command: process.execPath, + resources: { dataRoot: "/tmp/open-design-stable", ownerPid: null, port: 0, runtimeRoot: "/tmp/open-design-stable-runtime" }, + stamp: stable, + }); + await launchSidecar({ + args: [fixture], + command: process.execPath, + resources: { dataRoot: "/tmp/open-design-beta", ownerPid: null, port: 0, runtimeRoot: "/tmp/open-design-beta-runtime" }, + stamp: beta, + }); + + try { + await expect(findSidecarProcesses(stable)).resolves.toHaveLength(1); + await expect(findSidecarProcesses(beta)).resolves.toHaveLength(1); + const result = await stopSidecar(stable, { killGraceMs: 2_000, termGraceMs: 0 }); + expect(result.remainingPids).toEqual([]); + expect(result.gracefulAccepted).toBe(false); + await vi.waitFor(async () => { + await expect(findSidecarProcesses(stable)).resolves.toEqual([]); + }); + await expect(findSidecarProcesses(beta)).resolves.toHaveLength(1); + } finally { + await stopSidecar(stable, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await stopSidecar(beta, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + } + }, process.platform === "win32" ? 20_000 : 10_000); + + it("removes an unresponsive generation and its stale private endpoint", async () => { + if (process.platform === "win32") return; + const fixture = fileURLToPath(new URL("./fixtures/unresponsive-sidecar.ts", import.meta.url)); + const staleStamp = { ...stamp, app: "web", namespace: `stale-endpoint-${process.pid}` }; + const endpoint = resolvePrivateIpcPath(staleStamp); + await launchSidecar({ + args: [fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_STALE_ENDPOINT: endpoint }, + resources: { dataRoot: "/tmp/open-design-stale", ownerPid: null, port: 0, runtimeRoot: "/tmp/open-design-stale-runtime" }, + stamp: staleStamp, + }); + + try { + await vi.waitFor(async () => { + expect((await lstat(endpoint)).isSocket()).toBe(true); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + const result = await stopSidecar(staleStamp, { killGraceMs: 2_000, termGraceMs: 0 }); + expect(result.remainingPids).toEqual([]); + await expect(lstat(endpoint)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await stopSidecar(staleStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + } + }, 20_000); + + it("sends SIGTERM before waiting when graceful IPC is not accepted", async () => { + if (process.platform === "win32") return; + const fixture = fileURLToPath(new URL("./fixtures/term-responsive-sidecar.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-sidecar-term-fallback-")); + const termStamp = { ...stamp, app: "web", namespace: `term-fallback-${process.pid}` }; + const endpoint = resolvePrivateIpcPath(termStamp); + const marker = join(root, "term.marker"); + const spawned = await spawnSidecar({ + args: [fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_STALE_ENDPOINT: endpoint, OD_TEST_TERM_MARKER: marker }, + resources: { dataRoot: join(root, "data"), ownerPid: null, port: 0, runtimeRoot: join(root, "runtime") }, + stamp: termStamp, + }); + + try { + await vi.waitFor( + async () => expect((await lstat(endpoint)).isSocket()).toBe(true), + { timeout: FIXTURE_READY_TIMEOUT_MS }, + ); + const result = await spawned.stop({ killGraceMs: 2_000, termGraceMs: 2_000 }); + expect(result).toMatchObject({ forcedPids: [], gracefulAccepted: false, remainingPids: [] }); + await expect(readFile(marker, "utf8")).resolves.toBe("SIGTERM"); + } finally { + await spawned.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await stopSidecar(termStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }, 20_000); + + it("lets an owned generation handle retire its stale endpoint without discovering a replacement", async () => { + if (process.platform === "win32") return; + const fixture = fileURLToPath(new URL("./fixtures/unresponsive-sidecar.ts", import.meta.url)); + const ownedStamp = { ...stamp, app: "web", namespace: `owned-endpoint-${process.pid}` }; + const endpoint = resolvePrivateIpcPath(ownedStamp); + const spawned = await spawnSidecar({ + args: [fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_STALE_ENDPOINT: endpoint }, + resources: { + dataRoot: "/tmp/open-design-owned-endpoint", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-owned-endpoint-runtime", + }, + stamp: ownedStamp, + }); + + try { + await vi.waitFor( + async () => expect((await lstat(endpoint)).isSocket()).toBe(true), + { timeout: FIXTURE_READY_TIMEOUT_MS }, + ); + const result = await spawned.stop({ killGraceMs: 2_000, termGraceMs: 0 }); + expect(result).toMatchObject({ remainingPids: [], staleEndpointRemoved: true }); + await expect(lstat(endpoint)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await spawned.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await stopSidecar(ownedStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + } + }, 20_000); + + it("removes a refused stale endpoint after its stamped generation is already gone", async () => { + if (process.platform === "win32") return; + const fixture = fileURLToPath(new URL("./fixtures/unresponsive-sidecar.ts", import.meta.url)); + const staleStamp = { ...stamp, app: "web", namespace: `stale-only-${process.pid}` }; + const endpoint = resolvePrivateIpcPath(staleStamp); + const launched = await launchSidecar({ + args: [fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_STALE_ENDPOINT: endpoint }, + resources: { dataRoot: "/tmp/open-design-stale-only", ownerPid: null, port: 0, runtimeRoot: "/tmp/open-design-stale-only-runtime" }, + stamp: staleStamp, + }); + + try { + await vi.waitFor( + async () => expect((await lstat(endpoint)).isSocket()).toBe(true), + { timeout: FIXTURE_READY_TIMEOUT_MS }, + ); + const generationPids = collectProcessTreePids(await captureProcessSnapshot(), [launched.pid]); + for (const pid of generationPids) { + try { process.kill(pid, "SIGKILL"); } catch {} + } + await Promise.all(generationPids.map(async (pid) => await waitForProcessExit(pid, 2_000))); + await expect(findSidecarProcesses(staleStamp)).resolves.toEqual([]); + + const result = await stopSidecar(staleStamp, { killGraceMs: 2_000, termGraceMs: 0 }); + expect(result).toMatchObject({ alreadyStopped: true, staleEndpointRemoved: true }); + await expect(lstat(endpoint)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await stopSidecar(staleStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + } + }, 20_000); + + it("force-stops an unresponsive target after its declared owner dies", async () => { + const fixture = fileURLToPath(new URL("./fixtures/unresponsive-sidecar.ts", import.meta.url)); + const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); + if (owner.pid == null) throw new Error("owner fixture has no pid"); + const ownerStamp = { ...stamp, app: "web", namespace: `owner-death-${process.pid}` }; + const endpoint = resolvePrivateIpcPath(ownerStamp); + const spawned = await spawnSidecar({ + args: [fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_STALE_ENDPOINT: endpoint }, + resources: { dataRoot: "/tmp/open-design-owner-death", ownerPid: owner.pid, port: 0, runtimeRoot: "/tmp/open-design-owner-death-runtime" }, + stamp: ownerStamp, + }); + let generationPids: number[] = []; + try { + await vi.waitFor(async () => { + expect((await findSidecarProcesses(ownerStamp)).map(({ pid }) => pid)).toContain(spawned.process.pid); + generationPids = collectProcessTreePids(await captureProcessSnapshot(), [spawned.process.pid]); + expect(generationPids.length).toBeGreaterThan(1); + }, { interval: 100, timeout: FIXTURE_READY_TIMEOUT_MS }); + owner.kill("SIGKILL"); + await expect(waitForProcessExit(spawned.process.pid, 9_000)).resolves.toBe(true); + await Promise.all(generationPids.map(async (pid) => { + await expect(waitForProcessExit(pid, 2_000)).resolves.toBe(true); + })); + } finally { + try { owner.kill("SIGKILL"); } catch {} + for (const pid of generationPids) { + try { process.kill(pid, "SIGKILL"); } catch {} + } + await spawned.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await stopSidecar(ownerStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + } + }, process.platform === "win32" ? 30_000 : 15_000); + + it("stops an ownerless target after it requests a visible argv rewrite", async () => { + const fixture = fileURLToPath(new URL("./fixtures/renamed-child.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-owner-renamed-")); + const readyPath = join(root, "ready.json"); + const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); + if (owner.pid == null) throw new Error("owner fixture has no pid"); + const ownerStamp = { ...stamp, app: "web", namespace: `owner-renamed-${process.pid}` }; + const spawned = await spawnSidecar({ + args: [fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_SIDECAR_READY: readyPath }, + resources: { + dataRoot: join(root, "data"), + ownerPid: owner.pid, + port: 0, + runtimeRoot: join(root, "runtime"), + }, + stamp: ownerStamp, + }); + let runtimePid: number | null = null; + try { + await vi.waitFor(async () => { + const ready = await readFile(readyPath, "utf8").then((value) => JSON.parse(value)).catch(() => null); + runtimePid = Number(ready?.runtimePid); + expect(ready?.generationPid).toBe(spawned.process.pid); + expect(runtimePid).toBeGreaterThan(0); + const runtime = (await captureProcessSnapshot()).find(({ pid }) => pid === runtimePid); + // Win32_Process.CommandLine is the immutable launch command even after + // Node updates process.title; Unix process listings expose that update. + expect(runtime?.command).toContain(process.platform === "win32" ? "renamed-child.ts" : "next-server"); + }, { interval: 100, timeout: FIXTURE_READY_TIMEOUT_MS }); + const renamedRuntimePid = runtimePid; + if (renamedRuntimePid == null) throw new Error("renamed target did not report its pid"); + + owner.kill("SIGKILL"); + + await expect(waitForProcessExit(spawned.process.pid, 9_000)).resolves.toBe(true); + await expect(waitForProcessExit(renamedRuntimePid, 2_000)).resolves.toBe(true); + } finally { + try { owner.kill("SIGKILL"); } catch {} + if (runtimePid != null) { + try { process.kill(runtimePid, "SIGKILL"); } catch {} + } + await spawned.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await stopSidecar(ownerStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }, process.platform === "win32" ? 30_000 : 15_000); + + it.skipIf(process.platform === "win32")( + "bounds retirement of a frozen runtime with rewritten argv", + async () => { + const fixture = fileURLToPath(new URL("./fixtures/managed-child.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-sidecar-frozen-")); + const frozenStamp = { ...stamp, app: "web", namespace: `frozen-runtime-${process.pid}` }; + const spawned = await spawnSidecar({ + args: ["--import", "tsx", fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_RENAME_RUNTIME: "1" }, + resources: { + dataRoot: join(root, "data"), + ownerPid: null, + port: 0, + runtimeRoot: join(root, "runtime"), + }, + stamp: frozenStamp, + }); + let runtimePid: number | null = null; + try { + await vi.waitFor(async () => { + await expect(getSidecarStatus(frozenStamp)).resolves.toMatchObject({ pid: spawned.process.pid }); + runtimePid = (await captureProcessSnapshot()) + .find(({ ppid }) => ppid === spawned.process.pid)?.pid ?? null; + expect(runtimePid).toBeGreaterThan(0); + }, { interval: 100, timeout: FIXTURE_READY_TIMEOUT_MS }); + const frozenRuntimePid = runtimePid; + if (frozenRuntimePid == null) throw new Error("managed runtime was not discovered"); + process.kill(frozenRuntimePid, "SIGSTOP"); + + const startedAt = Date.now(); + const stopped = await stopSidecar(frozenStamp, { + gracefulRequestTimeoutMs: 300, + killGraceMs: 750, + termGraceMs: 300, + }); + + expect(Date.now() - startedAt).toBeLessThan(6_000); + expect(stopped.remainingPids).toEqual([]); + expect(stopped.stoppedPids).toEqual(expect.arrayContaining([spawned.process.pid, frozenRuntimePid])); + await expect(waitForProcessExit(spawned.process.pid, 1_000)).resolves.toBe(true); + await expect(waitForProcessExit(frozenRuntimePid, 1_000)).resolves.toBe(true); + } finally { + if (runtimePid != null) { + try { process.kill(runtimePid, "SIGCONT"); } catch {} + try { process.kill(runtimePid, "SIGKILL"); } catch {} + } + await spawned.stop({ killGraceMs: 750, termGraceMs: 0 }).catch(() => undefined); + await stopSidecar(frozenStamp, { killGraceMs: 750, termGraceMs: 0 }).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }, + 15_000, + ); + + it("quick-fails a managed target when its supervisor disappears", async () => { + const fixture = fileURLToPath(new URL("./fixtures/managed-child.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-supervisor-death-")); + const supervisedStamp = { ...stamp, namespace: `supervisor-death-${process.pid}` }; + const spawned = await spawnSidecar({ + args: ["--import", "tsx", fixture], + command: process.execPath, + resources: { + dataRoot: join(root, "data"), + ownerPid: null, + port: 0, + runtimeRoot: join(root, "runtime"), + }, + stamp: supervisedStamp, + }); + let runtimePid: number | null = null; + try { + await vi.waitFor(async () => { + const status = await getSidecarStatus<{ pid: number; port: number }>(supervisedStamp); + expect(status.pid).toBe(spawned.process.pid); + expect(status.port).toBeGreaterThan(0); + runtimePid = (await captureProcessSnapshot()) + .find(({ ppid }) => ppid === spawned.process.pid)?.pid ?? null; + expect(runtimePid).toBeGreaterThan(0); + }, { interval: 100, timeout: FIXTURE_READY_TIMEOUT_MS }); + const managedRuntimePid = runtimePid; + if (managedRuntimePid == null) throw new Error("managed target was not discovered"); + + process.kill(spawned.process.pid, "SIGKILL"); + + await expect(waitForProcessExit(spawned.process.pid, 2_000)).resolves.toBe(true); + await expect(waitForProcessExit(managedRuntimePid, 4_000)).resolves.toBe(true); + } finally { + if (runtimePid != null) { + try { process.kill(runtimePid, "SIGKILL"); } catch {} + } + try { process.kill(spawned.process.pid, "SIGKILL"); } catch {} + await stopSidecar(supervisedStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }, process.platform === "win32" ? 30_000 : 15_000); + + it("retires fenced descendants when an ownerless target exits on SIGTERM", async () => { + const fixture = fileURLToPath(new URL("./fixtures/orphaning-sidecar.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-owner-orphan-")); + const readyPath = join(root, "ready.json"); + const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); + if (owner.pid == null) throw new Error("owner fixture has no pid"); + const ownerStamp = { ...stamp, app: "web", namespace: `owner-orphan-${process.pid}` }; + const spawned = await spawnSidecar({ + args: [fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_ORPHAN_READY: readyPath }, + resources: { dataRoot: join(root, "data"), ownerPid: owner.pid, port: 0, runtimeRoot: join(root, "runtime") }, + stamp: ownerStamp, + }); + let descendantPid: number | null = null; + try { + await vi.waitFor(async () => { + const ready = await readFile(readyPath, "utf8").then((value) => JSON.parse(value)).catch(() => null); + descendantPid = Number(ready?.pid); + expect(descendantPid).toBeGreaterThan(0); + expect(collectProcessTreePids(await captureProcessSnapshot(), [spawned.process.pid])).toContain(descendantPid); + }, { interval: 100, timeout: process.platform === "win32" ? 15_000 : 5_000 }); + const fencedDescendantPid = descendantPid; + if (fencedDescendantPid == null) throw new Error("orphan fixture did not report its descendant"); + + owner.kill("SIGKILL"); + await expect(waitForProcessExit(spawned.process.pid, 9_000)).resolves.toBe(true); + await expect(waitForProcessExit(fencedDescendantPid, 2_000)).resolves.toBe(true); + } finally { + try { owner.kill("SIGKILL"); } catch {} + if (descendantPid != null) { + try { process.kill(descendantPid, "SIGKILL"); } catch {} + } + await spawned.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await stopSidecar(ownerStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }, process.platform === "win32" ? 30_000 : 15_000); + + it("force-stops fenced descendants when the supervisor root exits during retirement", async () => { + const fixture = fileURLToPath(new URL("./fixtures/stamped-child.ts", import.meta.url)); + const orphanStamp = { ...stamp, namespace: `orphan-retirement-${process.pid}` }; + const spawned = await spawnSidecar({ + args: [fixture], + command: process.execPath, + resources: { + dataRoot: "/tmp/open-design-orphan-retirement", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-orphan-retirement-runtime", + }, + stamp: orphanStamp, + }); + let generationPids: number[] = []; + try { + let snapshots = await captureProcessSnapshot(); + await vi.waitFor(async () => { + snapshots = await captureProcessSnapshot(); + generationPids = collectProcessTreePids(snapshots, [spawned.process.pid]); + expect(generationPids.length).toBeGreaterThan(1); + }, { interval: 100, timeout: FIXTURE_READY_TIMEOUT_MS }); + + const stopping = retireSidecarGeneration( + sidecarGenerationRef(orphanStamp, spawned.process.pid), + { killGraceMs: 2_000, termGraceMs: 500 }, + snapshots, + ); + process.kill(spawned.process.pid, "SIGKILL"); + await expect(waitForProcessExit(spawned.process.pid, 2_000)).resolves.toBe(true); + + const result = await stopping; + expect(result.remainingPids).toEqual([]); + await Promise.all(generationPids.map(async (pid) => { + await expect(waitForProcessExit(pid, 2_000)).resolves.toBe(true); + })); + } finally { + for (const pid of generationPids) { + try { process.kill(pid, "SIGKILL"); } catch {} + } + await spawned.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + await stopSidecar(orphanStamp, { killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined); + } + }, process.platform === "win32" ? 20_000 : 10_000); + + it("does not let an earlier stop terminate a replacement with the same stamp", async () => { + const fixture = fileURLToPath(new URL("./fixtures/stamped-child.ts", import.meta.url)); + const replacementStamp = { ...stamp, namespace: `replacement-${process.pid}` }; + const resources = { + dataRoot: "/tmp/open-design-replacement", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-replacement-runtime", + }; + const old = await spawnSidecar({ args: [fixture], command: process.execPath, resources, stamp: replacementStamp }); + const oldPid = old.process.pid; + let replacement: Awaited> | null = null; + try { + await vi.waitFor(async () => { + expect((await findSidecarProcesses(replacementStamp)).map(({ pid }) => pid)).toContain(oldPid); + }, { interval: 100, timeout: FIXTURE_READY_TIMEOUT_MS }); + const stopping = old.stop({ killGraceMs: 2_000, termGraceMs: 300 }); + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + replacement = await spawnSidecar({ args: [fixture], command: process.execPath, resources, stamp: replacementStamp }); + await vi.waitFor(async () => { + expect((await findSidecarProcesses(replacementStamp)).map(({ pid }) => pid)).toContain(replacement?.process.pid); + }, { interval: 100, timeout: FIXTURE_READY_TIMEOUT_MS }); + + const result = await stopping; + expect(result.matchedPids).toContain(oldPid); + expect(result.matchedPids).not.toContain(replacement.process.pid); + expect(result.forcedPids).not.toContain(replacement.process.pid); + expect((await findSidecarProcesses(replacementStamp)).map(({ pid }) => pid)).toContain(replacement.process.pid); + } finally { + await Promise.all([ + old.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined), + replacement?.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined), + ]); + } + }, 10_000); + + it("quick-fails stamp-only recovery when multiple generation roots are ambiguous", async () => { + const fixture = fileURLToPath(new URL("./fixtures/stamped-child.ts", import.meta.url)); + const ambiguousStamp = { ...stamp, namespace: `ambiguous-${process.pid}` }; + const resources = { + dataRoot: "/tmp/open-design-ambiguous", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-ambiguous-runtime", + }; + const first = await spawnSidecar({ args: [fixture], command: process.execPath, resources, stamp: ambiguousStamp }); + const second = await spawnSidecar({ args: [fixture], command: process.execPath, resources, stamp: ambiguousStamp }); + const firstPid = first.process.pid; + const secondPid = second.process.pid; + let budgetTimer: NodeJS.Timeout | null = null; + let stopping: Promise | null = null; + try { + await vi.waitFor(async () => { + expect((await findSidecarProcesses(ambiguousStamp)).map(({ pid }) => pid).sort()) + .toEqual([firstPid, secondPid].sort()); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + stopping = stopSidecar(ambiguousStamp, { termGraceMs: 0 }); + const boundedStop = Promise.race([ + stopping, + new Promise((_resolve, reject) => { + budgetTimer = setTimeout(() => reject(new Error("ambiguous generation observation exceeded 4900ms")), 4_900); + }), + ]); + await expect(boundedStop) + .rejects.toThrow("multiple stamped generation roots"); + expect((await findSidecarProcesses(ambiguousStamp)).map(({ pid }) => pid).sort()) + .toEqual([firstPid, secondPid].sort()); + } finally { + if (budgetTimer != null) clearTimeout(budgetTimer); + await Promise.all([ + first.stop({ killGraceMs: 2_000, termGraceMs: 0 }), + second.stop({ killGraceMs: 2_000, termGraceMs: 0 }), + ]); + await stopping?.catch(() => undefined); + } + }, 15_000); + + it("retires a multi-resource lifecycle set at one declared boundary", async () => { + const fixture = fileURLToPath(new URL("./fixtures/stamped-child.ts", import.meta.url)); + const namespace = `resource-set-${process.pid}`; + const resources = { + dataRoot: "/tmp/open-design-resource-set", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-resource-set-runtime", + }; + const daemonStamp = { ...stamp, namespace }; + const webStamp = { ...stamp, app: "web", namespace }; + const daemon = await spawnSidecar({ args: [fixture], command: process.execPath, resources, stamp: daemonStamp }); + const web = await spawnSidecar({ args: [fixture], command: process.execPath, resources, stamp: webStamp }); + try { + await vi.waitFor(async () => { + await expect(findSidecarProcesses(daemonStamp)).resolves.toHaveLength(1); + await expect(findSidecarProcesses(webStamp)).resolves.toHaveLength(1); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + + const result = await stopSidecars([ + { stamp: daemonStamp }, + { stamp: webStamp }, + ]); + + expect(result.remainingPids).toEqual([]); + expect(result.stoppedPids).toEqual(expect.arrayContaining([daemon.process.pid, web.process.pid])); + expect(result.results.map(({ stamp: stoppedStamp }) => stoppedStamp.app)).toEqual(["daemon", "web"]); + } finally { + await Promise.all([ + daemon.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined), + web.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined), + ]); + } + }, 15_000); + + it("re-observes a transient second root before mutating the surviving generation", async () => { + const fixture = fileURLToPath(new URL("./fixtures/stamped-child.ts", import.meta.url)); + const transientStamp = { ...stamp, namespace: `transient-root-${process.pid}` }; + const resources = { + dataRoot: "/tmp/open-design-transient-root", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-transient-root-runtime", + }; + const first = await spawnSidecar({ args: [fixture], command: process.execPath, resources, stamp: transientStamp }); + const second = await spawnSidecar({ args: [fixture], command: process.execPath, resources, stamp: transientStamp }); + let retireSecond: Promise = Promise.resolve(); + try { + await vi.waitFor(async () => { + await expect(findSidecarProcesses(transientStamp)).resolves.toHaveLength(2); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + setTimeout(() => { + retireSecond = second.stop({ killGraceMs: 2_000, termGraceMs: 0 }); + }, 100); + + const result = await stopSidecar(transientStamp, { killGraceMs: 2_000, termGraceMs: 0 }); + expect(result.matchedPids).toContain(first.process.pid); + expect(result.matchedPids).not.toContain(second.process.pid); + await retireSecond; + await expect(findSidecarProcesses(transientStamp)).resolves.toEqual([]); + } finally { + await Promise.all([ + first.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined), + second.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined), + ]); + } + }, 10_000); + + it("stops its spawned generation after the process hides its argv stamp", async () => { + const fixture = fileURLToPath(new URL("./fixtures/renamed-child.ts", import.meta.url)); + const root = await mkdtemp(join(tmpdir(), "open-design-sidecar-renamed-")); + const readyPath = join(root, "ready"); + const renamedStamp = { ...stamp, namespace: `renamed-${process.pid}` }; + const resources = { + dataRoot: join(root, "data"), + ownerPid: null, + port: 0, + runtimeRoot: join(root, "runtime"), + }; + const spawned = await spawnSidecar({ + args: [fixture], + command: process.execPath, + env: { ...process.env, OD_TEST_SIDECAR_READY: readyPath }, + resources, + stamp: renamedStamp, + }); + let replacement: Awaited> | null = null; + + try { + let ready!: { generationPid: number; runtimePid: number }; + await vi.waitFor(async () => { + ready = JSON.parse(await readFile(readyPath, "utf8")); + expect(ready.generationPid).toBe(spawned.process.pid); + expect(ready.runtimePid).not.toBe(spawned.process.pid); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + await expect(findSidecarProcesses(renamedStamp)).resolves.toEqual([ + expect.objectContaining({ pid: spawned.process.pid }), + ]); + replacement = await spawnSidecar({ + args: [fileURLToPath(new URL("./fixtures/stamped-child.ts", import.meta.url))], + command: process.execPath, + resources, + stamp: renamedStamp, + }); + await vi.waitFor(async () => { + expect((await findSidecarProcesses(renamedStamp)).map(({ pid }) => pid)).toContain(replacement?.process.pid); + }, { timeout: FIXTURE_READY_TIMEOUT_MS }); + + const result = await spawned.stop({ killGraceMs: 2_000, termGraceMs: 0 }); + + expect(result.alreadyStopped).toBe(false); + expect(result.matchedPids).toContain(spawned.process.pid); + expect(result.matchedPids).not.toContain(replacement.process.pid); + await expect(waitForProcessExit(spawned.process.pid, 2_000)).resolves.toBe(true); + expect((await findSidecarProcesses(renamedStamp)).map(({ pid }) => pid)).toContain(replacement.process.pid); + } finally { + await Promise.all([ + spawned.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined), + replacement?.stop({ killGraceMs: 2_000, termGraceMs: 0 }).catch(() => undefined), + ]); + await rm(root, { force: true, recursive: true }); + } + }, 20_000); +}); diff --git a/packages/sidecar/tests/fixtures/converging-launcher.ts b/packages/sidecar/tests/fixtures/converging-launcher.ts new file mode 100644 index 0000000000..58c16623e9 --- /dev/null +++ b/packages/sidecar/tests/fixtures/converging-launcher.ts @@ -0,0 +1,38 @@ +import { fileURLToPath } from "node:url"; +import { readFileSync, writeFileSync } from "node:fs"; + +import { + bootstrapSidecarProcess, + isCurrentSidecarLauncher, + readCurrentSidecarStamp, + SidecarFactory, +} from "../../src/index.js"; + +const attemptPath = process.env.OD_TEST_LAUNCH_ATTEMPT; +if (attemptPath == null) throw new Error("OD_TEST_LAUNCH_ATTEMPT is required"); +const stamp = readCurrentSidecarStamp(); +const resources = { + dataRoot: "/tmp/open-design-converging-launcher-data", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-converging-launcher-runtime", +}; + +if (isCurrentSidecarLauncher()) { + const attempt = Number(readFileSync(attemptPath, "utf8")) + 1; + writeFileSync(attemptPath, String(attempt)); + if (attempt === 1) process.exit(Number(process.env.OD_TEST_FIRST_LAUNCH_EXIT ?? 0)); + if (await bootstrapSidecarProcess(stamp, resources, { + args: ["--import", "tsx", fileURLToPath(import.meta.url)], + })) process.exit(0); +} + +const client = SidecarFactory.create({ + lifecycle: { + async start() { return {}; }, + status() { return { ready: true }; }, + async stop() {}, + }, +}); +await client.start(); +await client.waitUntilStopped(); diff --git a/packages/sidecar/tests/fixtures/handoff-entry.ts b/packages/sidecar/tests/fixtures/handoff-entry.ts new file mode 100644 index 0000000000..8faa56f963 --- /dev/null +++ b/packages/sidecar/tests/fixtures/handoff-entry.ts @@ -0,0 +1,10 @@ +import { fileURLToPath } from "node:url"; + +import { handoffCurrentSidecarGeneration } from "../../src/index.js"; + +await handoffCurrentSidecarGeneration({ + args: ["--import", "tsx", fileURLToPath(new URL("./handoff-successor.ts", import.meta.url))], + command: process.execPath, + env: process.env, +}); +process.exit(0); diff --git a/packages/sidecar/tests/fixtures/handoff-successor.ts b/packages/sidecar/tests/fixtures/handoff-successor.ts new file mode 100644 index 0000000000..6562dfdec3 --- /dev/null +++ b/packages/sidecar/tests/fixtures/handoff-successor.ts @@ -0,0 +1,12 @@ +import { SidecarFactory } from "../../src/index.js"; + +const client = SidecarFactory.create<{ generationPid: number }>({ + lifecycle: { + async start(resources) { return { generationPid: resources.pid }; }, + status(runtime) { return { generationPid: runtime.generationPid, phase: "successor" }; }, + async stop() {}, + }, +}); + +await client.start(); +await client.waitUntilStopped(); diff --git a/packages/sidecar/tests/fixtures/managed-child.ts b/packages/sidecar/tests/fixtures/managed-child.ts new file mode 100644 index 0000000000..12daf15741 --- /dev/null +++ b/packages/sidecar/tests/fixtures/managed-child.ts @@ -0,0 +1,39 @@ +import { SidecarFactory } from "../../src/index.js"; +import { createServer } from "node:http"; + +if (process.env.OD_TEST_RENAME_RUNTIME === "1") process.title = "next-server"; + +type ManagedRuntime = { + pid: number; + port: number; + server: ReturnType; +}; + +const client = SidecarFactory.create({ + lifecycle: { + async start(resources): Promise { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ pid: resources.pid })); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(resources.port, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address == null || typeof address === "string") throw new Error("managed sidecar did not bind TCP"); + return { pid: resources.pid, port: address.port, server }; + }, + status(runtime) { + return { pid: runtime.pid, port: runtime.port }; + }, + async stop(runtime) { + await new Promise((resolve, reject) => { + runtime.server.close((error) => error == null ? resolve() : reject(error)); + }); + }, + }, +}); + +await client.start(); +await client.waitUntilStopped(); diff --git a/packages/sidecar/tests/fixtures/nested-sidecar.ts b/packages/sidecar/tests/fixtures/nested-sidecar.ts new file mode 100644 index 0000000000..4b6084d13c --- /dev/null +++ b/packages/sidecar/tests/fixtures/nested-sidecar.ts @@ -0,0 +1,29 @@ +import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { spawnSidecar, type SidecarStamp } from "../../src/index.js"; + +const serializedStamp = process.env.OD_TEST_NESTED_SIDECAR_STAMP; +const readyPath = process.env.OD_TEST_NESTED_SIDECAR_READY; +if (serializedStamp == null || readyPath == null) { + throw new Error("nested sidecar fixture configuration is required"); +} + +const childStamp = JSON.parse(serializedStamp) as SidecarStamp; +const child = await spawnSidecar({ + args: [fileURLToPath(new URL("./stamped-child.ts", import.meta.url))], + command: process.execPath, + // Windows isolation is a sidecar invariant even when a caller attempts to + // opt out. On POSIX this remains a normal attached child. + detached: false, + resources: { + dataRoot: "/tmp/open-design-nested-child", + ownerPid: null, + port: 0, + runtimeRoot: "/tmp/open-design-nested-child-runtime", + }, + stamp: childStamp, +}); +await writeFile(readyPath, JSON.stringify({ pid: child.process.pid })); + +setInterval(() => undefined, 60_000); diff --git a/packages/sidecar/tests/fixtures/orphaning-sidecar.ts b/packages/sidecar/tests/fixtures/orphaning-sidecar.ts new file mode 100644 index 0000000000..9d66939c2d --- /dev/null +++ b/packages/sidecar/tests/fixtures/orphaning-sidecar.ts @@ -0,0 +1,18 @@ +import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; + +const readyPath = process.env.OD_TEST_ORPHAN_READY; +if (readyPath == null) throw new Error("OD_TEST_ORPHAN_READY is required"); + +const descendant = spawn(process.execPath, [ + "-e", + "process.on('SIGTERM',()=>{});setInterval(()=>{},60000)", +], { + stdio: "ignore", + windowsHide: true, +}); +if (descendant.pid == null) throw new Error("orphan fixture descendant has no pid"); +writeFileSync(readyPath, JSON.stringify({ pid: descendant.pid })); + +process.on("SIGTERM", () => process.exit(0)); +setInterval(() => undefined, 60_000); diff --git a/packages/sidecar/tests/fixtures/renamed-child.ts b/packages/sidecar/tests/fixtures/renamed-child.ts new file mode 100644 index 0000000000..2dbb310a62 --- /dev/null +++ b/packages/sidecar/tests/fixtures/renamed-child.ts @@ -0,0 +1,16 @@ +import { writeFileSync } from "node:fs"; + +process.title = "next-server"; + +const readyPath = process.env.OD_TEST_SIDECAR_READY; +const context = JSON.parse(process.env.OD_SIDECAR_SUPERVISED_CONTEXT ?? "null") as { generationPid?: unknown } | null; +if (readyPath != null) writeFileSync(readyPath, JSON.stringify({ + generationPid: Number(context?.generationPid), + runtimePid: process.pid, +})); + +process.on("SIGTERM", () => { + // Exercise the managed generation's force-stop fallback. +}); + +setInterval(() => undefined, 60_000); diff --git a/packages/sidecar/tests/fixtures/stamped-child.ts b/packages/sidecar/tests/fixtures/stamped-child.ts new file mode 100644 index 0000000000..9ebac19759 --- /dev/null +++ b/packages/sidecar/tests/fixtures/stamped-child.ts @@ -0,0 +1,16 @@ +import { writeFileSync } from "node:fs"; + +const capturePath = process.env.OD_TEST_SIDECAR_ENV_CAPTURE; +if (capturePath != null) { + writeFileSync(capturePath, JSON.stringify({ + argv: process.argv, + endpoint: process.env.OD_SIDECAR_CLIENT_ENDPOINT ?? null, + resources: process.env.OD_SIDECAR_RESOURCES ?? null, + })); +} + +process.on("SIGTERM", () => { + // The convergence test deliberately exercises the exact-stamp force fallback. +}); + +setInterval(() => undefined, 60_000); diff --git a/packages/sidecar/tests/fixtures/term-responsive-sidecar.ts b/packages/sidecar/tests/fixtures/term-responsive-sidecar.ts new file mode 100644 index 0000000000..40ffd62b4f --- /dev/null +++ b/packages/sidecar/tests/fixtures/term-responsive-sidecar.ts @@ -0,0 +1,19 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { createServer } from "node:net"; + +const endpoint = process.env.OD_TEST_STALE_ENDPOINT; +const marker = process.env.OD_TEST_TERM_MARKER; +if (endpoint == null || marker == null) throw new Error("term-responsive fixture configuration is required"); + +mkdirSync(dirname(endpoint), { recursive: true }); +const server = createServer(() => { + // Accept connections without speaking sidecar IPC so graceful stop is not accepted. +}); +server.listen(endpoint); + +process.on("SIGTERM", () => { + writeFileSync(marker, "SIGTERM"); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 500).unref(); +}); diff --git a/packages/sidecar/tests/fixtures/unresponsive-sidecar.ts b/packages/sidecar/tests/fixtures/unresponsive-sidecar.ts new file mode 100644 index 0000000000..a28e014b4a --- /dev/null +++ b/packages/sidecar/tests/fixtures/unresponsive-sidecar.ts @@ -0,0 +1,15 @@ +import { createServer } from "node:net"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +const endpoint = process.env.OD_TEST_STALE_ENDPOINT; +if (endpoint == null) throw new Error("OD_TEST_STALE_ENDPOINT is required"); +mkdirSync(dirname(endpoint), { recursive: true }); +const server = createServer(() => { + // Deliberately accept the connection without speaking the sidecar protocol. +}); +server.listen(endpoint); + +process.on("SIGTERM", () => { + // Exercise the force-stop and stale-endpoint recovery path. +}); diff --git a/packages/sidecar/tests/index.test.ts b/packages/sidecar/tests/index.test.ts index 9164a24497..e0bd8ef69b 100644 --- a/packages/sidecar/tests/index.test.ts +++ b/packages/sidecar/tests/index.test.ts @@ -1,14 +1,12 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { lstat, mkdtemp, rm } from "node:fs/promises"; import { describe, expect, it, vi } from "vitest"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; +import { bootstrapSidecarRuntime, createSidecarLaunchEnv } from "../src/bootstrap.js"; +import { createJsonIpcServer, requestJsonIpc } from "../src/json-ipc.js"; +import { resolveAppIpcPath } from "../src/paths.js"; import { - bootstrapSidecarRuntime, - createJsonIpcServer, - createSidecarLaunchEnv, - requestJsonIpc, - resolveAppIpcPath, resolveAppRuntimePath, resolveLogFilePath, resolveNamespace, @@ -16,11 +14,10 @@ import { resolveRuntimeNamespaceRoot, resolveSidecarBase, resolveSourceRuntimeRoot, - type SidecarContractDescriptor, - type SidecarStampShape, } from "../src/index.js"; +import type { RuntimeLayoutStampShape, SidecarContractDescriptor } from "../src/types.js"; -type FakeStamp = SidecarStampShape & { +type FakeStamp = RuntimeLayoutStampShape & { app: "api" | "ui"; mode: "dev" | "prod"; source: "tool" | "pack"; @@ -141,6 +138,45 @@ describe("generic sidecar path boundary", () => { }); describe("generic sidecar JSON IPC", () => { + it("does not unlink a replacement socket when an old server finishes closing", async () => { + if (process.platform === "win32") return; + const root = await mkdtemp(join(tmpdir(), "open-design-sidecar-owned-ipc-")); + const socketPath = testIpcPath(root); + let releaseOld!: () => void; + let markEntered!: () => void; + const entered = new Promise((resolve) => { markEntered = resolve; }); + const holdOld = new Promise((resolve) => { releaseOld = resolve; }); + const old = await createJsonIpcServer({ + socketPath, + handler: async () => { + markEntered(); + await holdOld; + return { generation: "old" }; + }, + }); + const oldRequest = requestJsonIpc(socketPath, { type: "status" }); + await entered; + const closing = old.close(); + await vi.waitFor(async () => { + await expect(lstat(socketPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + const replacement = await createJsonIpcServer({ + socketPath, + handler: async () => ({ generation: "replacement" }), + }); + + try { + releaseOld(); + await oldRequest; + await closing; + await expect(requestJsonIpc(socketPath, { type: "status" })).resolves.toEqual({ generation: "replacement" }); + } finally { + releaseOld(); + await replacement.close(); + await rm(root, { force: true, recursive: true }); + } + }); + it("traces low-level IPC events without changing request semantics", async () => { const root = await mkdtemp(join(tmpdir(), "open-design-sidecar-ipc-")); const socketPath = testIpcPath(root); @@ -242,7 +278,6 @@ describe("generic sidecar bootstrap", () => { ).toEqual({ app: "api", base: resolve("/runtime/base"), - ipc: stamp.ipc, mode: "dev", namespace: "alpha", source: "tool", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c213f505ab..beff027af0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -788,6 +788,10 @@ importers: version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@29.1.1)(vite@7.3.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) packages/sidecar: + dependencies: + '@open-design/platform': + specifier: workspace:* + version: link:../platform devDependencies: '@types/node': specifier: 24.12.2 diff --git a/tools/dev/src/config.ts b/tools/dev/src/config.ts index 81851621e1..2cb30cf91e 100644 --- a/tools/dev/src/config.ts +++ b/tools/dev/src/config.ts @@ -9,7 +9,6 @@ import { SIDECAR_SOURCES, } from "@open-design/sidecar-proto"; import { - resolveAppIpcPath, resolveAppRuntimePath, resolveLogFilePath, resolveNamespace, @@ -40,7 +39,6 @@ export type ToolDevOptions = { export type ToolDevAppConfig = { app: ToolDevAppName; - ipcPath: string; latestLogPath: string; logDir: string; }; @@ -89,11 +87,6 @@ function resolveAppConfig(options: { }): ToolDevAppConfig { return { app: options.app, - ipcPath: resolveAppIpcPath({ - app: options.app, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: options.namespace, - }), latestLogPath: resolveLogFilePath({ runtimeRoot: options.namespaceRoot, app: options.app, contract: OPEN_DESIGN_SIDECAR_CONTRACT }), logDir: path.dirname(resolveLogFilePath({ runtimeRoot: options.namespaceRoot, app: options.app, contract: OPEN_DESIGN_SIDECAR_CONTRACT })), }; diff --git a/tools/dev/src/data-root.ts b/tools/dev/src/data-root.ts new file mode 100644 index 0000000000..e4527c0a69 --- /dev/null +++ b/tools/dev/src/data-root.ts @@ -0,0 +1,16 @@ +import { homedir } from "node:os"; +import path from "node:path"; + +export function resolveToolsDevDataRoot( + workspaceRoot: string, + env: NodeJS.ProcessEnv = process.env, +): string | null { + const configured = env.OD_DATA_DIR?.trim(); + if (configured == null || configured.length === 0) return null; + const home = homedir(); + const expanded = configured + .replace(/^~(?=$|[\\/])/, home) + .replace(/^\$HOME(?=$|[\\/])/, home) + .replace(/^\$\{HOME\}(?=$|[\\/])/, home); + return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(workspaceRoot, expanded); +} diff --git a/tools/dev/src/index.ts b/tools/dev/src/index.ts index f34488f699..78a16d8a96 100644 --- a/tools/dev/src/index.ts +++ b/tools/dev/src/index.ts @@ -6,7 +6,6 @@ import { cac } from "cac"; import { APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_ENV, SIDECAR_MESSAGES, SIDECAR_SOURCES, @@ -19,18 +18,20 @@ import { type DesktopUpdateResult, type WebStatusSnapshot, } from "@open-design/sidecar-proto"; -import { createSidecarLaunchEnv, requestJsonIpc } from "@open-design/sidecar"; import { - collectProcessTreePids, + findSidecarProcesses, + invokeSidecar, + launchSidecar, + restartSidecar, + stopSidecar, + type SidecarRestartOptions, + type SidecarRestartResult, + type SidecarStamp as ConvergedSidecarStamp, +} from "@open-design/sidecar"; +import { createPackageManagerInvocation, - createProcessStampArgs, isProcessAlive, - listProcessSnapshots, - matchesStampedProcess, readLogTail, - spawnBackgroundProcess, - stopProcesses, - type StopProcessesResult, } from "@open-design/platform"; import { @@ -49,6 +50,7 @@ import { type ToolDevConfig, type ToolDevOptions, } from "./config.js"; +import { resolveToolsDevDataRoot } from "./data-root.js"; import { appendStartupLogDiagnostics, createUnsupportedNodeRuntimeError, @@ -342,52 +344,20 @@ async function runLoggedCommand(request: { }); } -function createAppStamp(config: ToolDevConfig, appName: ToolDevAppName) { - const currentAppConfig = appConfig(config, appName); - const stamp = { +function createConvergedAppStamp(config: ToolDevConfig, appName: ToolDevAppName): ConvergedSidecarStamp { + return { app: appName, - ipc: currentAppConfig.ipcPath, - mode: "dev" as const, + channel: "local", + mode: "dev", namespace: config.namespace, source: SIDECAR_SOURCES.TOOLS_DEV, }; - - return { - args: createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT), - env: createSidecarLaunchEnv({ - base: config.toolsDevRoot, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - stamp, - }), - stamp, - }; } async function findAppProcessTree(config: ToolDevConfig, appName: ToolDevAppName) { - const processes = await listProcessSnapshots(); - const rootPids = processes - .filter((processInfo) => - matchesStampedProcess(processInfo, { - app: appName, - mode: "dev", - namespace: config.namespace, - source: SIDECAR_SOURCES.TOOLS_DEV, - }, OPEN_DESIGN_SIDECAR_CONTRACT), - ) - .map((processInfo) => processInfo.pid); - const pids = collectProcessTreePids(processes, rootPids); - - return { pids, rootPids }; -} - -async function waitForAppProcessExit(config: ToolDevConfig, appName: ToolDevAppName, timeoutMs = 5000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const current = await findAppProcessTree(config, appName); - if (current.pids.length === 0) return []; - await new Promise((resolveWait) => setTimeout(resolveWait, 120)); - } - return (await findAppProcessTree(config, appName)).pids; + const roots = await findSidecarProcesses(createConvergedAppStamp(config, appName)); + const rootPids = roots.map(({ pid }) => pid); + return { pids: rootPids, rootPids }; } async function assertNoStaleActiveProcess(config: ToolDevConfig, appName: ToolDevAppName): Promise { @@ -402,29 +372,37 @@ async function spawnSidecarRuntime(request: { config: ToolDevConfig; env: NodeJS.ProcessEnv; logHandle: FileHandle; -}): Promise<{ pid: number }> { - const { args: stampArgs, env } = createAppStamp(request.config, request.appName); + restart?: boolean; + restartOptions?: SidecarRestartOptions; +}): Promise<{ pid: number } | SidecarRestartResult> { const sidecarConfig = request.config.apps[request.appName]; - const spawned = await spawnBackgroundProcess({ - args: [request.config.tsxCliPath, sidecarConfig.sidecarEntryPath, ...stampArgs], + const launchRequest = { + args: [request.config.tsxCliPath, sidecarConfig.sidecarEntryPath], command: process.execPath, cwd: request.config.workspaceRoot, detached: true, - env: { - ...process.env, - ...env, - ...request.env, - }, + env: { ...process.env, ...request.env }, logFd: request.logHandle.fd, - }); - return { pid: spawned.pid }; + resources: { + dataRoot: resolveToolsDevDataRoot(request.config.workspaceRoot), + ownerPid: request.env[TOOLS_DEV_PARENT_PID_ENV] == null ? null : Number(request.env[TOOLS_DEV_PARENT_PID_ENV]), + port: Number(request.env[ + request.appName === APP_KEYS.DAEMON ? SIDECAR_ENV.DAEMON_PORT : SIDECAR_ENV.WEB_PORT + ] ?? 0), + runtimeRoot: request.config.toolsDevRoot, + }, + stamp: createConvergedAppStamp(request.config, request.appName), + }; + return request.restart === true + ? await restartSidecar(launchRequest, request.restartOptions) + : await launchSidecar(launchRequest); } async function spawnDaemonRuntime( config: ToolDevConfig, options: CliOptions, - spawnOptions: { requireDesktopAuth?: boolean } = {}, -): Promise<{ pid: number }> { + spawnOptions: { requireDesktopAuth?: boolean; restart?: boolean; restartOptions?: SidecarRestartOptions } = {}, +): Promise<{ pid: number } | SidecarRestartResult> { const daemonPort = parsePortOption(options.daemonPort, "--daemon-port"); const webPort = parsePortOption(options.webPort, "--web-port"); const logHandle = await openAppLog(config, APP_KEYS.DAEMON); @@ -453,6 +431,8 @@ async function spawnDaemonRuntime( ...(spawnOptions.requireDesktopAuth ? { OD_REQUIRE_DESKTOP_AUTH: "1" } : {}), }, logHandle, + ...(spawnOptions.restart === undefined ? {} : { restart: spawnOptions.restart }), + ...(spawnOptions.restartOptions === undefined ? {} : { restartOptions: spawnOptions.restartOptions }), }); } finally { await logHandle.close(); @@ -610,7 +590,6 @@ async function writeWebDevTsconfig(config: ToolDevConfig): Promise { } async function spawnDesktopRuntime(config: ToolDevConfig, options: CliOptions): Promise<{ pid: number }> { - const { args: stampArgs, env } = createAppStamp(config, APP_KEYS.DESKTOP); const logHandle = await openAppLog(config, APP_KEYS.DESKTOP); try { @@ -618,7 +597,6 @@ async function spawnDesktopRuntime(config: ToolDevConfig, options: CliOptions): await logHandle.write(`[tools-dev] launching desktop at ${new Date().toISOString()}\n`); const spawnEnv: NodeJS.ProcessEnv = { ...process.env, - ...env, ...(options.parentPid == null ? {} : { [TOOLS_DEV_PARENT_PID_ENV]: String(options.parentPid) }), }; // ELECTRON_RUN_AS_NODE=1 makes Electron boot as plain Node and skip @@ -643,13 +621,20 @@ async function spawnDesktopRuntime(config: ToolDevConfig, options: CliOptions): delete spawnEnv[key]; } } - const spawned = await spawnBackgroundProcess({ - args: [config.apps.desktop.mainEntryPath, ...stampArgs], + const spawned = await launchSidecar({ + args: [config.apps.desktop.mainEntryPath], command: config.apps.desktop.electronBinaryPath, cwd: config.workspaceRoot, detached: true, env: spawnEnv, logFd: logHandle.fd, + resources: { + dataRoot: resolveToolsDevDataRoot(config.workspaceRoot), + ownerPid: options.parentPid ?? null, + port: 0, + runtimeRoot: config.toolsDevRoot, + }, + stamp: createConvergedAppStamp(config, APP_KEYS.DESKTOP), }); return { pid: spawned.pid }; } finally { @@ -726,6 +711,74 @@ async function startDaemon( } } +async function restartDaemon(config: ToolDevConfig, options: CliOptions) { + const runningDaemon = await inspectDaemonRuntime(runtimeLookup(config)); + const runningWeb = await inspectWebRuntime(runtimeLookup(config)); + const requestedDaemonPort = parsePortOption(options.daemonPort, "--daemon-port"); + const requestedWebPort = parsePortOption(options.webPort, "--web-port"); + const runningDaemonPort = runningDaemon?.url == null ? null : Number(urlPort(runningDaemon.url)); + const runningWebPort = runningWeb?.url == null ? null : Number(urlPort(runningWeb.url)); + + if (runningWebPort != null && requestedWebPort != null && requestedWebPort !== runningWebPort) { + throw new Error( + `${APP_KEYS.WEB} is already running in namespace ${config.namespace} at ${runningWeb?.url}; ` + + `restart web to change its port`, + ); + } + if ( + runningWebPort != null && + requestedDaemonPort != null && + runningDaemonPort != null && + requestedDaemonPort !== runningDaemonPort + ) { + throw new Error( + `${APP_KEYS.WEB} still depends on daemon port ${runningDaemonPort}; restart web to change the daemon port`, + ); + } + + const restartOptions: CliOptions = { + ...options, + ...(runningWebPort == null ? {} : { webPort: runningWebPort }), + ...(requestedDaemonPort == null && runningDaemonPort != null + ? { daemonPort: runningDaemonPort } + : {}), + }; + const desktopAlreadyRunning = await inspectDesktopRuntime(runtimeLookup(config)); + const restarted = await spawnDaemonRuntime(config, restartOptions, { + requireDesktopAuth: desktopAlreadyRunning != null, + restart: true, + restartOptions: { requireConcretePort: runningWebPort != null }, + }); + if (!("stop" in restarted)) throw new Error("sidecar restart did not return its stopped generation"); + + try { + const status = await waitForDaemonRuntime( + runtimeLookup(config), + undefined, + () => isProcessAlive(restarted.pid), + ); + return { + start: { + [APP_KEYS.DAEMON]: { + app: APP_KEYS.DAEMON, + created: true, + logPath: config.apps.daemon.latestLogPath, + pid: restarted.pid, + status, + }, + }, + stop: { + [APP_KEYS.DAEMON]: formatStopAppResult(APP_KEYS.DAEMON, restarted.stop), + }, + }; + } catch (error) { + const logPath = config.apps.daemon.latestLogPath; + const lines = await readLogTail(logPath, 80).catch(() => []); + await stopApp(config, APP_KEYS.DAEMON).catch(() => undefined); + throw appendStartupLogDiagnostics(error, APP_KEYS.DAEMON, createStartupLogDiagnostics(logPath, lines)); + } +} + async function startWeb(config: ToolDevConfig, options: CliOptions) { const webPort = parsePortOption(options.webPort, "--web-port"); const existing = await inspectWebRuntime(runtimeLookup(config)); @@ -852,48 +905,17 @@ async function startApp( } } -async function requestAppShutdown(config: ToolDevConfig, appName: ToolDevAppName): Promise { - try { - await requestJsonIpc(appConfig(config, appName).ipcPath, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 }); - return true; - } catch { - return false; - } -} - -function stoppedByGracefulResult(matchedPids: number[]): StopProcessesResult { +function formatStopAppResult(appName: ToolDevAppName, stop: Awaited>) { return { - alreadyStopped: matchedPids.length === 0, - forcedPids: [], - matchedPids, - remainingPids: [], - stoppedPids: matchedPids, + app: appName, + status: stop.alreadyStopped && !stop.gracefulAccepted ? "not-running" : stop.remainingPids.length === 0 ? "stopped" : "partial", + stop, + via: stop.gracefulAccepted ? (stop.forcedPids.length === 0 ? "ipc" : "ipc+fallback") : "fallback", }; } async function stopApp(config: ToolDevConfig, appName: ToolDevAppName) { - const before = await findAppProcessTree(config, appName); - const gracefulRequested = await requestAppShutdown(config, appName); - const remainingAfterGraceful = gracefulRequested - ? await waitForAppProcessExit(config, appName) - : before.pids; - - if (remainingAfterGraceful.length === 0) { - return { - app: appName, - status: before.pids.length === 0 ? "not-running" : "stopped", - stop: stoppedByGracefulResult(before.pids), - via: gracefulRequested ? "ipc" : "process-scan", - }; - } - - const stop = await stopProcesses(remainingAfterGraceful); - return { - app: appName, - status: stop.remainingPids.length === 0 ? "stopped" : "partial", - stop, - via: gracefulRequested ? "ipc+fallback" : "fallback", - }; + return formatStopAppResult(appName, await stopSidecar(createConvergedAppStamp(config, appName))); } async function inspectAppStatus(config: ToolDevConfig, appName: ToolDevAppName) { @@ -944,6 +966,7 @@ async function status(config: ToolDevConfig, appName: string | undefined) { } async function restartTargets(config: ToolDevConfig, appName: string | undefined, options: CliOptions) { + if (appName === APP_KEYS.DAEMON) return await restartDaemon(config, options); const stopTargets = resolveStopApps(appName); const startTargets = resolveStartApps(appName); await resolveSharedPortsFromRunningState(startTargets, options, { @@ -1038,20 +1061,12 @@ async function inspectDesktop(config: ToolDevConfig, target: string | undefined, return (await inspectDesktopRuntime(runtimeLookup(config), 1000)) ?? ({ state: "idle" } satisfies DesktopStatusSnapshot); case "eval": if (options.expr == null) throw new Error("--expr is required for desktop eval"); - return await requestJsonIpc( - config.apps.desktop.ipcPath, - { input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL }, - { timeoutMs }, - ); + return await invokeSidecar(createConvergedAppStamp(config, APP_KEYS.DESKTOP), SIDECAR_MESSAGES.EVAL, { expression: options.expr }, { timeoutMs }); case "screenshot": if (options.path == null) throw new Error("--path is required for desktop screenshot"); - return await requestJsonIpc( - config.apps.desktop.ipcPath, - { input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT }, - { timeoutMs }, - ); + return await invokeSidecar(createConvergedAppStamp(config, APP_KEYS.DESKTOP), SIDECAR_MESSAGES.SCREENSHOT, { path: options.path }, { timeoutMs }); case "console": - return await requestJsonIpc(config.apps.desktop.ipcPath, { type: SIDECAR_MESSAGES.CONSOLE }, { timeoutMs }); + return await invokeSidecar(createConvergedAppStamp(config, APP_KEYS.DESKTOP), SIDECAR_MESSAGES.CONSOLE, undefined, { timeoutMs }); case "update": if ( options.updateAction != null && @@ -1059,18 +1074,10 @@ async function inspectDesktop(config: ToolDevConfig, target: string | undefined, ) { throw new Error("--update-action must be status, check, download, or install"); } - return await requestJsonIpc( - config.apps.desktop.ipcPath, - { input: { action: options.updateAction ?? "status" }, type: SIDECAR_MESSAGES.UPDATE }, - { timeoutMs }, - ); + return await invokeSidecar(createConvergedAppStamp(config, APP_KEYS.DESKTOP), SIDECAR_MESSAGES.UPDATE, { action: options.updateAction ?? "status" }, { timeoutMs }); case "click": if (options.selector == null) throw new Error("--selector is required for desktop click"); - return await requestJsonIpc( - config.apps.desktop.ipcPath, - { input: { selector: options.selector }, type: SIDECAR_MESSAGES.CLICK }, - { timeoutMs }, - ); + return await invokeSidecar(createConvergedAppStamp(config, APP_KEYS.DESKTOP), SIDECAR_MESSAGES.CLICK, { selector: options.selector }, { timeoutMs }); default: throw new Error(`unsupported desktop inspect target: ${operation}`); } diff --git a/tools/dev/src/sidecar-client.ts b/tools/dev/src/sidecar-client.ts index db28819114..03ea1b9d92 100644 --- a/tools/dev/src/sidecar-client.ts +++ b/tools/dev/src/sidecar-client.ts @@ -1,18 +1,26 @@ import { APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, - SIDECAR_MESSAGES, type DaemonStatusSnapshot, type DesktopStatusSnapshot, type WebStatusSnapshot, } from "@open-design/sidecar-proto"; -import { requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; +import { getSidecarStatus, type SidecarStamp } from "@open-design/sidecar"; export type AppRuntimeLookup = { base: string; namespace: string; }; +function convergedStamp(runtime: AppRuntimeLookup, app: (typeof APP_KEYS)[keyof typeof APP_KEYS]): SidecarStamp { + return { + app, + channel: "local", + mode: "dev", + namespace: runtime.namespace, + source: "tools-dev", + }; +} + export const DAEMON_STARTUP_TIMEOUT_MS = 120_000; const WEB_STARTUP_TIMEOUT_MS = 35_000; const DESKTOP_STARTUP_TIMEOUT_MS = 15_000; @@ -25,21 +33,9 @@ function assertSpawnedProcessAlive(appName: string, isProcessAlive: ProcessAlive } } -export function resolveDaemonIpcPath(runtime: AppRuntimeLookup): string { - return resolveAppIpcPath({ app: APP_KEYS.DAEMON, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace }); -} - -export function resolveWebIpcPath(runtime: AppRuntimeLookup): string { - return resolveAppIpcPath({ app: APP_KEYS.WEB, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace }); -} - -export function resolveDesktopIpcPath(runtime: AppRuntimeLookup): string { - return resolveAppIpcPath({ app: APP_KEYS.DESKTOP, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace }); -} - export async function inspectDaemonRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise { try { - return await requestJsonIpc(resolveDaemonIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs }); + return await getSidecarStatus(convergedStamp(runtime, APP_KEYS.DAEMON), { timeoutMs }); } catch { return null; } @@ -64,7 +60,7 @@ export async function waitForDaemonRuntime( export async function inspectWebRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise { try { - return await requestJsonIpc(resolveWebIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs }); + return await getSidecarStatus(convergedStamp(runtime, APP_KEYS.WEB), { timeoutMs }); } catch { return null; } @@ -89,7 +85,7 @@ export async function waitForWebRuntime( export async function inspectDesktopRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise { try { - return await requestJsonIpc(resolveDesktopIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs }); + return await getSidecarStatus(convergedStamp(runtime, APP_KEYS.DESKTOP), { timeoutMs }); } catch { return null; } diff --git a/tools/dev/tests/data-root.test.ts b/tools/dev/tests/data-root.test.ts new file mode 100644 index 0000000000..48907cf47f --- /dev/null +++ b/tools/dev/tests/data-root.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import path from "node:path"; + +import { resolveToolsDevDataRoot } from "../src/data-root.js"; + +test("preserves an absent daemon data root for sandbox startup validation", () => { + assert.equal(resolveToolsDevDataRoot("/workspace", {}), null); + assert.equal(resolveToolsDevDataRoot("/workspace", { OD_SANDBOX_MODE: "1" }), null); +}); + +test("forwards an explicit isolated daemon data root", () => { + assert.equal( + resolveToolsDevDataRoot("/workspace", { OD_DATA_DIR: "isolated", OD_SANDBOX_MODE: "1" }), + path.resolve("/workspace", "isolated"), + ); +}); diff --git a/tools/pack/AGENTS.md b/tools/pack/AGENTS.md index 99fcadc7d2..46e0fb1c11 100644 --- a/tools/pack/AGENTS.md +++ b/tools/pack/AGENTS.md @@ -27,7 +27,7 @@ Read `tools/pack/CACHE.md` before changing any build-cache node key, adding a ca - Keep cross-platform source responsibilities in named directories such as `cache/`, `config/`, `launcher/`, `resources/`, `updates/`, and `versioning/`; keep platform-owned behavior below `mac/` or `win/`. Mirror those responsibilities in `tests/` when a test set has the same ownership. A new root-level `src/*.ts` file is intentionally treated as unclassified by CI and pays the conservative Windows payload fallback until it is placed in an owned source unit. - Tests import source modules through the test-only `@/*` alias. Tests that intentionally inspect source text use the same alias with Vitest's `?raw` suffix; do not reintroduce directory-depth-dependent `../src/` imports or file URLs. -- Do not hand-build `--od-stamp-*` args; use `createProcessStampArgs` with `OPEN_DESIGN_SIDECAR_CONTRACT`. +- Do not hand-build stamp argv, IPC paths, or process matching; use `@open-design/sidecar` launch/discovery/invoke/stop atomics. - Do not use port numbers in data/log/runtime/cache path decisions. Namespace decides paths; ports are only transient transports. - Public release artifacts must use channel-specific app identity: stable uses `Open Design`, beta uses `Open Design Beta`, prerelease uses `Open Design Prerelease`, and preview uses `Open Design Preview`. Local tools-pack installs may still use namespace-scoped install paths only as a developer multi-instance validation convention. - Do not let namespace-named `.app` installs change data/log/runtime/cache path conventions. @@ -49,7 +49,7 @@ Read this section before changing packaged auto-update behavior. The updater cro - `apps/web/src/components/UpdaterPopup.tsx` remains the ready-update surface in the left rail. `apps/web/src/components/UpdateDialog.tsx` owns the explicit macOS app-menu check flow. All visible copy and native menu labels must go through `apps/web/src/i18n`. - `packages/launcher-proto` owns launcher pointer, attempt, and desktop-handoff journal shapes plus payload selection. `runtime.json` together with `attempt.json` is the only payload-version state machine. - `apps/packaged/src/index.ts` delegates to the selected payload desktop before initializing the outer Electron runtime, then passes packaged `appVersion` and namespace-scoped `updateRoot` into desktop main only when the outer itself must run. -- `apps/daemon/src/sidecar/payload-desktop-handoff.ts` is the isolated compatibility bridge for historical outers that cannot delegate. It rearms the selected payload with the real previous pointer, launches that payload's desktop after the old outer exits, and persists a small desktop-binding journal for later shortcut cold starts. The journal is not a second version selector. +- `apps/daemon/src/sidecar/payload-desktop-handoff.ts` owns the historical-outer bridge. It uses the daemon's `SidecarFactory` client to confirm and shut down the exact outer desktop, then `spawnSidecar` to launch the payload generation while preserving the true previous launcher pointer across the `prepared` → `armed` → `confirmed` journal lifecycle. - `install.json` continues to identify the physically installed outer executable for recovery. Payload activation or handoff must not rewrite it to a versioned payload executable. - `tools/serve` owns deterministic local updater fixtures only. It must not contain product updater runtime logic. - `tools/pack` owns packaged build/install/start/inspect/logs/uninstall/cleanup and the platform installer harness, including Windows NSIS registry observation and cleanup. @@ -136,7 +136,7 @@ C:\odtp-beta-release-fixed\out\win\namespaces\release-beta-win\builder\Open Desi - The native Windows File menu must not expose update actions. On macOS, the app menu exposes the state-aware update item and opens the renderer update dialog without making background checks intrusive. - The updater popup uses i18n strings and download progress must not flash to 100% before real bytes arrive. - Applying the payload update should quit and relaunch the exact executable under the prepared version's `payload` directory, then mark launcher `active` and `lastSuccessful` to that version and clear `attempt.json`. -- A historical outer may first create a mixed generation. Its daemon-sidecar compatibility handoff must replace the historical desktop with the exact payload desktop executable, preserve the true previous pointer for recovery, and leave the handoff journal either absent or `confirmed`—never stranded in `prepared` or `armed`. +- For a historical outer, the handoff must retain the true previous launcher pointer for fail-closed recovery and terminate with `desktop-handoff.json` absent or `confirmed`; `prepared` and `armed` are not successful terminal states. - After a full stop, launching the installed shortcut/outer again must still converge on the same active payload desktop and preserve daemon/API behavior, including a real PPTX export. - If the updater falls back to the installer path, clicking `Open installer` opens the real downloaded beta installer. Installing it should overwrite the same `Open Design-release-beta-win` registry key, not create a second beta key. diff --git a/tools/pack/CACHE.md b/tools/pack/CACHE.md index 88f66725a1..2dc9aad4b5 100644 --- a/tools/pack/CACHE.md +++ b/tools/pack/CACHE.md @@ -165,8 +165,8 @@ otherwise.** A newly added node is low confidence by default. Recorded so that grading stays checkable. These are known and accepted; do not extend them. -- `.workspace-build` — `pnpm-workspace.yaml` is not a key input; - file mode (executable bit) is not hashed by `hashPackageSourcePath`. +- `.workspace-build` — file mode (executable bit) is not hashed by + `hashPackageSourcePath`. - `win.launcher-payload` — the `seed: "nsis-base"` branch takes content from the NSIS base payload but carries only the literal `"nsis-base"`, not `WIN_ARCHIVE_CACHE_VERSION`. Bumping that constant without bumping the diff --git a/tools/pack/README.md b/tools/pack/README.md index 2d8b06c8cb..e53d2c904b 100644 --- a/tools/pack/README.md +++ b/tools/pack/README.md @@ -31,17 +31,17 @@ Packaged runtime state is namespace-scoped under `.tmp/tools-pack/runtime/mac/na - `cache/` is reserved for namespace-local packaged cache state. - `user-data/` is the Electron/Chromium `userData` root, with `user-data/session/` used for `sessionData`. -Finder/manual launches cannot carry argv stamps on the root desktop process. To keep process fallback safe, -`apps/packaged` writes `runtime/desktop-root.json` with the desktop stamp, PID, executable path, app path, and log path. -`tools-pack mac stop` trusts that marker only when namespace/stamp/PID/command validation passes; otherwise it reports the -unmanaged/not-owned reason instead of killing unknown processes. +Every packaged root registers the exact five-field sidecar stamp +(`channel / namespace / source / mode / app`) in its own argv. Direct OS +launches register during packaged startup; tools-pack launches receive the same +stamp through the sidecar launch atomic. No identity file participates in +discovery or ownership. -### `tools-pack mac stop` validation - -- If the marker is absent, stop reports `not-running`. -- If the marker PID is gone, stop reports `not-running` and clears the stale marker. -- If the marker PID was reused by an unrelated process, stop reports `unmanaged`. -- If the marker namespace, stamp, runtime root, or command does not match the current namespace, stop reports `unmanaged`. +`tools-pack mac stop` scans exact argv stamps for desktop, Web, and daemon across +both tools-pack and direct packaged sources, requests private graceful teardown, +waits for every captured generation to exit, and then force-stops only the +still-matching process trees. Cleanup and uninstall refuse to remove artifacts +while any captured generation remains alive. This keeps `stop` from killing processes outside the current namespace. @@ -130,8 +130,8 @@ Headless mode targets environments without a display (WSL2, headless servers, CI `--headless` makes `install`, `start`, `stop`, `uninstall`, and `cleanup` operate on the headless entry (`@open-design/packaged/dist/headless.mjs`) instead of the AppImage. Headless mode runs daemon + web without Electron. - `install --headless` writes a shell launcher at `~/.local/bin/open-design-headless-` that bakes in the namespace and resource paths. The launcher is self-contained, but the assembled app directory at those paths must remain in place — don't move it after install. -- `start --headless` spawns the headless process directly, redirects stdout/stderr to `logs/desktop/latest.log`, and waits up to 95s (35s for identity marker + 60s for web URL) before returning. -- `stop --headless` reads `runtime/headless-root.json` (separate from the AppImage path's `runtime/desktop-root.json`), validates the marker's packaged desktop stamp and namespace root, sends a graceful SHUTDOWN over IPC, then terminates the process tree. The separate marker prevents headless stop/cleanup from claiming a menu-launched AppImage; unlike AppImage stop, it does not perform the AppImage-specific process-command check. +- `start --headless` launches through the sidecar atomic and waits up to 95s for private status readiness. +- `stop --headless` terminally stops the exact `mode=headless` argv stamp. The mode field keeps it disjoint from the AppImage desktop without identity files or public IPC paths. - `inspect --headless` returns status only. Eval and screenshot require AppImage mode because there is no Electron renderer in headless mode. - `uninstall --headless` removes the headless launcher after a safe stop. - `cleanup --headless` stops the headless process before removing namespace output/runtime roots. diff --git a/tools/pack/src/config/sidecar-stamps.ts b/tools/pack/src/config/sidecar-stamps.ts new file mode 100644 index 0000000000..c496bec90b --- /dev/null +++ b/tools/pack/src/config/sidecar-stamps.ts @@ -0,0 +1,60 @@ +import { + APP_KEYS, + SIDECAR_MODES, + SIDECAR_SOURCES, +} from "@open-design/sidecar-proto"; +import type { SidecarStamp, SidecarStopRequest } from "@open-design/sidecar"; +import { releaseChannelFromNamespace, releaseChannelFromVersion } from "@open-design/release"; + +import type { ToolPackConfig } from "./index.js"; + +type PackagedSource = typeof SIDECAR_SOURCES.TOOLS_PACK | typeof SIDECAR_SOURCES.PACKAGED; + +export function toolPackSidecarStamp( + config: ToolPackConfig, + options: { + app?: SidecarStamp["app"]; + mode?: SidecarStamp["mode"]; + source?: PackagedSource; + } = {}, +): SidecarStamp { + return { + app: options.app ?? APP_KEYS.DESKTOP, + channel: releaseChannelFromVersion(config.appVersion) + ?? releaseChannelFromNamespace(config.namespace, "default") + ?? "stable", + mode: options.mode ?? SIDECAR_MODES.RUNTIME, + namespace: config.namespace, + source: options.source ?? SIDECAR_SOURCES.TOOLS_PACK, + }; +} + +/** + * Every process resource that may belong to one packaged namespace. tools-pack + * owns this product declaration; packages/sidecar owns how the set is observed + * and retired. + */ +export function packagedSidecarStopRequests( + config: ToolPackConfig, + desktopMode: SidecarStamp["mode"] = SIDECAR_MODES.RUNTIME, +): SidecarStopRequest[] { + return [SIDECAR_SOURCES.TOOLS_PACK, SIDECAR_SOURCES.PACKAGED].flatMap((source) => [ + { stamp: toolPackSidecarStamp(config, { app: APP_KEYS.DESKTOP, mode: desktopMode, source }) }, + { + options: { + gracefulRequestTimeoutMs: 500, + killGraceMs: 750, + termGraceMs: 750, + }, + stamp: toolPackSidecarStamp(config, { app: APP_KEYS.WEB, mode: desktopMode, source }), + }, + { stamp: toolPackSidecarStamp(config, { app: APP_KEYS.DAEMON, mode: desktopMode, source }) }, + ]); +} + +export function allPackagedSidecarStopRequests(config: ToolPackConfig): SidecarStopRequest[] { + return [ + ...packagedSidecarStopRequests(config, SIDECAR_MODES.RUNTIME), + ...packagedSidecarStopRequests(config, "headless"), + ]; +} diff --git a/tools/pack/src/linux.ts b/tools/pack/src/linux.ts index 8064428f3b..fa3e9c406a 100644 --- a/tools/pack/src/linux.ts +++ b/tools/pack/src/linux.ts @@ -1,37 +1,39 @@ import { execFile, spawn } from "node:child_process"; -import { access, chmod, cp, mkdir, open, readFile, readdir, readlink, rename, rm, stat, writeFile } from "node:fs/promises"; +import { access, chmod, cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join, posix } from "node:path"; import { promisify } from "node:util"; import { APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_MESSAGES, SIDECAR_MODES, SIDECAR_SOURCES, type DesktopEvalResult, type DesktopScreenshotResult, type DesktopStatusSnapshot, - type SidecarStamp, } from "@open-design/sidecar-proto"; -import { createSidecarLaunchEnv, requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; import { - collectProcessTreePids, - createPackageManagerInvocation, - createProcessStampArgs, - listProcessSnapshots, - readLogTail, - spawnBackgroundProcess, - stopProcesses, -} from "@open-design/platform"; + convergeSidecarLaunch, + findSidecarProcesses, + getSidecarStatus, + invokeSidecar, + stopSidecars, + type SidecarStamp, +} from "@open-design/sidecar"; +import { createPackageManagerInvocation, readLogTail } from "@open-design/platform"; import type { ToolPackConfig } from "./config/index.js"; +import { + allPackagedSidecarStopRequests, + packagedSidecarStopRequests, + toolPackSidecarStamp, +} from "./config/sidecar-stamps.js"; import { domToPptxBundleResource } from "./dom-to-pptx-resource.js"; import { copyBundledResourceTrees, linuxResources, packBundledDshRuntime } from "./resources/index.js"; import { copyOptionalVelaCliBinary } from "./vela-cli.js"; import { electronBuilderVersionForAppVersion, readRuntimeAppVersion } from "./versioning/index.js"; -import { processWebSourcemaps } from "./web-sourcemaps.js"; +import { runWorkspaceBuild } from "./workspace-build.js"; const execFileAsync = promisify(execFile); @@ -52,10 +54,10 @@ export const INTERNAL_PACKAGES = [ { directory: "packages/components", name: "@open-design/components" }, { directory: "packages/contracts", name: "@open-design/contracts" }, { directory: "packages/registry-protocol", name: "@open-design/registry-protocol" }, - { directory: "packages/launcher-proto", name: "@open-design/launcher-proto" }, { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, - { directory: "packages/sidecar", name: "@open-design/sidecar" }, + { directory: "packages/launcher-proto", name: "@open-design/launcher-proto" }, { directory: "packages/platform", name: "@open-design/platform" }, + { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/download", name: "@open-design/download" }, { directory: "packages/host", name: "@open-design/host" }, { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, @@ -460,45 +462,6 @@ async function readPackagedVersion(config: ToolPackConfig): Promise { return readRuntimeAppVersion(config); } -async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise { - const webNextEnvPath = join(config.workspaceRoot, "apps", "web", "next-env.d.ts"); - const previousWebNextEnv = await readFile(webNextEnvPath, "utf8").catch(() => null); - - await runPnpm(config, ["--filter", "@open-design/release", "build"]); - await runPnpm(config, ["--filter", "@open-design/contracts", "build"]); - await runPnpm(config, ["--filter", "@open-design/registry-protocol", "build"]); - await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]); - await runPnpm(config, ["--filter", "@open-design/launcher-proto", "build"]); - await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]); - await runPnpm(config, ["--filter", "@open-design/platform", "build"]); - await runPnpm(config, ["--filter", "@open-design/host", "build"]); - await runPnpm(config, ["--filter", "@open-design/download", "build"]); - await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]); - await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]); - await runPnpm(config, ["--filter", "@open-design/download", "build"]); - await runPnpm(config, ["--filter", "@open-design/host", "build"]); - await runPnpm(config, ["--filter", "@open-design/diagnostics", "build"]); - await runPnpm(config, ["--filter", "@open-design/dsh-runtime", "build"]); - await runPnpm(config, ["--filter", "@open-design/components", "build"]); - await runPnpm(config, ["--filter", "@open-design/daemon", "build"]); - try { - await runPnpm(config, ["--filter", "@open-design/web", "build"], { OD_WEB_OUTPUT_MODE: "server" }); - await runPnpm(config, ["--filter", "@open-design/web", "build:sidecar"]); - // Inject chunk IDs + upload browser sourcemaps to PostHog, then strip - // .map files before AppImage packaging. See - // `tools/pack/src/web-sourcemaps.ts`. - await processWebSourcemaps(config); - } finally { - if (previousWebNextEnv == null) { - await rm(webNextEnvPath, { force: true }); - } else { - await writeFile(webNextEnvPath, previousWebNextEnv, "utf8"); - } - } - await runPnpm(config, ["--filter", "@open-design/desktop", "build"]); - await runPnpm(config, ["--filter", "@open-design/packaged", "build"]); -} - // --- Step 3: Tarball + resource helpers --- type PackedTarballInfo = { @@ -743,7 +706,10 @@ export async function packLinux(config: ToolPackConfig): Promise await runPnpm(config, args, extraEnv), + ); await copyResourceTree(config, paths); const tarballs = await collectWorkspaceTarballs(config, paths); await writeAssembledApp(config, paths, tarballs); @@ -898,28 +864,12 @@ export function shouldRejectLinuxHeadlessInspectOptions(options: { return options.expr != null || options.path != null; } -type DesktopRootIdentityMarker = { - appPath: string; - executablePath: string; - logPath: string; - namespaceRoot: string; - pid: number; - ppid: number; - stamp: SidecarStamp; - startedAt: string; - updatedAt: string; - version: 1; -}; - -type DesktopRootIdentityFallback = { - marker?: Partial; - markerPath: string; - processCommand?: string; +type SidecarDiscoveryFallback = { reason: string; }; export type LinuxStopResult = { - fallback?: DesktopRootIdentityFallback; + fallback?: SidecarDiscoveryFallback; gracefulRequested: boolean; namespace: string; remainingPids: number[]; @@ -927,206 +877,55 @@ export type LinuxStopResult = { stoppedPids: number[]; }; -type ProcessSnapshots = Awaited>; -type ProcessSnapshot = ProcessSnapshots[number]; - -type DesktopAppImageMarkerValidation = - | { status: "valid"; candidate: ProcessSnapshot } - | { status: "not-running" } - | { status: "invalid"; candidate: ProcessSnapshot; processCommand: string }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value != null && !Array.isArray(value); -} - -function isDesktopRootIdentityMarker(value: unknown): value is DesktopRootIdentityMarker { - if (!isRecord(value)) return false; - return ( - value.version === 1 && - typeof value.pid === "number" && - typeof value.ppid === "number" && - typeof value.appPath === "string" && - typeof value.executablePath === "string" && - typeof value.logPath === "string" && - typeof value.namespaceRoot === "string" && - typeof value.startedAt === "string" && - typeof value.updatedAt === "string" && - isRecord(value.stamp) - ); -} - -async function readRootIdentityMarker(markerPath: string): Promise<{ - fallback: DesktopRootIdentityFallback; - marker: DesktopRootIdentityMarker | null; -}> { - let payload: unknown; - try { - payload = JSON.parse(await readFile(markerPath, "utf8")); - } catch (error) { - const code = isRecord(error) && "code" in error ? String(error.code) : null; - return { - fallback: { markerPath, reason: code === "ENOENT" ? "marker-not-found" : "marker-read-failed" }, - marker: null, - }; - } - if (!isDesktopRootIdentityMarker(payload)) { - return { fallback: { markerPath, reason: "marker-invalid-shape" }, marker: null }; - } - return { - fallback: { marker: payload, markerPath, reason: "marker-present" }, - marker: payload, - }; -} - -async function readDesktopRootIdentityMarker(config: ToolPackConfig): Promise<{ - fallback: DesktopRootIdentityFallback; - marker: DesktopRootIdentityMarker | null; -}> { - return readRootIdentityMarker(desktopIdentityPath(config)); -} - -async function readHeadlessRootIdentityMarker(config: ToolPackConfig): Promise<{ - fallback: DesktopRootIdentityFallback; - marker: DesktopRootIdentityMarker | null; -}> { - return readRootIdentityMarker(headlessIdentityPath(config)); -} - -async function readProcessEnv(pid: number): Promise> { - try { - const raw = await readFile(`/proc/${pid}/environ`, "utf8"); - const result: Record = {}; - for (const entry of raw.split("\0")) { - const eq = entry.indexOf("="); - if (eq <= 0) continue; - result[entry.slice(0, eq)] = entry.slice(eq + 1); - } - return result; - } catch { - return {}; - } -} - -async function readProcessExe(pid: number): Promise { - try { - return await readlink(`/proc/${pid}/exe`); - } catch { - return ""; - } -} - -async function validateDesktopAppImageMarker( - config: ToolPackConfig, - marker: DesktopRootIdentityMarker, - snapshots: ProcessSnapshots, -): Promise { - const candidate = snapshots.find((s) => s.pid === marker.pid); - if (candidate == null) return { status: "not-running" }; - - // Validate the marker stamp (file content written by apps/packaged itself) - // rather than the process command line. Menu launches via the .desktop - // entry don't pass createProcessStampArgs to the AppImage -- they only set - // OD_PACKAGED_NAMESPACE -- so apps/packaged falls back to a SIDECAR_SOURCES.PACKAGED - // stamp. Validating the process command would reject those legitimate - // launches as `unmanaged`, which on uninstall would also remove the - // AppImage/desktop/icon files out from under the still-running app. - // Accept either TOOLS_PACK (CLI start) or PACKAGED (menu launch). Mirrors - // the dual-source acceptance pattern in mac/lifecycle.ts. - const expectedIpc = resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: config.namespace, - }); - const stampOk = - marker.stamp.app === APP_KEYS.DESKTOP && - marker.stamp.mode === SIDECAR_MODES.RUNTIME && - marker.stamp.namespace === config.namespace && - marker.stamp.ipc === expectedIpc && - (marker.stamp.source === SIDECAR_SOURCES.TOOLS_PACK || - marker.stamp.source === SIDECAR_SOURCES.PACKAGED); - const paths = resolveLinuxPaths(config); - const exePath = await readProcessExe(marker.pid); - const env = await readProcessEnv(marker.pid); - // marker.appPath is unreliable on Linux (apps/packaged writes "/"). Use the - // canonical install path we know about, falling back to the built AppImage - // for not-yet-installed builds. - const candidateAppImagePath = - (await pathExists(paths.installAppImagePath)) ? paths.installAppImagePath : await findBuiltAppImage(paths); - const cmdOk = candidateAppImagePath != null && matchesAppImageProcess( - { pid: marker.pid, executable: exePath, env }, - candidateAppImagePath, - ); - - if (stampOk && cmdOk && marker.namespaceRoot === config.roots.runtime.namespaceRoot) { - return { candidate, status: "valid" }; - } - - return { candidate, processCommand: candidate.command, status: "invalid" }; -} - function desktopLogPath(config: ToolPackConfig): string { return join(config.roots.runtime.namespaceRoot, "logs", APP_KEYS.DESKTOP, "latest.log"); } -function desktopIdentityPath(config: ToolPackConfig): string { - return join(config.roots.runtime.namespaceRoot, "runtime", "desktop-root.json"); -} - -function headlessIdentityPath(config: ToolPackConfig): string { - return join(config.roots.runtime.namespaceRoot, "runtime", "headless-root.json"); -} - -function linuxDesktopStamp(config: ToolPackConfig): SidecarStamp { - return { - app: APP_KEYS.DESKTOP, - ipc: resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: config.namespace, - }), - mode: SIDECAR_MODES.RUNTIME, - namespace: config.namespace, - source: SIDECAR_SOURCES.TOOLS_PACK, - }; +function linuxStamp( + config: ToolPackConfig, + options: { + app?: SidecarStamp["app"]; + mode?: string; + source?: typeof SIDECAR_SOURCES.TOOLS_PACK | typeof SIDECAR_SOURCES.PACKAGED; + } = {}, +): SidecarStamp { + return toolPackSidecarStamp(config, options); } export function createLinuxDesktopLaunchEnv( - config: ToolPackConfig, - stamp: SidecarStamp, + _config: ToolPackConfig, + _stamp: SidecarStamp, baseEnv: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - const env = createSidecarLaunchEnv({ - base: join(config.roots.runtime.namespaceRoot, "runtime"), - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - extraEnv: { ...baseEnv, [DESKTOP_LOG_ECHO_ENV]: "0" }, - stamp, - }); + const env: NodeJS.ProcessEnv = { ...baseEnv, [DESKTOP_LOG_ECHO_ENV]: "0" }; delete env.ELECTRON_RUN_AS_NODE; return env; } -async function waitForMarker(markerPath: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await pathExists(markerPath)) return true; - await new Promise((r) => setTimeout(r, 200)); - } - return false; +type ReachableLinuxSidecar = { + stamp: SidecarStamp; + status: T; +}; + +async function resolveReachableLinuxSidecar( + stamps: readonly SidecarStamp[], + timeoutMs: number, +): Promise | null> { + const probes = await Promise.all(stamps.map(async (stamp): Promise | null> => { + const status = await getSidecarStatus(stamp, { timeoutMs }).catch(() => null); + return status == null ? null : { stamp, status }; + })); + return probes.find((probe): probe is ReachableLinuxSidecar => probe != null) ?? null; } -async function fetchDesktopStatus(config: ToolPackConfig): Promise { - try { - const ipc = resolveAppIpcPath({ - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: config.namespace, - app: APP_KEYS.DESKTOP, - }); - const reply = await requestJsonIpc(ipc, { type: SIDECAR_MESSAGES.STATUS }); - if (reply == null || typeof reply !== "object") return null; - return reply as DesktopStatusSnapshot; - } catch { - return null; +async function waitForLinuxStatus(stamps: readonly SidecarStamp[], timeoutMs: number): Promise | null> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const active = await resolveReachableLinuxSidecar(stamps, 1_000); + if (active != null) return active; + await new Promise((r) => setTimeout(r, 200)); } + return null; } export async function startPackedLinuxApp(config: ToolPackConfig): Promise { @@ -1144,47 +943,38 @@ export async function startPackedLinuxApp(config: ToolPackConfig): Promise undefined); - - const stamp = linuxDesktopStamp(config); + const stamp = linuxStamp(config); + const launchStamps = [ + stamp, + linuxStamp(config, { source: SIDECAR_SOURCES.PACKAGED }), + ]; // --appimage-extract-and-run bypasses FUSE-mounted SquashFS, which is too slow // for daemon startup on first launch (smoke testing showed startup exceeded the // packaged sidecar's 35-second timeout when running from FUSE). - const args = ["--appimage-extract-and-run", ...createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT)]; - - const child = await spawnBackgroundProcess({ - args, + const convergence = await convergeSidecarLaunch({ + args: ["--appimage-extract-and-run"], command: appImagePath, cwd: dirname(appImagePath), env: createLinuxDesktopLaunchEnv(config, stamp), logFd: null, - }); + resources: { + dataRoot: join(config.roots.runtime.namespaceRoot, "data"), + ownerPid: null, + port: 0, + runtimeRoot: join(config.roots.runtime.namespaceRoot, "runtime"), + }, + stamp, + }, { ownerStamps: launchStamps, timeoutMs: 60_000 }); // 60s ceiling: AppImage --appimage-extract-and-run unpacks ~200MB to /tmp on // first launch before exec'ing the inner electron, which adds substantial // overhead vs mac's direct .app launch. // - // If the readiness wait or the post-ready status fetch throws, the detached - // child we just spawned is still running but unidentifiable to a future - // `linux stop` (the marker is the only persistent identity source). Tear it - // down via the same process-tree path stopPackedLinuxApp uses, then rethrow - // so the failure surfaces to the caller. Any cleanup error is suppressed -- - // we want the original failure preserved in the rejection. - const markerPath = desktopIdentityPath(config); - let status: DesktopStatusSnapshot | null; - try { - const ready = await waitForMarker(markerPath, 60_000); - if (!ready) { - throw new Error(`desktop-root.json not written within 60s at ${markerPath}`); - } - status = await fetchDesktopStatus(config); - } catch (error) { - await teardownOrphanedStart(child.pid).catch(() => undefined); - throw error; + const active = await waitForLinuxStatus(launchStamps, 60_000); + if (active == null) { + await stopSidecars(launchStamps.map((candidate) => ({ stamp: candidate }))).catch(() => undefined); + throw new Error(`desktop sidecar did not become ready within 60s for ${config.namespace}`); } return { @@ -1192,88 +982,21 @@ export async function startPackedLinuxApp(config: ToolPackConfig): Promise { - const snapshots = await listProcessSnapshots(); - const treePids = collectProcessTreePids(snapshots, [rootPid]); - await stopProcesses(treePids); -} - export async function stopPackedLinuxApp(config: ToolPackConfig): Promise { - const { fallback, marker } = await readDesktopRootIdentityMarker(config); - - if (marker == null) { - return { - fallback, - gracefulRequested: false, - namespace: config.namespace, - remainingPids: [], - status: "not-running", - stoppedPids: [], - }; - } - - // Validate the marker still represents a live, owned process. - const snapshots = await listProcessSnapshots(); - const validation = await validateDesktopAppImageMarker(config, marker, snapshots); - if (validation.status === "not-running") { - return { - fallback: { ...fallback, reason: "marker-pid-not-running" }, - gracefulRequested: false, - namespace: config.namespace, - remainingPids: [], - status: "not-running", - stoppedPids: [], - }; - } - - if (validation.status === "invalid") { - return { - fallback: { - ...fallback, - marker: { pid: marker.pid, stamp: marker.stamp }, - processCommand: validation.processCommand, - reason: "marker-validation-failed", - }, - gracefulRequested: false, - namespace: config.namespace, - remainingPids: [marker.pid], - status: "unmanaged", - stoppedPids: [], - }; - } - - // Try graceful shutdown via IPC first. mac/lifecycle.ts's pattern: best-effort SHUTDOWN - // request with a short timeout so Electron renderers + sidecars get a chance - // to flush state (SQLite WAL, logs) before SIGTERM. - let gracefulRequested = false; - try { - await requestJsonIpc(marker.stamp.ipc, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 }); - gracefulRequested = true; - } catch { - gracefulRequested = false; - } - - // Gather process tree, then SIGTERM -> SIGKILL via stopProcesses. - const treePids = collectProcessTreePids(snapshots, [marker.pid]); - const result = await stopProcesses(treePids); - - // Remove the marker on a clean stop so the next start has a fresh slate. - if (result.remainingPids.length === 0) { - await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined); - } + const stopped = await stopSidecars(allPackagedSidecarStopRequests(config)); return { - gracefulRequested, + gracefulRequested: stopped.gracefulAccepted, namespace: config.namespace, - remainingPids: result.remainingPids, - status: result.remainingPids.length === 0 ? "stopped" : "partial", - stoppedPids: result.stoppedPids, + remainingPids: stopped.remainingPids, + status: stopped.remainingPids.length > 0 ? "partial" : stopped.matchedPids.length > 0 ? "stopped" : "not-running", + stoppedPids: stopped.stoppedPids, }; } @@ -1300,12 +1023,16 @@ export async function inspectPackedLinuxApp( throw new Error("linux inspect --headless supports status only; omit --expr and --path"); } - const stamp = linuxDesktopStamp(config); - const status = await requestJsonIpc( - stamp.ipc, - { type: SIDECAR_MESSAGES.STATUS }, - { timeoutMs: 2000 }, - ).catch(() => null); + const stamps = [ + linuxStamp(config, { mode: options.headless === true ? "headless" : SIDECAR_MODES.RUNTIME }), + linuxStamp(config, { + mode: options.headless === true ? "headless" : SIDECAR_MODES.RUNTIME, + source: SIDECAR_SOURCES.PACKAGED, + }), + ]; + const active = await resolveReachableLinuxSidecar(stamps, 2000); + const stamp = active?.stamp ?? stamps[0]; + const status = active?.status ?? null; if (options.headless === true) { return { status }; @@ -1315,20 +1042,12 @@ export async function inspectPackedLinuxApp( ...(options.expr == null ? {} : { - eval: await requestJsonIpc( - stamp.ipc, - { input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL }, - { timeoutMs: 5000 }, - ), + eval: await invokeSidecar(stamp, SIDECAR_MESSAGES.EVAL, { expression: options.expr }, { timeoutMs: 5000 }), }), ...(options.path == null ? {} : { - screenshot: await requestJsonIpc( - stamp.ipc, - { input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT }, - { timeoutMs: 10000 }, - ), + screenshot: await invokeSidecar(stamp, SIDECAR_MESSAGES.SCREENSHOT, { path: options.path }, { timeoutMs: 10000 }), }), status, }; @@ -1337,9 +1056,9 @@ export async function inspectPackedLinuxApp( export type LinuxUninstallResult = { namespace: string; removed: { - appImage: "ok" | "already-removed" | "skipped-process-running"; - desktop: "ok" | "already-removed" | "skipped-process-running"; - icon: "ok" | "already-removed" | "skipped-process-running"; + appImage: "ok" | "already-removed"; + desktop: "ok" | "already-removed"; + icon: "ok" | "already-removed"; }; stop: LinuxStopResult; postUninstall: { @@ -1362,26 +1081,17 @@ async function tryRemove(path: string): Promise<"ok" | "already-removed"> { // ours -- in both cases something is still using the AppImage's mounted or // extracted contents, so destructive removal would leave broken file handles // and an orphan with stale state. -function isSafeToRemoveInstallFiles(stop: LinuxStopResult): boolean { - return stop.status === "stopped" || stop.status === "not-running"; +function assertLinuxStopComplete(stop: LinuxStopResult, operation: string): void { + if (stop.status === "stopped" || stop.status === "not-running") return; + throw new Error( + `cannot ${operation} packaged namespace while sidecar processes remain: ${stop.remainingPids.join(", ") || stop.status}`, + ); } export async function uninstallPackedLinuxApp(config: ToolPackConfig): Promise { const paths = resolveLinuxPaths(config); const stop = await stopPackedLinuxApp(config); - - if (!isSafeToRemoveInstallFiles(stop)) { - return { - namespace: config.namespace, - removed: { - appImage: "skipped-process-running", - desktop: "skipped-process-running", - icon: "skipped-process-running", - }, - stop, - postUninstall: { desktopDatabase: "skipped", iconCache: "skipped" }, - }; - } + assertLinuxStopComplete(stop, "uninstall"); const removedAppImage = await tryRemove(paths.installAppImagePath); const removedDesktop = await tryRemove(paths.installDesktopFilePath); @@ -1405,7 +1115,7 @@ export async function uninstallPackedLinuxApp(config: ToolPackConfig): Promise { const stop = await stopPackedLinuxHeadless(config); const launcherPath = headlessLauncherPath(config); - - if (!isSafeToRemoveInstallFiles(stop)) { - return { - launcherPath, - namespace: config.namespace, - removed: "skipped-process-running", - stop, - }; - } + assertLinuxStopComplete(stop, "uninstall"); return { launcherPath, @@ -1438,11 +1140,8 @@ export type LinuxCleanupResult = { removedOutputRoot: boolean; removedRuntimeNamespaceRoot: boolean; runtimeNamespaceRoot: string; - // True when stopPackedLinuxApp returned "partial" or "unmanaged" -- the - // output and runtime namespace roots may contain files held open by a - // surviving process tree, so we leave them in place rather than yanking - // SQLite WAL files / log handles / IPC sockets out from under it. - // Both removed* flags will be false in this case. + // Headless cleanup leaves the namespace intact when a desktop-mode owner is + // still active; a partial stop itself fails before this result is produced. skipped: boolean; stop: LinuxStopResult; }; @@ -1481,53 +1180,9 @@ export type LinuxHeadlessStartResult = { logPath: string; namespace: string; pid: number; - status: WebRootIdentity; + status: DesktopStatusSnapshot; }; -type WebRootIdentity = { - namespace: string; - pid: number; - url: string; - startedAt: string; - version: 1; -}; - -function webIdentityPath(config: ToolPackConfig): string { - return join(config.roots.runtime.namespaceRoot, "runtime", "web-root.json"); -} - -function isValidWebIdentity( - identity: unknown, - namespace: string, - pid: number, -): identity is WebRootIdentity { - if (typeof identity !== "object" || identity == null) return false; - const obj = identity as Record; - return ( - obj.version === 1 && - obj.namespace === namespace && - obj.pid === pid && - typeof obj.url === "string" && - obj.url.length > 0 - ); -} - -async function waitForWebIdentity(config: ToolPackConfig, childPid: number, timeoutMs: number): Promise { - const path = webIdentityPath(config); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const content = await readFile(path, "utf8"); - const identity = JSON.parse(content); - if (isValidWebIdentity(identity, config.namespace, childPid)) return identity; - } catch { - // File doesn't exist yet or invalid JSON - } - await new Promise((r) => setTimeout(r, 100)); - } - return null; -} - export async function installPackedLinuxHeadless(config: ToolPackConfig): Promise { const paths = resolveLinuxPaths(config); const entryPath = resolveHeadlessEntryPath(paths); @@ -1564,8 +1219,6 @@ export async function installPackedLinuxHeadless(config: ToolPackConfig): Promis return { launcherPath, namespace: config.namespace }; } -// Waits up to 35s for the desktop identity marker, then up to 60s for the -// web identity (95s total). export async function startPackedLinuxHeadless(config: ToolPackConfig): Promise { const paths = resolveLinuxPaths(config); const entryPath = resolveHeadlessEntryPath(paths); @@ -1582,51 +1235,42 @@ export async function startPackedLinuxHeadless(config: ToolPackConfig): Promise< await mkdir(dirname(logPath), { recursive: true }); await writeFile(logPath, "", "utf8"); - // Remove stale headless identity markers from a previous run so waitForMarker - // and waitForWebIdentity below wait for the newly spawned process. Leave - // desktop-root.json alone: a menu-launched AppImage uses that marker and - // headless start/stop must not claim or erase it. - await rm(headlessIdentityPath(config), { force: true }).catch(() => undefined); - await rm(webIdentityPath(config), { force: true }).catch(() => undefined); - - // Open the log file so stdout/stderr from the headless process are captured. + const stamp = linuxStamp(config, { mode: "headless" }); + const launchStamps = [ + stamp, + linuxStamp(config, { mode: "headless", source: SIDECAR_SOURCES.PACKAGED }), + ]; const logHandle = await open(logPath, "a"); let child: { pid: number }; try { - child = await spawnBackgroundProcess({ + const convergence = await convergeSidecarLaunch({ args: [entryPath], command: nodeCommand, cwd: dirname(entryPath), env: { ...process.env, - // Bake in the packaged namespace so headless uses the same namespace - // as the tools-pack config regardless of the caller's environment. OD_PACKAGED_NAMESPACE: config.namespace, - // Point the headless data root at the tools-pack runtime directory so - // the identity marker is written to the path this function polls. - // headless.ts computes: join(OD_DATA_DIR, "namespaces") which must - // equal config.roots.runtime.namespaceBaseRoot. OD_DATA_DIR: dirname(config.roots.runtime.namespaceBaseRoot), OD_RESOURCE_ROOT: paths.resourceRoot, }, logFd: logHandle.fd, - }); + resources: { + dataRoot: join(config.roots.runtime.namespaceRoot, "data"), + ownerPid: null, + port: 0, + runtimeRoot: join(config.roots.runtime.namespaceRoot, "runtime"), + }, + stamp, + }, { ownerStamps: launchStamps, timeoutMs: 95_000 }); + child = { pid: convergence.description.resources.pid }; } finally { - // Close the parent-side handle; the child has already inherited the fd. await logHandle.close().catch(() => undefined); } - const markerPath = headlessIdentityPath(config); - const ready = await waitForMarker(markerPath, 35_000); - if (!ready) { - await teardownOrphanedStart(child.pid).catch(() => undefined); - throw new Error(`headless-root.json not written within 35s at ${markerPath}`); - } - - const webIdentity = await waitForWebIdentity(config, child.pid, 60_000); - if (webIdentity == null) { - await teardownOrphanedStart(child.pid).catch(() => undefined); - throw new Error(`web-root.json not written within 60s at ${webIdentityPath(config)}`); + const active = await waitForLinuxStatus(launchStamps, 95_000); + if (active == null) { + await stopSidecars(launchStamps.map((candidate) => ({ stamp: candidate }))).catch(() => undefined); + throw new Error(`headless sidecar did not become ready within 95s for ${config.namespace}`); } return { @@ -1634,91 +1278,19 @@ export async function startPackedLinuxHeadless(config: ToolPackConfig): Promise< logPath, namespace: config.namespace, pid: child.pid, - status: webIdentity, + status: active.status, }; } export async function stopPackedLinuxHeadless(config: ToolPackConfig): Promise { - const { fallback, marker } = await readHeadlessRootIdentityMarker(config); - - if (marker == null) { - return { - fallback, - gracefulRequested: false, - namespace: config.namespace, - remainingPids: [], - status: "not-running", - stoppedPids: [], - }; - } - - const snapshots = await listProcessSnapshots(); - const candidate = snapshots.find((s) => s.pid === marker.pid); - if (candidate == null) { - return { - fallback: { ...fallback, reason: "marker-pid-not-running" }, - gracefulRequested: false, - namespace: config.namespace, - remainingPids: [], - status: "not-running", - stoppedPids: [], - }; - } - - // Validate the stamp from headless-root.json. A menu-launched AppImage writes - // the same PACKAGED source to desktop-root.json, so the distinct marker path - // is the ownership boundary that keeps --headless stop/cleanup from claiming - // the AppImage runtime. - const expectedIpc = resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: config.namespace, - }); - const stampOk = - marker.stamp.app === APP_KEYS.DESKTOP && - marker.stamp.mode === SIDECAR_MODES.RUNTIME && - marker.stamp.namespace === config.namespace && - marker.stamp.ipc === expectedIpc && - marker.stamp.source === SIDECAR_SOURCES.PACKAGED; - - if (!stampOk || marker.namespaceRoot !== config.roots.runtime.namespaceRoot) { - return { - fallback: { - ...fallback, - marker: { pid: marker.pid, stamp: marker.stamp }, - processCommand: candidate.command, - reason: "marker-validation-failed", - }, - gracefulRequested: false, - namespace: config.namespace, - remainingPids: [marker.pid], - status: "unmanaged", - stoppedPids: [], - }; - } - - let gracefulRequested = false; - try { - await requestJsonIpc(marker.stamp.ipc, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 }); - gracefulRequested = true; - } catch { - gracefulRequested = false; - } - - const treePids = collectProcessTreePids(snapshots, [marker.pid]); - const result = await stopProcesses(treePids); - - if (result.remainingPids.length === 0) { - await rm(headlessIdentityPath(config), { force: true }).catch(() => undefined); - await rm(webIdentityPath(config), { force: true }).catch(() => undefined); - } + const stopped = await stopSidecars(packagedSidecarStopRequests(config, "headless")); return { - gracefulRequested, + gracefulRequested: stopped.gracefulAccepted, namespace: config.namespace, - remainingPids: result.remainingPids, - status: result.remainingPids.length === 0 ? "stopped" : "partial", - stoppedPids: result.stoppedPids, + remainingPids: stopped.remainingPids, + status: stopped.remainingPids.length > 0 ? "partial" : stopped.matchedPids.length > 0 ? "stopped" : "not-running", + stoppedPids: stopped.stoppedPids, }; } @@ -1733,33 +1305,23 @@ export async function cleanupPackedLinuxNamespace( const outputRoot = config.roots.output.namespaceRoot; const runtimeNamespaceRoot = config.roots.runtime.namespaceRoot; - if (!isSafeToRemoveInstallFiles(stop)) { - return { - namespace: config.namespace, - outputRoot, - removedOutputRoot: false, - removedRuntimeNamespaceRoot: false, - runtimeNamespaceRoot, - skipped: true, - stop, - }; - } + assertLinuxStopComplete(stop, "cleanup"); if (mode === "headless") { - const { marker } = await readDesktopRootIdentityMarker(config); - if (marker != null) { - const desktop = await validateDesktopAppImageMarker(config, marker, await listProcessSnapshots()); - if (desktop.status !== "not-running") { - return { - namespace: config.namespace, - outputRoot, - removedOutputRoot: false, - removedRuntimeNamespaceRoot: false, - runtimeNamespaceRoot, - skipped: true, - stop, - }; - } + const desktopRunning = await Promise.all([ + findSidecarProcesses(linuxStamp(config)), + findSidecarProcesses(linuxStamp(config, { source: SIDECAR_SOURCES.PACKAGED })), + ]); + if (desktopRunning.some((processes) => processes.length > 0)) { + return { + namespace: config.namespace, + outputRoot, + removedOutputRoot: false, + removedRuntimeNamespaceRoot: false, + runtimeNamespaceRoot, + skipped: true, + stop, + }; } } diff --git a/tools/pack/src/mac/app.ts b/tools/pack/src/mac/app.ts index 57a95cb190..be191452ef 100644 --- a/tools/pack/src/mac/app.ts +++ b/tools/pack/src/mac/app.ts @@ -1,4 +1,4 @@ -import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { cp, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { dirname, join, relative } from "node:path"; @@ -41,8 +41,12 @@ function toPosixPath(value: string): string { return value.replaceAll("\\", "/"); } -function toRelativeImportSpecifier(fromDirectory: string, targetPath: string): string { - const specifier = toPosixPath(relative(fromDirectory, targetPath)); +export async function toRelativeImportSpecifier(fromDirectory: string, targetPath: string): Promise { + const [canonicalFrom, canonicalTarget] = await Promise.all([ + realpath(fromDirectory), + realpath(targetPath), + ]); + const specifier = toPosixPath(relative(canonicalFrom, canonicalTarget)); return specifier.startsWith(".") ? specifier : `./${specifier}`; } @@ -66,7 +70,6 @@ async function buildPrebundledStandaloneRuntime( metafilePath: paths.packagedMainPrebundleMetaPath, policyName: "packagedMain", }); - await runEsbuild(config, [ join(config.workspaceRoot, "apps", "web", "dist", "sidecar", "index.js"), "--bundle", @@ -86,7 +89,7 @@ async function buildPrebundledStandaloneRuntime( await writeFile( paths.daemonSidecarPrebundleEntrypointPath, `import ${JSON.stringify( - toRelativeImportSpecifier( + await toRelativeImportSpecifier( dirname(paths.daemonSidecarPrebundleEntrypointPath), join(config.workspaceRoot, "apps", "daemon", "dist", "sidecar", "index.js"), ), @@ -101,7 +104,7 @@ async function buildPrebundledStandaloneRuntime( "process.env.OD_BIN ??= selfPath;", "process.env.OD_DAEMON_CLI_PATH ??= selfPath;", `await import(${JSON.stringify( - toRelativeImportSpecifier( + await toRelativeImportSpecifier( dirname(paths.daemonCliPrebundleEntrypointPath), join(config.workspaceRoot, "apps", "daemon", "dist", "cli.js"), ), diff --git a/tools/pack/src/mac/constants.ts b/tools/pack/src/mac/constants.ts index 2c335dc7dc..38dde66dfa 100644 --- a/tools/pack/src/mac/constants.ts +++ b/tools/pack/src/mac/constants.ts @@ -7,8 +7,8 @@ export const INTERNAL_PACKAGES = [ { directory: "packages/registry-protocol", name: "@open-design/registry-protocol" }, { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/launcher-proto", name: "@open-design/launcher-proto" }, - { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/platform", name: "@open-design/platform" }, + { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/download", name: "@open-design/download" }, { directory: "packages/host", name: "@open-design/host" }, { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, diff --git a/tools/pack/src/mac/lifecycle.ts b/tools/pack/src/mac/lifecycle.ts index d19d2a7d0a..6deaefd101 100644 --- a/tools/pack/src/mac/lifecycle.ts +++ b/tools/pack/src/mac/lifecycle.ts @@ -1,11 +1,10 @@ -import { execFile, type ChildProcess } from "node:child_process"; +import { execFile } from "node:child_process"; import { mkdir, open, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; import { APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_MESSAGES, SIDECAR_MODES, SIDECAR_SOURCES, @@ -15,266 +14,72 @@ import { type DesktopStatusSnapshot, type DesktopUpdateAction, type DesktopUpdateResult, - type SidecarStamp, } from "@open-design/sidecar-proto"; -import { createSidecarLaunchEnv, requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; import { - collectProcessTreePids, - createProcessStampArgs, - isProcessAlive, - listProcessSnapshots, - matchesStampedProcess, - readLogTail, - spawnLoggedProcess, - stopProcesses, -} from "@open-design/platform"; + convergeSidecarLaunch, + getSidecarStatus, + invokeSidecar, + stopSidecars, + type SidecarStamp as ConvergedSidecarStamp, +} from "@open-design/sidecar"; +import { readLogTail } from "@open-design/platform"; import type { ToolPackConfig } from "../config/index.js"; +import { allPackagedSidecarStopRequests, toolPackSidecarStamp } from "../config/sidecar-stamps.js"; +import { resolveToolPackLauncherLayout } from "../launcher/layout.js"; import { readToolPackLauncherRuntimeSnapshot } from "../launcher/runtime-snapshot.js"; import { readToolPackUpdateCacheLifecycleSnapshot } from "../updates/cache-lifecycle-snapshot.js"; import { PACKAGED_CONFIG_PATH_ENV, writeLaunchPackagedConfig } from "./app-config.js"; import { DESKTOP_LOG_ECHO_ENV } from "./constants.js"; import { pathExists, scrubMacExtendedAttributes } from "./fs.js"; import { resolveMacInstallIdentity } from "./identity.js"; -import { desktopIdentityPath, desktopLogPath, macAppExecutablePath, resolveMacPaths } from "./paths.js"; -import type { DesktopRootIdentityFallback, DesktopRootIdentityMarker, MacCleanupResult, MacInspectResult, MacInstallResult, MacStartResult, MacStartSource, MacStopResult, MacUninstallResult } from "./types.js"; +import { desktopLogPath, macAppExecutablePath, resolveMacPaths } from "./paths.js"; +import type { MacCleanupResult, MacInspectResult, MacInstallResult, MacStartResult, MacStartSource, MacStopResult, MacUninstallResult } from "./types.js"; const execFileAsync = promisify(execFile); const UPDATE_ACTION_TIMEOUT_MS = 10 * 60 * 1000; -function desktopStamp(config: ToolPackConfig): SidecarStamp { - return { - app: APP_KEYS.DESKTOP, - ipc: resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: config.namespace, - }), - mode: SIDECAR_MODES.RUNTIME, - namespace: config.namespace, - source: SIDECAR_SOURCES.TOOLS_PACK, - }; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value != null && !Array.isArray(value); } -function isDesktopRootIdentityMarker(value: unknown): value is DesktopRootIdentityMarker { - if (!isRecord(value)) return false; - return ( - value.version === 1 && - typeof value.pid === "number" && - typeof value.ppid === "number" && - typeof value.appPath === "string" && - typeof value.executablePath === "string" && - typeof value.logPath === "string" && - typeof value.namespaceRoot === "string" && - typeof value.startedAt === "string" && - typeof value.updatedAt === "string" && - isRecord(value.stamp) - ); +function convergedDesktopStamp( + config: ToolPackConfig, + source: typeof SIDECAR_SOURCES.TOOLS_PACK | typeof SIDECAR_SOURCES.PACKAGED = SIDECAR_SOURCES.TOOLS_PACK, + mode: ConvergedSidecarStamp["mode"] = SIDECAR_MODES.RUNTIME, +): ConvergedSidecarStamp { + return toolPackSidecarStamp(config, { app: APP_KEYS.DESKTOP, mode, source }); } -function summarizeDesktopMarker( - marker: DesktopRootIdentityMarker | null, -): Partial | undefined { - if (marker == null) return undefined; - return { - appPath: marker.appPath, - executablePath: marker.executablePath, - logPath: marker.logPath, - namespaceRoot: marker.namespaceRoot, - pid: marker.pid, - ppid: marker.ppid, - stamp: marker.stamp, - startedAt: marker.startedAt, - updatedAt: marker.updatedAt, - version: marker.version, - }; -} +type ReachableDesktop = { + stamp: ConvergedSidecarStamp; + status: DesktopStatusSnapshot; +}; -async function readDesktopRootIdentityMarker(config: ToolPackConfig): Promise<{ - fallback: DesktopRootIdentityFallback; - marker: DesktopRootIdentityMarker | null; -}> { - const markerPath = desktopIdentityPath(config); - let payload: unknown; - - try { - payload = JSON.parse(await readFile(markerPath, "utf8")); - } catch (error) { - const code = typeof error === "object" && error != null && "code" in error - ? String((error as { code?: unknown }).code) - : null; - return { - fallback: { - markerPath, - reason: code === "ENOENT" ? "marker-not-found" : "marker-read-failed", - }, - marker: null, - }; - } - - if (!isDesktopRootIdentityMarker(payload)) { - return { - fallback: { - markerPath, - reason: "marker-invalid-shape", - }, - marker: null, - }; - } - - return { - fallback: { - marker: summarizeDesktopMarker(payload), - markerPath, - reason: "marker-present", - }, - marker: payload, - }; -} - -function commandMatchesDesktopMarker( - command: string, - marker: DesktopRootIdentityMarker, -): boolean { - return command.includes(marker.executablePath) || command.includes(macAppExecutablePath(marker.appPath, basename(marker.executablePath))); -} - -async function resolveDesktopRootIdentityFallback(config: ToolPackConfig): Promise<{ - fallback: DesktopRootIdentityFallback; - rootPid: number | null; -}> { - const { fallback, marker } = await readDesktopRootIdentityMarker(config); - if (marker == null) return { fallback, rootPid: null }; - - let stamp: SidecarStamp; - try { - stamp = OPEN_DESIGN_SIDECAR_CONTRACT.normalizeStamp(marker.stamp); - } catch { - return { - fallback: { ...fallback, reason: "marker-invalid-stamp" }, - rootPid: null, - }; - } - - const expectedIpc = resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: config.namespace, - }); - if ( - stamp.app !== APP_KEYS.DESKTOP || - stamp.mode !== SIDECAR_MODES.RUNTIME || - stamp.namespace !== config.namespace || - stamp.ipc !== expectedIpc || - (stamp.source !== SIDECAR_SOURCES.PACKAGED && stamp.source !== SIDECAR_SOURCES.TOOLS_PACK) - ) { - return { - fallback: { ...fallback, reason: "marker-stamp-mismatch" }, - rootPid: null, - }; - } - - if (marker.namespaceRoot !== config.roots.runtime.namespaceRoot) { - return { - fallback: { ...fallback, reason: "marker-namespace-root-mismatch" }, - rootPid: null, - }; - } - - const processes = await listProcessSnapshots(); - const processInfo = processes.find((entry) => entry.pid === marker.pid) ?? null; - if (processInfo == null) { - return { - fallback: { ...fallback, reason: "marker-pid-not-running" }, - rootPid: null, - }; - } - - if (!commandMatchesDesktopMarker(processInfo.command, marker)) { - return { - fallback: { - ...fallback, - processCommand: processInfo.command, - reason: "marker-command-mismatch", - }, - rootPid: null, - }; - } - - return { - fallback: { - ...fallback, - processCommand: processInfo.command, - reason: "marker-matched", - }, - rootPid: marker.pid, - }; -} - -function isUnmanagedDesktopFallback(fallback: DesktopRootIdentityFallback | undefined): boolean { - return fallback != null && ![ - "marker-matched", - "marker-not-found", - "marker-pid-not-running", - ].includes(fallback.reason); +async function resolveReachableDesktop(config: ToolPackConfig, timeoutMs: number): Promise { + const probes = await Promise.all([ + SIDECAR_SOURCES.TOOLS_PACK, + SIDECAR_SOURCES.PACKAGED, + ].map(async (source) => { + const stamp = convergedDesktopStamp(config, source); + try { + return { stamp, status: await getSidecarStatus(stamp, { timeoutMs }) }; + } catch { + return null; + } + })); + return probes.find((probe): probe is ReachableDesktop => probe != null) ?? null; } async function waitForDesktopStatus(config: ToolPackConfig, timeoutMs = 45_000): Promise { - const stamp = desktopStamp(config); const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { - try { - return await requestJsonIpc(stamp.ipc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 1000 }); - } catch { - await new Promise((resolveWait) => setTimeout(resolveWait, 200)); - } + const active = await resolveReachableDesktop(config, 1000); + if (active != null) return active.status; + await new Promise((resolveWait) => setTimeout(resolveWait, 200)); } return null; } -type ProcessExit = { code: number | null; signal: NodeJS.Signals | null }; - -function watchProcessExit(child: ChildProcess): { - current(): ProcessExit | null; - wait(timeoutMs: number): Promise; -} { - let exit: ProcessExit | null = null; - const waiters = new Set<(value: ProcessExit) => void>(); - - child.once("exit", (code, signal) => { - exit = { code, signal }; - for (const resolveWait of waiters) resolveWait(exit); - waiters.clear(); - }); - - return { - current() { - return exit; - }, - async wait(timeoutMs: number): Promise { - if (exit != null) return exit; - return await new Promise((resolveWait) => { - const timer = setTimeout(() => { - waiters.delete(onExit); - resolveWait(null); - }, timeoutMs); - const onExit = (value: ProcessExit) => { - clearTimeout(timer); - resolveWait(value); - }; - waiters.add(onExit); - }); - }, - }; -} - -function formatExit(exit: ProcessExit): string { - return `code=${exit.code ?? "null"} signal=${exit.signal ?? "null"}`; -} - function nonEmptyLines(value: string): string[] { return value.split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.length > 0); } @@ -527,154 +332,67 @@ export async function installPackedMacDmg(config: ToolPackConfig): Promise { const target = await resolvePackedMacStartTarget(config); - const stamp = desktopStamp(config); + const stamp = convergedDesktopStamp(config); const logPath = desktopLogPath(config); const launchConfigPath = await writeLaunchPackagedConfig(config, target.appPath); await mkdir(dirname(logPath), { recursive: true }); await writeFile(logPath, "", "utf8"); const logHandle = await open(logPath, "a"); - let child: ChildProcess; + let convergence: Awaited>; try { - child = await spawnLoggedProcess({ - args: createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT), + convergence = await convergeSidecarLaunch({ + args: [], command: target.executablePath, cwd: target.appPath, detached: true, - env: createSidecarLaunchEnv({ - base: join(config.roots.runtime.namespaceRoot, "runtime"), - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - extraEnv: { + env: { ...process.env, [DESKTOP_LOG_ECHO_ENV]: "0", [PACKAGED_CONFIG_PATH_ENV]: launchConfigPath, - }, - stamp, - }), + }, logFd: logHandle.fd, - }); + resources: { + dataRoot: join(config.roots.runtime.namespaceRoot, "data"), + ownerPid: null, + port: 0, + runtimeRoot: join(config.roots.runtime.namespaceRoot, "runtime"), + }, + stamp, + }, { ownerStamps: [stamp, convergedDesktopStamp(config, SIDECAR_SOURCES.PACKAGED)] }); } finally { await logHandle.close().catch(() => undefined); } - const pid = child.pid ?? 0; - const exit = watchProcessExit(child); - const earlyExit = await exit.wait(1500); - child.unref(); - const cleanLauncherExit = earlyExit?.code === 0 && earlyExit.signal == null; - if (earlyExit != null && !cleanLauncherExit) { - throw new Error(await createLaunchFailureMessage(config, target, { - pid, - reason: `process exited early ${formatExit(earlyExit)}`, - })); - } + convergence.launcherProcess.unref(); + const pid = convergence.description.resources.pid; const status = await waitForDesktopStatus(config); - if (status == null && earlyExit != null) { + if (status == null) { throw new Error(await createLaunchFailureMessage(config, target, { pid, - reason: `launcher exited cleanly before desktop IPC was available ${formatExit(earlyExit)}`, + reason: "converged sidecar owner stopped responding before desktop status became available", })); } - const delayedExit = exit.current(); - if (status == null && delayedExit != null) { - throw new Error(await createLaunchFailureMessage(config, target, { - pid, - reason: `process exited before desktop IPC was available ${formatExit(delayedExit)}`, - })); - } - if (status == null && !isProcessAlive(pid)) { - throw new Error(await createLaunchFailureMessage(config, target, { - pid, - reason: "process exited before desktop IPC was available without an observed exit event", - })); - } - const activePid = typeof status?.pid === "number" && status.pid > 0 ? status.pid : pid; return { appPath: target.appPath, executablePath: target.executablePath, logPath, namespace: config.namespace, - pid: activePid, + pid, source: target.source, status, }; } -async function findManagedDesktopProcessTree(config: ToolPackConfig): Promise<{ - fallback?: DesktopRootIdentityFallback; - pids: number[]; -}> { - const processes = await listProcessSnapshots(); - const stampedRootPids = processes - .filter((processInfo) => - [SIDECAR_SOURCES.TOOLS_PACK, SIDECAR_SOURCES.PACKAGED].some((source) => - matchesStampedProcess( - processInfo, - { mode: SIDECAR_MODES.RUNTIME, namespace: config.namespace, source }, - OPEN_DESIGN_SIDECAR_CONTRACT, - ) - ), - ) - .map((processInfo) => processInfo.pid); - const identity = await resolveDesktopRootIdentityFallback(config); - const pids = collectProcessTreePids(processes, [ - ...stampedRootPids, - identity.rootPid, - ]); - return { fallback: identity.fallback, pids }; -} - -async function waitForNoManagedDesktopProcesses( - config: ToolPackConfig, - timeoutMs = 6000, -): Promise<{ fallback?: DesktopRootIdentityFallback; pids: number[] }> { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const current = await findManagedDesktopProcessTree(config); - if (current.pids.length === 0) return current; - await new Promise((resolveWait) => setTimeout(resolveWait, 150)); - } - return await findManagedDesktopProcessTree(config); -} - export async function stopPackedMacApp(config: ToolPackConfig): Promise { - const stamp = desktopStamp(config); - const before = await findManagedDesktopProcessTree(config); - let gracefulRequested = false; - - try { - await requestJsonIpc(stamp.ipc, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 }); - gracefulRequested = true; - } catch { - gracefulRequested = false; - } - - const remainingAfterGraceful = gracefulRequested ? await waitForNoManagedDesktopProcesses(config) : before; - if (remainingAfterGraceful.pids.length === 0) { - const unmanaged = !gracefulRequested && before.pids.length === 0 && isUnmanagedDesktopFallback(before.fallback); - if (!unmanaged) { - await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined); - } - return { - ...(before.fallback == null ? {} : { fallback: before.fallback }), - gracefulRequested, - namespace: config.namespace, - remainingPids: [], - status: unmanaged ? "unmanaged" : before.pids.length === 0 ? "not-running" : "stopped", - stoppedPids: before.pids, - }; - } - - const stopped = await stopProcesses(remainingAfterGraceful.pids); - if (stopped.remainingPids.length === 0) { - await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined); - } + const stopped = await stopSidecars(allPackagedSidecarStopRequests(config)); return { - ...(remainingAfterGraceful.fallback == null ? {} : { fallback: remainingAfterGraceful.fallback }), - gracefulRequested, + gracefulRequested: stopped.gracefulAccepted, namespace: config.namespace, remainingPids: stopped.remainingPids, - status: stopped.remainingPids.length === 0 ? "stopped" : "partial", + status: stopped.remainingPids.length > 0 + ? "partial" + : stopped.matchedPids.length > 0 || stopped.gracefulAccepted ? "stopped" : "not-running", stoppedPids: stopped.stoppedPids, }; } @@ -700,35 +418,35 @@ function resolveUpdateAction(value: string | undefined): DesktopUpdateAction | n } export async function inspectPackedMacApp(config: ToolPackConfig, options: { expr?: string; path?: string; updateAction?: string }): Promise { - const stamp = desktopStamp(config); - const status = await requestJsonIpc( - stamp.ipc, - { type: SIDECAR_MESSAGES.STATUS }, - { timeoutMs: 2000 }, - ).catch(() => null); + const active = await resolveReachableDesktop(config, 2000); + const stamp = active?.stamp ?? convergedDesktopStamp(config); + const status = active?.status ?? null; const updateAction = resolveUpdateAction(options.updateAction); return { ...(options.expr == null ? {} : { - eval: await requestJsonIpc( - stamp.ipc, - { input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL }, + eval: await invokeSidecar( + stamp, + SIDECAR_MESSAGES.EVAL, + { expression: options.expr }, { timeoutMs: 5000 }, ), }), launcher: await readToolPackLauncherRuntimeSnapshot(config), updateCache: await readToolPackUpdateCacheLifecycleSnapshot(config), ...(options.path == null ? {} : { - screenshot: await requestJsonIpc( - stamp.ipc, - { input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT }, + screenshot: await invokeSidecar( + stamp, + SIDECAR_MESSAGES.SCREENSHOT, + { path: options.path }, { timeoutMs: 10000 }, ), }), ...(updateAction == null ? {} : { - update: await requestJsonIpc( - stamp.ipc, - { input: { action: updateAction }, type: SIDECAR_MESSAGES.UPDATE }, + update: await invokeSidecar( + stamp, + SIDECAR_MESSAGES.UPDATE, + { action: updateAction }, { timeoutMs: UPDATE_ACTION_TIMEOUT_MS }, ), }), @@ -739,6 +457,7 @@ export async function inspectPackedMacApp(config: ToolPackConfig, options: { exp export async function uninstallPackedMacApp(config: ToolPackConfig): Promise { const paths = resolveMacPaths(config); const stop = await stopPackedMacApp(config); + assertMacStopComplete(stop, "uninstall"); const removed = await pathExists(paths.installedAppPath); await rm(paths.installedAppPath, { force: true, recursive: true }); @@ -752,21 +471,33 @@ export async function uninstallPackedMacApp(config: ToolPackConfig): Promise { const paths = resolveMacPaths(config); + const launcher = resolveToolPackLauncherLayout(config); const stop = await stopPackedMacApp(config); + assertMacStopComplete(stop, "cleanup"); const detachedMount = await detachMount(paths.mountPoint); const removedOutputRoot = await pathExists(config.roots.output.namespaceRoot); const removedRuntimeNamespaceRoot = await pathExists(config.roots.runtime.namespaceRoot); + const removedLauncherNamespaceRoot = await pathExists(launcher.paths.namespaceRoot); await rm(config.roots.output.namespaceRoot, { force: true, recursive: true }); await rm(config.roots.runtime.namespaceRoot, { force: true, recursive: true }); + await rm(launcher.paths.namespaceRoot, { force: true, recursive: true }); return { detachedMount, namespace: config.namespace, outputRoot: config.roots.output.namespaceRoot, + removedLauncherNamespaceRoot, removedOutputRoot, removedRuntimeNamespaceRoot, runtimeNamespaceRoot: config.roots.runtime.namespaceRoot, stop, }; } + +function assertMacStopComplete(stop: MacStopResult, operation: string): void { + if (stop.remainingPids.length === 0) return; + throw new Error( + `cannot ${operation} packaged namespace while sidecar processes remain: ${stop.remainingPids.join(", ")}`, + ); +} diff --git a/tools/pack/src/mac/paths.ts b/tools/pack/src/mac/paths.ts index b8ed6a1ff3..4d45cbc7e0 100644 --- a/tools/pack/src/mac/paths.ts +++ b/tools/pack/src/mac/paths.ts @@ -85,7 +85,3 @@ export function resolveMacPaths(config: ToolPackConfig): MacPaths { export function desktopLogPath(config: ToolPackConfig): string { return join(config.roots.runtime.namespaceRoot, "logs", APP_KEYS.DESKTOP, "latest.log"); } - -export function desktopIdentityPath(config: ToolPackConfig): string { - return join(config.roots.runtime.namespaceRoot, "runtime", "desktop-root.json"); -} diff --git a/tools/pack/src/mac/prebundle.ts b/tools/pack/src/mac/prebundle.ts index 109cce402e..fd6ed24f69 100644 --- a/tools/pack/src/mac/prebundle.ts +++ b/tools/pack/src/mac/prebundle.ts @@ -45,14 +45,13 @@ export const MAC_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES = [ "@open-design/desktop", "@open-design/launcher-proto", "@open-design/packaged", - "@open-design/sidecar", "@open-design/sidecar-proto", "@open-design/web", ] as const; export const MAC_PREBUNDLE_POLICIES = { packagedMain: { - externals: ["electron"], + externals: ["@open-design/sidecar", "electron"], forbiddenInputs: [ "/apps/web/", "/node_modules/@open-design/web/", @@ -64,7 +63,7 @@ export const MAC_PREBUNDLE_POLICIES = { label: "packaged main", }, daemonCli: { - externals: ["@ffmpeg-installer/ffmpeg", "better-sqlite3", "blake3-wasm", "fsevents", "hyperframes", "node-pty"], + externals: ["@ffmpeg-installer/ffmpeg", "@open-design/sidecar", "better-sqlite3", "blake3-wasm", "fsevents", "hyperframes", "node-pty"], forbiddenInputs: [ "/node_modules/@open-design/daemon/", "/node_modules/@ffmpeg-installer/ffmpeg/", @@ -82,7 +81,7 @@ export const MAC_PREBUNDLE_POLICIES = { label: "daemon cli", }, daemonSidecar: { - externals: ["@ffmpeg-installer/ffmpeg", "better-sqlite3", "blake3-wasm", "fsevents", "hyperframes", "node-pty"], + externals: ["@ffmpeg-installer/ffmpeg", "@open-design/sidecar", "better-sqlite3", "blake3-wasm", "fsevents", "hyperframes", "node-pty"], forbiddenInputs: [ "/node_modules/@open-design/daemon/", "/node_modules/@ffmpeg-installer/ffmpeg/", @@ -100,7 +99,7 @@ export const MAC_PREBUNDLE_POLICIES = { label: "daemon sidecar", }, webSidecar: { - externals: [], + externals: ["@open-design/sidecar"], forbiddenInputs: [ "/node_modules/next/", "/node_modules/openai/", diff --git a/tools/pack/src/mac/types.ts b/tools/pack/src/mac/types.ts index be11e18291..dd63461871 100644 --- a/tools/pack/src/mac/types.ts +++ b/tools/pack/src/mac/types.ts @@ -1,4 +1,4 @@ -import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot, DesktopUpdateResult, SidecarStamp } from "@open-design/sidecar-proto"; +import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot, DesktopUpdateResult } from "@open-design/sidecar-proto"; import type { CacheReport } from "../cache/index.js"; import type { ToolPackBuildOutput, ToolPackConfig } from "../config/index.js"; import type { ToolPackLauncherRuntimeSnapshot } from "../launcher/runtime-snapshot.js"; @@ -90,32 +90,11 @@ export type MacInspectResult = { update?: DesktopUpdateResult; }; -export type DesktopRootIdentityMarker = { - appPath: string; - executablePath: string; - logPath: string; - namespaceRoot: string; - pid: number; - ppid: number; - stamp: SidecarStamp; - startedAt: string; - updatedAt: string; - version: 1; -}; - -export type DesktopRootIdentityFallback = { - marker?: Partial; - markerPath: string; - processCommand?: string; - reason: string; -}; - export type MacStopResult = { - fallback?: DesktopRootIdentityFallback; gracefulRequested: boolean; namespace: string; remainingPids: number[]; - status: "not-running" | "partial" | "stopped" | "unmanaged"; + status: "not-running" | "partial" | "stopped"; stoppedPids: number[]; }; @@ -138,6 +117,7 @@ export type MacCleanupResult = { detachedMount: boolean; namespace: string; outputRoot: string; + removedLauncherNamespaceRoot: boolean; removedOutputRoot: boolean; removedRuntimeNamespaceRoot: boolean; runtimeNamespaceRoot: string; diff --git a/tools/pack/src/mac/workspace.ts b/tools/pack/src/mac/workspace.ts index d925bb9799..20e0347997 100644 --- a/tools/pack/src/mac/workspace.ts +++ b/tools/pack/src/mac/workspace.ts @@ -1,52 +1,12 @@ -import { readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - import type { ToolPackCache } from "../cache/index.js"; import type { ToolPackConfig } from "../config/index.js"; -import { processWebSourcemaps } from "../web-sourcemaps.js"; import { ensureWorkspaceBuildArtifacts } from "../workspace-build.js"; import { runPnpm } from "./commands.js"; -async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise { - const webNextEnvPath = join(config.workspaceRoot, "apps", "web", "next-env.d.ts"); - const previousWebNextEnv = await readFile(webNextEnvPath, "utf8").catch(() => null); - - await runPnpm(config, ["--filter", "@open-design/contracts", "build"]); - await runPnpm(config, ["--filter", "@open-design/registry-protocol", "build"]); - await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]); - await runPnpm(config, ["--filter", "@open-design/launcher-proto", "build"]); - await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]); - await runPnpm(config, ["--filter", "@open-design/platform", "build"]); - await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]); - await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]); - await runPnpm(config, ["--filter", "@open-design/download", "build"]); - await runPnpm(config, ["--filter", "@open-design/host", "build"]); - await runPnpm(config, ["--filter", "@open-design/diagnostics", "build"]); - await runPnpm(config, ["--filter", "@open-design/dsh-runtime", "build"]); - await runPnpm(config, ["--filter", "@open-design/components", "build"]); - await runPnpm(config, ["--filter", "@open-design/daemon", "build"]); - try { - await runPnpm(config, ["--filter", "@open-design/web", "build"], { - OD_WEB_OUTPUT_MODE: config.webOutputMode, - }); - await runPnpm(config, ["--filter", "@open-design/web", "build:sidecar"]); - // Inject chunk IDs + upload browser sourcemaps to PostHog, then strip - // .map files. Runs before any packaging step copies the web output into - // the Electron resources so .map never ends up inside the .app bundle. - await processWebSourcemaps(config); - } finally { - if (previousWebNextEnv == null) { - await rm(webNextEnvPath, { force: true }); - } else { - await writeFile(webNextEnvPath, previousWebNextEnv, "utf8"); - } - } - await runPnpm(config, ["--filter", "@open-design/desktop", "build"]); - await runPnpm(config, ["--filter", "@open-design/packaged", "build"]); -} - export async function ensureMacWorkspaceBuild(config: ToolPackConfig, cache: ToolPackCache): Promise { - await ensureWorkspaceBuildArtifacts(config, cache, async () => { - await buildWorkspaceArtifacts(config); - }); + await ensureWorkspaceBuildArtifacts( + config, + cache, + async (args, extraEnv) => await runPnpm(config, args, extraEnv), + ); } diff --git a/tools/pack/src/win/app.ts b/tools/pack/src/win/app.ts index 5dc4d51145..527e8bca66 100644 --- a/tools/pack/src/win/app.ts +++ b/tools/pack/src/win/app.ts @@ -31,7 +31,6 @@ import { shouldInstallInternalPackageForWinPrebundle, shouldUseWinStandalonePrebundle, } from "./prebundle.js"; -import { processWebSourcemaps } from "../web-sourcemaps.js"; import { ensureWorkspaceBuildArtifacts } from "../workspace-build.js"; import { ELECTRON_BUILDER_BUILD_DEPENDENCIES_FROM_SOURCE, @@ -127,44 +126,12 @@ async function validateWinPackagedAppRuntime(appRoot: string): Promise { - const webNextEnvPath = join(config.workspaceRoot, "apps", "web", "next-env.d.ts"); - const previousWebNextEnv = await readFile(webNextEnvPath, "utf8").catch(() => null); - - await runPnpm(config, ["--filter", "@open-design/release", "build"]); - await runPnpm(config, ["--filter", "@open-design/contracts", "build"]); - await runPnpm(config, ["--filter", "@open-design/registry-protocol", "build"]); - await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]); - await runPnpm(config, ["--filter", "@open-design/launcher-proto", "build"]); - await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]); - await runPnpm(config, ["--filter", "@open-design/platform", "build"]); - await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]); - await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]); - await runPnpm(config, ["--filter", "@open-design/download", "build"]); - await runPnpm(config, ["--filter", "@open-design/host", "build"]); - await runPnpm(config, ["--filter", "@open-design/diagnostics", "build"]); - await runPnpm(config, ["--filter", "@open-design/dsh-runtime", "build"]); - await runPnpm(config, ["--filter", "@open-design/components", "build"]); - await runPnpm(config, ["--filter", "@open-design/daemon", "build"]); - try { - await runPnpm(config, ["--filter", "@open-design/web", "build"], { OD_WEB_OUTPUT_MODE: config.webOutputMode }); - await runPnpm(config, ["--filter", "@open-design/web", "build:sidecar"]); - // Inject chunk IDs + upload browser sourcemaps to PostHog, then strip - // .map files before any packaging step copies the web output into the - // Electron resources. See `tools/pack/src/web-sourcemaps.ts`. - await processWebSourcemaps(config); - } finally { - if (previousWebNextEnv == null) await rm(webNextEnvPath, { force: true }); - else await writeFile(webNextEnvPath, previousWebNextEnv, "utf8"); - } - await runPnpm(config, ["--filter", "@open-design/desktop", "build"]); - await runPnpm(config, ["--filter", "@open-design/packaged", "build"]); -} - export async function ensureWinWorkspaceBuild(config: ToolPackConfig, cache: ToolPackCache): Promise { - return ensureWorkspaceBuildArtifacts(config, cache, async () => { - await buildWorkspaceArtifacts(config); - }); + return ensureWorkspaceBuildArtifacts( + config, + cache, + async (args, extraEnv) => await runPnpm(config, args, extraEnv), + ); } export async function createWorkspaceTarballsCacheKey( @@ -430,7 +397,7 @@ export async function createWinPackagedAppCacheKey( platform: "win32", prebundle: shouldUseWinStandalonePrebundle(config.webOutputMode), runtimeDependencies: shouldUseWinStandalonePrebundle(config.webOutputMode) ? runtimeDependencies : null, - schemaVersion: 4, + schemaVersion: 6, tarballsKey, webOutputMode: config.webOutputMode, }); diff --git a/tools/pack/src/win/constants.ts b/tools/pack/src/win/constants.ts index 3157219f1d..cc1525dffd 100644 --- a/tools/pack/src/win/constants.ts +++ b/tools/pack/src/win/constants.ts @@ -36,8 +36,8 @@ export const INTERNAL_PACKAGES = [ { directory: "packages/registry-protocol", name: "@open-design/registry-protocol" }, { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/launcher-proto", name: "@open-design/launcher-proto" }, - { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/platform", name: "@open-design/platform" }, + { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/download", name: "@open-design/download" }, { directory: "packages/host", name: "@open-design/host" }, { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, diff --git a/tools/pack/src/win/lifecycle.ts b/tools/pack/src/win/lifecycle.ts index dac2b2211b..647a77e329 100644 --- a/tools/pack/src/win/lifecycle.ts +++ b/tools/pack/src/win/lifecycle.ts @@ -14,21 +14,21 @@ import { type DesktopStatusSnapshot, type DesktopUpdateAction, type DesktopUpdateResult, - type SidecarStamp, type WebStatusSnapshot, } from "@open-design/sidecar-proto"; -import { createSidecarLaunchEnv, requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; import { - collectProcessTreePids, - createProcessStampArgs, - listProcessSnapshots, - matchesStampedProcess, - readLogTail, - spawnBackgroundProcess, - stopProcesses, -} from "@open-design/platform"; + convergeSidecarLaunch, + findSidecarProcesses, + getSidecarStatus, + invokeSidecar, + stopSidecars, + withSidecarLifecycleLock, + type SidecarStamp, +} from "@open-design/sidecar"; +import { readLogTail } from "@open-design/platform"; import type { ToolPackConfig } from "../config/index.js"; +import { allPackagedSidecarStopRequests, toolPackSidecarStamp } from "../config/sidecar-stamps.js"; import { resolveToolPackLauncherLayout } from "../launcher/layout.js"; import { readToolPackLauncherRuntimeSnapshot } from "../launcher/runtime-snapshot.js"; import { readToolPackUpdateCacheLifecycleSnapshot } from "../updates/cache-lifecycle-snapshot.js"; @@ -63,39 +63,46 @@ import type { } from "./types.js"; const PACKAGED_CONFIG_PATH_ENV = "OD_PACKAGED_CONFIG_PATH"; +const PACKAGED_COLD_START_CONVERGENCE_TIMEOUT_MS = 90_000; const UPDATE_ACTION_TIMEOUT_MS = 10 * 60 * 1000; -function desktopStamp(config: ToolPackConfig): SidecarStamp { - return { - app: APP_KEYS.DESKTOP, - ipc: resolveAppIpcPath({ app: APP_KEYS.DESKTOP, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: config.namespace }), - mode: SIDECAR_MODES.RUNTIME, - namespace: config.namespace, - source: SIDECAR_SOURCES.TOOLS_PACK, +function appStamp( + config: ToolPackConfig, + app: SidecarStamp["app"] = APP_KEYS.DESKTOP, + source: typeof SIDECAR_SOURCES.TOOLS_PACK | typeof SIDECAR_SOURCES.PACKAGED = SIDECAR_SOURCES.TOOLS_PACK, + mode: SidecarStamp["mode"] = SIDECAR_MODES.RUNTIME, +): SidecarStamp & { source: typeof SIDECAR_SOURCES.TOOLS_PACK | typeof SIDECAR_SOURCES.PACKAGED } { + return toolPackSidecarStamp(config, { app, mode, source }) as SidecarStamp & { + source: typeof SIDECAR_SOURCES.TOOLS_PACK | typeof SIDECAR_SOURCES.PACKAGED; }; } -function appIpcPath(config: ToolPackConfig, app: SidecarStamp["app"]): string { - return resolveAppIpcPath({ app, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: config.namespace }); -} - function desktopLogPath(config: ToolPackConfig): string { return join(config.roots.runtime.namespaceRoot, "logs", APP_KEYS.DESKTOP, "latest.log"); } -function desktopIdentityPath(config: ToolPackConfig): string { - return join(config.roots.runtime.namespaceRoot, "runtime", "desktop-root.json"); +type DesktopEndpoint = { + snapshot: { error?: string; status: DesktopStatusSnapshot | null }; + stamp: SidecarStamp & { source: typeof SIDECAR_SOURCES.TOOLS_PACK | typeof SIDECAR_SOURCES.PACKAGED }; +}; + +async function resolveDesktopEndpoint(config: ToolPackConfig, timeoutMs: number): Promise { + const probes = await Promise.all([ + SIDECAR_SOURCES.TOOLS_PACK, + SIDECAR_SOURCES.PACKAGED, + ].map(async (source): Promise => { + const stamp = appStamp(config, APP_KEYS.DESKTOP, source); + return { snapshot: await requestStatusSnapshot(stamp, timeoutMs), stamp }; + })); + return probes.find(({ snapshot }) => snapshot.status != null) ?? probes[0]!; } async function waitForDesktopStatus(config: ToolPackConfig, timeoutMs = 45_000): Promise { - const stamp = desktopStamp(config); const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { - try { - return await requestJsonIpc(stamp.ipc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 1000 }); - } catch { - await new Promise((resolveWait) => setTimeout(resolveWait, 200)); - } + const active = await resolveDesktopEndpoint(config, 1000); + if (active.snapshot.status != null) return active.snapshot.status; + await new Promise((resolveWait) => setTimeout(resolveWait, 200)); } return null; } @@ -276,51 +283,53 @@ async function resolveStartTarget(config: ToolPackConfig): Promise<{ configPath: } export async function startPackedWinApp(config: ToolPackConfig, options: { waitForStatus?: boolean } = {}): Promise { + const lifecycleStamps = allPackagedSidecarStopRequests(config).map(({ stamp }) => stamp); + return await withSidecarLifecycleLock(lifecycleStamps, async () => await startPackedWinAppLocked(config, options)); +} + +async function startPackedWinAppLocked(config: ToolPackConfig, options: { waitForStatus?: boolean }): Promise { const target = await resolveStartTarget(config); - const stamp = desktopStamp(config); + const stamp = appStamp(config); const logPath = desktopLogPath(config); await mkdir(dirname(logPath), { recursive: true }); await writeFile(logPath, "", "utf8"); - const spawned = await spawnBackgroundProcess({ - args: createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT), + const convergence = await convergeSidecarLaunch({ + args: [], command: target.executablePath, cwd: dirname(target.executablePath), - env: createSidecarLaunchEnv({ - base: join(config.roots.runtime.namespaceRoot, "runtime"), - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - extraEnv: { + env: { ...process.env, [DESKTOP_LOG_ECHO_ENV]: "0", ...(target.configPath == null ? {} : { [PACKAGED_CONFIG_PATH_ENV]: target.configPath }), - }, - stamp, - }), + }, logFd: null, + resources: { + dataRoot: join(config.roots.runtime.namespaceRoot, "data"), + ownerPid: null, + port: 0, + runtimeRoot: join(config.roots.runtime.namespaceRoot, "runtime"), + }, + stamp, + }, { + ownerStamps: [stamp, appStamp(config, APP_KEYS.DESKTOP, SIDECAR_SOURCES.PACKAGED)], + timeoutMs: PACKAGED_COLD_START_CONVERGENCE_TIMEOUT_MS, }); return { executablePath: target.executablePath, logPath, namespace: config.namespace, - pid: spawned.pid, + pid: convergence.description.resources.pid, source: target.source, status: options.waitForStatus === false ? null : await waitForDesktopStatus(config), }; } async function findManagedDesktopProcessTree(config: ToolPackConfig): Promise { - const processes = await listProcessSnapshots(); - const stampedRootPids = processes - .filter((processInfo) => - [SIDECAR_SOURCES.TOOLS_PACK, SIDECAR_SOURCES.PACKAGED].some((source) => - matchesStampedProcess( - processInfo, - { mode: SIDECAR_MODES.RUNTIME, namespace: config.namespace, source }, - OPEN_DESIGN_SIDECAR_CONTRACT, - ) - ), - ) - .map((processInfo) => processInfo.pid); - return collectProcessTreePids(processes, stampedRootPids); + const processes = await Promise.all([ + findSidecarProcesses(appStamp(config, APP_KEYS.DESKTOP, SIDECAR_SOURCES.TOOLS_PACK)), + findSidecarProcesses(appStamp(config, APP_KEYS.DESKTOP, SIDECAR_SOURCES.PACKAGED)), + ]); + return processes.flat().map(({ pid }) => pid); } async function waitForNoManagedDesktopProcesses(config: ToolPackConfig, timeoutMs = 6000): Promise { @@ -334,29 +343,19 @@ async function waitForNoManagedDesktopProcesses(config: ToolPackConfig, timeoutM } export async function stopPackedWinApp(config: ToolPackConfig): Promise { - const stamp = desktopStamp(config); - const before = await findManagedDesktopProcessTree(config); - let gracefulRequested = false; - try { - await requestJsonIpc(stamp.ipc, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 }); - gracefulRequested = true; - } catch { - gracefulRequested = false; - } - const remainingAfterGraceful = gracefulRequested ? await waitForNoManagedDesktopProcesses(config) : before; - if (remainingAfterGraceful.length === 0) { - await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined); - return { gracefulRequested, namespace: config.namespace, remainingPids: [], status: before.length === 0 ? "not-running" : "stopped", stoppedPids: before }; - } - const stopped = await stopProcesses(remainingAfterGraceful); - if (stopped.remainingPids.length === 0) await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined); - return { - gracefulRequested, - namespace: config.namespace, - remainingPids: stopped.remainingPids, - status: stopped.remainingPids.length === 0 ? "stopped" : "partial", - stoppedPids: stopped.stoppedPids, - }; + const requests = allPackagedSidecarStopRequests(config); + return await withSidecarLifecycleLock(requests.map(({ stamp }) => stamp), async () => { + const stopped = await stopSidecars(requests); + return { + gracefulRequested: stopped.gracefulAccepted, + namespace: config.namespace, + remainingPids: stopped.remainingPids, + status: stopped.remainingPids.length > 0 + ? "partial" + : stopped.matchedPids.length > 0 || stopped.gracefulAccepted ? "stopped" : "not-running", + stoppedPids: stopped.stoppedPids, + }; + }); } export async function readPackedWinLogs(config: ToolPackConfig) { @@ -381,6 +380,7 @@ export async function uninstallPackedWinApp(config: ToolPackConfig): Promise resolveWinRegisteredPaths(config, paths)); const stop = await measureLifecycleStep(lifecycleTimings, "stop", async () => stopPackedWinApp(config)); + assertWinStopComplete(stop, "uninstall"); if (await pathExists(registeredPaths.uninstallerPath)) { await measureLifecycleStep(lifecycleTimings, "nsis uninstall", async () => runTimed(paths.uninstallTimingPath, "uninstall", async () => { await invokeNsis(paths, registeredPaths.uninstallerPath, config.silent ? ["/S"] : [], "uninstall"); @@ -427,10 +427,10 @@ export async function cleanupPackedWinNamespace(config: ToolPackConfig): Promise const launcher = resolveToolPackLauncherLayout(config); const registeredPaths = await resolveWinRegisteredPaths(config, paths); const removalPlan = await createWinRemovalPlan(config); - if (await pathExists(registeredPaths.uninstallerPath)) { - await uninstallPackedWinApp(config); - } - const stop = await stopPackedWinApp(config); + const stop = await pathExists(registeredPaths.uninstallerPath) + ? (await uninstallPackedWinApp(config)).stop + : await stopPackedWinApp(config); + assertWinStopComplete(stop, "cleanup"); const removedOutputRoot = await pathExists(config.roots.output.namespaceRoot); const removedRuntimeNamespaceRoot = await pathExists(config.roots.runtime.namespaceRoot); const removedLauncherNamespaceRoot = await pathExists(launcher.paths.namespaceRoot); @@ -456,6 +456,13 @@ export async function cleanupPackedWinNamespace(config: ToolPackConfig): Promise }; } +function assertWinStopComplete(stop: WinStopResult, operation: string): void { + if (stop.remainingPids.length === 0) return; + throw new Error( + `cannot ${operation} packaged namespace while sidecar processes remain: ${stop.remainingPids.join(", ")}`, + ); +} + export async function listPackedWinNamespaces(config: ToolPackConfig): Promise { const paths = resolveWinPaths(config); const registeredPaths = await resolveWinRegisteredPaths(config, paths); @@ -517,13 +524,14 @@ function resolveUpdateAction(value: string | undefined): DesktopUpdateAction | n } async function requestDesktopEval( - ipc: string, + stamp: SidecarStamp, expression: string, ): Promise { try { - return await requestJsonIpc( - ipc, - { input: { expression }, type: SIDECAR_MESSAGES.EVAL }, + return await invokeSidecar( + stamp, + SIDECAR_MESSAGES.EVAL, + { expression }, { timeoutMs: 5000 }, ); } catch (error) { @@ -534,9 +542,9 @@ async function requestDesktopEval( } } -async function requestStatusSnapshot(ipc: string): Promise<{ error?: string; status: T | null }> { +async function requestStatusSnapshot(stamp: SidecarStamp, timeoutMs = 2000): Promise<{ error?: string; status: T | null }> { try { - return { status: await requestJsonIpc(ipc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 2000 }) }; + return { status: await getSidecarStatus(stamp, { timeoutMs }) }; } catch (error) { return { error: error instanceof Error ? error.message : String(error), @@ -558,16 +566,16 @@ async function delay(ms: number): Promise { async function pollWinInspectStatus(config: ToolPackConfig, count: number, intervalMs: number): Promise { const samples: WinInspectStatusPollSample[] = []; - const desktopIpc = desktopStamp(config).ipc; - const daemonIpc = appIpcPath(config, APP_KEYS.DAEMON); - const webIpc = appIpcPath(config, APP_KEYS.WEB); + const desktop = (await resolveDesktopEndpoint(config, 2000)).stamp; + const daemon = appStamp(config, APP_KEYS.DAEMON, desktop.source); + const web = appStamp(config, APP_KEYS.WEB, desktop.source); for (let attempt = 1; attempt <= count; attempt += 1) { const startedAtMs = Date.now(); const startedAt = new Date(startedAtMs).toISOString(); const [desktopSnapshot, daemonSnapshot, webSnapshot] = await Promise.all([ - requestStatusSnapshot(desktopIpc), - requestStatusSnapshot(daemonIpc), - requestStatusSnapshot(webIpc), + requestStatusSnapshot(desktop), + requestStatusSnapshot(daemon), + requestStatusSnapshot(web), ]); samples.push({ attempt, @@ -589,12 +597,13 @@ export async function inspectPackedWinApp( config: ToolPackConfig, options: { expr?: string; path?: string; statusPollCount?: string | number; statusPollIntervalMs?: string | number; updateAction?: string }, ): Promise { - const stamp = desktopStamp(config); - const [desktopSnapshot, daemonSnapshot, webSnapshot] = await Promise.all([ - requestStatusSnapshot(stamp.ipc), - requestStatusSnapshot(appIpcPath(config, APP_KEYS.DAEMON)), - requestStatusSnapshot(appIpcPath(config, APP_KEYS.WEB)), + const desktop = await resolveDesktopEndpoint(config, 2000); + const stamp = desktop.stamp; + const [daemonSnapshot, webSnapshot] = await Promise.all([ + requestStatusSnapshot(appStamp(config, APP_KEYS.DAEMON, stamp.source)), + requestStatusSnapshot(appStamp(config, APP_KEYS.WEB, stamp.source)), ]); + const desktopSnapshot = desktop.snapshot; const updateAction = resolveUpdateAction(options.updateAction); const statusPollCount = resolveOptionalPositiveInteger(options.statusPollCount, "--status-poll-count"); const statusPollIntervalMs = resolveOptionalPositiveInteger(options.statusPollIntervalMs, "--status-poll-interval-ms") ?? 500; @@ -604,7 +613,7 @@ export async function inspectPackedWinApp( daemonStatus: daemonSnapshot.status, ...(daemonSnapshot.error == null ? {} : { daemonStatusError: daemonSnapshot.error }), ...(options.expr == null ? {} : { - eval: await requestDesktopEval(stamp.ipc, options.expr), + eval: await requestDesktopEval(stamp, options.expr), }), launcher, launcherSource: { @@ -619,16 +628,18 @@ export async function inspectPackedWinApp( root: updateCache.updateRoot, }, ...(options.path == null ? {} : { - screenshot: await requestJsonIpc( - stamp.ipc, - { input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT }, + screenshot: await invokeSidecar( + stamp, + SIDECAR_MESSAGES.SCREENSHOT, + { path: options.path }, { timeoutMs: 10000 }, ), }), ...(updateAction == null ? {} : { - update: await requestJsonIpc( - stamp.ipc, - { input: { action: updateAction }, type: SIDECAR_MESSAGES.UPDATE }, + update: await invokeSidecar( + stamp, + SIDECAR_MESSAGES.UPDATE, + { action: updateAction }, { timeoutMs: UPDATE_ACTION_TIMEOUT_MS }, ), }), diff --git a/tools/pack/src/win/prebundle.ts b/tools/pack/src/win/prebundle.ts index e7c8dcc7fe..087d80ba52 100644 --- a/tools/pack/src/win/prebundle.ts +++ b/tools/pack/src/win/prebundle.ts @@ -36,14 +36,13 @@ export const WIN_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES = [ "@open-design/desktop", "@open-design/launcher-proto", "@open-design/packaged", - "@open-design/sidecar", "@open-design/sidecar-proto", "@open-design/web", ] as const; export const WIN_PREBUNDLE_POLICIES = { packagedMain: { - externals: ["electron"], + externals: ["@open-design/sidecar", "electron"], forbiddenInputs: [ "/apps/web/", "/node_modules/@open-design/web/", @@ -55,7 +54,7 @@ export const WIN_PREBUNDLE_POLICIES = { label: "packaged main", }, daemonCli: { - externals: ["@ffmpeg-installer/ffmpeg", "better-sqlite3", "blake3-wasm", "hyperframes", "node-pty"], + externals: ["@ffmpeg-installer/ffmpeg", "@open-design/sidecar", "better-sqlite3", "blake3-wasm", "hyperframes", "node-pty"], forbiddenInputs: [ "/node_modules/@open-design/daemon/", "/node_modules/@ffmpeg-installer/ffmpeg/", @@ -72,7 +71,7 @@ export const WIN_PREBUNDLE_POLICIES = { label: "daemon cli", }, daemonSidecar: { - externals: ["@ffmpeg-installer/ffmpeg", "better-sqlite3", "blake3-wasm", "hyperframes", "node-pty"], + externals: ["@ffmpeg-installer/ffmpeg", "@open-design/sidecar", "better-sqlite3", "blake3-wasm", "hyperframes", "node-pty"], forbiddenInputs: [ "/node_modules/@open-design/daemon/", "/node_modules/@ffmpeg-installer/ffmpeg/", @@ -89,7 +88,7 @@ export const WIN_PREBUNDLE_POLICIES = { label: "daemon sidecar", }, webSidecar: { - externals: [], + externals: ["@open-design/sidecar"], forbiddenInputs: [ "/node_modules/next/", "/node_modules/openai/", diff --git a/tools/pack/src/workspace-build.ts b/tools/pack/src/workspace-build.ts index 2b1f62d652..678a6cb9f0 100644 --- a/tools/pack/src/workspace-build.ts +++ b/tools/pack/src/workspace-build.ts @@ -1,21 +1,22 @@ import { createHash } from "node:crypto"; -import { access, cp, lstat, mkdir, readdir, readFile, stat, symlink, unlink, writeFile } from "node:fs/promises"; +import { access, cp, lstat, mkdir, readdir, readFile, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; import { hashJson, hashPath, ToolPackCache } from "./cache/index.js"; import type { ToolPackConfig } from "./config/index.js"; import { hashPackageSourcePath } from "./package-source-hash.js"; import { readRuntimeAppVersion, versionFamilyForAppVersion } from "./versioning/index.js"; +import { processWebSourcemaps } from "./web-sourcemaps.js"; -const WORKSPACE_BUILD_PACKAGES = [ +export const WORKSPACE_BUILD_PACKAGES = [ { directory: "packages/release", name: "@open-design/release" }, { directory: "packages/components", name: "@open-design/components" }, { directory: "packages/contracts", name: "@open-design/contracts" }, { directory: "packages/registry-protocol", name: "@open-design/registry-protocol" }, { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/launcher-proto", name: "@open-design/launcher-proto" }, - { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/platform", name: "@open-design/platform" }, + { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/download", name: "@open-design/download" }, { directory: "packages/host", name: "@open-design/host" }, { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, @@ -28,28 +29,50 @@ const WORKSPACE_BUILD_PACKAGES = [ { directory: "apps/packaged", name: "@open-design/packaged" }, ] as const; -const BUILD_COMMANDS = [ - { args: ["--filter", "@open-design/release", "build"] }, - { args: ["--filter", "@open-design/components", "build"] }, - { args: ["--filter", "@open-design/contracts", "build"] }, - { args: ["--filter", "@open-design/registry-protocol", "build"] }, - { args: ["--filter", "@open-design/sidecar-proto", "build"] }, - { args: ["--filter", "@open-design/launcher-proto", "build"] }, - { args: ["--filter", "@open-design/sidecar", "build"] }, - { args: ["--filter", "@open-design/platform", "build"] }, - { args: ["--filter", "@open-design/download", "build"] }, - { args: ["--filter", "@open-design/host", "build"] }, - { args: ["--filter", "@open-design/agui-adapter", "build"] }, - { args: ["--filter", "@open-design/plugin-runtime", "build"] }, - { args: ["--filter", "@open-design/diagnostics", "build"] }, - { args: ["--filter", "@open-design/dsh-runtime", "build"] }, - { args: ["--filter", "@open-design/daemon", "build"] }, - { args: ["--filter", "@open-design/web", "build"], env: ["OD_WEB_OUTPUT_MODE"] }, - { args: ["--filter", "@open-design/web", "build:sidecar"] }, - { args: ["--filter", "@open-design/desktop", "build"] }, - { args: ["--filter", "@open-design/packaged", "build"] }, +export const WORKSPACE_BUILD_COMMANDS = [ + { + args: [ + "--filter", "@open-design/dsh-runtime...", + "--workspace-concurrency=1", "--if-present", "run", "build", + ], + }, + { + args: [ + "--filter", "@open-design/packaged^...", + "--workspace-concurrency=1", "--if-present", "run", "build", + ], + env: ["OD_WEB_OUTPUT_MODE"], + }, + { args: ["--filter", "@open-design/web", "run", "build:sidecar"] }, + { args: ["--filter", "@open-design/packaged", "run", "build"] }, ] as const; +export const WORKSPACE_BUILD_CACHE_SCHEMA_VERSION = 10; + +export type WorkspaceBuildCacheKeyInputs = { + buildCommands: unknown; + node: string; + nodeVersion: string; + packageHashes: Readonly>; + packageManager: unknown; + platform: ToolPackConfig["platform"]; + pnpmLock: string; + pnpmWorkspace: string; + schemaVersion: number; + webOutputMode: ToolPackConfig["webOutputMode"]; +}; + +export function createWorkspaceBuildCacheKeyFromInputs(inputs: WorkspaceBuildCacheKeyInputs): string { + return hashJson(inputs); +} + +export type WorkspaceBuildRunner = ( + args: string[], + extraEnv?: NodeJS.ProcessEnv, +) => Promise; + +export type WorkspaceBuildMaterializer = (config: ToolPackConfig) => Promise; + type WorkspaceBuildMetadata = { builtAt: string; outputFiles: string[]; @@ -87,26 +110,54 @@ async function readPackageManager(workspaceRoot: string): Promise { return rootPackageJson.packageManager; } -async function createWorkspaceBuildCacheKey(config: ToolPackConfig): Promise { +export async function createWorkspaceBuildCacheKey(config: ToolPackConfig): Promise { const packageHashes: Record = {}; for (const packageInfo of WORKSPACE_BUILD_PACKAGES) { packageHashes[packageInfo.name] = await hashPackageSourcePath(join(config.workspaceRoot, packageInfo.directory)); } const nodeId = `${config.platform}.workspace-build`; - return hashJson({ - buildCommands: BUILD_COMMANDS, + return createWorkspaceBuildCacheKeyFromInputs({ + buildCommands: WORKSPACE_BUILD_COMMANDS, node: nodeId, nodeVersion: process.version, packageHashes, packageManager: await readPackageManager(config.workspaceRoot), platform: config.platform, pnpmLock: await hashPath(join(config.workspaceRoot, "pnpm-lock.yaml")), - schemaVersion: 8, + pnpmWorkspace: await hashPath(join(config.workspaceRoot, "pnpm-workspace.yaml")), + schemaVersion: WORKSPACE_BUILD_CACHE_SCHEMA_VERSION, webOutputMode: config.webOutputMode, }); } +/** + * Build the packaged workspace closure while leaving dependency ordering to + * pnpm's workspace graph. The explicit stages here are packaging stages, not a + * second dependency graph: DSH is an independently bundled resource, Web has + * one non-standard sidecar output, and packaged is the final assembly root. + */ +export async function runWorkspaceBuild( + config: ToolPackConfig, + runPnpm: WorkspaceBuildRunner, +): Promise { + const webNextEnvPath = join(config.workspaceRoot, "apps", "web", "next-env.d.ts"); + const previousWebNextEnv = await readFile(webNextEnvPath, "utf8").catch(() => null); + + try { + await runPnpm([...WORKSPACE_BUILD_COMMANDS[0].args]); + await runPnpm( + [...WORKSPACE_BUILD_COMMANDS[1].args], + { OD_WEB_OUTPUT_MODE: config.webOutputMode }, + ); + await runPnpm([...WORKSPACE_BUILD_COMMANDS[2].args]); + await runPnpm([...WORKSPACE_BUILD_COMMANDS[3].args]); + } finally { + if (previousWebNextEnv == null) await rm(webNextEnvPath, { force: true }); + else await writeFile(webNextEnvPath, previousWebNextEnv, "utf8"); + } +} + function workspaceBuildOutputFiles(config: ToolPackConfig): string[] { const webStandaloneServerCandidates = [ "apps/web/.next/standalone/apps/web/server.js", @@ -125,10 +176,10 @@ function workspaceBuildOutputFiles(config: ToolPackConfig): string[] { "packages/sidecar-proto/dist/index.d.ts", "packages/launcher-proto/dist/index.mjs", "packages/launcher-proto/dist/index.d.ts", - "packages/sidecar/dist/index.mjs", - "packages/sidecar/dist/index.d.ts", "packages/platform/dist/index.mjs", "packages/platform/dist/index.d.ts", + "packages/sidecar/dist/index.mjs", + "packages/sidecar/dist/index.d.ts", "packages/download/dist/index.mjs", "packages/download/dist/index.d.ts", "packages/host/dist/index.mjs", @@ -162,8 +213,8 @@ function workspaceBuildArtifacts(config: ToolPackConfig): WorkspaceBuildArtifact "packages/registry-protocol/dist", "packages/sidecar-proto/dist", "packages/launcher-proto/dist", - "packages/sidecar/dist", "packages/platform/dist", + "packages/sidecar/dist", "packages/download/dist", "packages/host/dist", "packages/agui-adapter/dist", @@ -225,6 +276,7 @@ async function stripBrokenSymlinks(rootPath: string): Promise { } const WEB_STANDALONE_ARTIFACT = "apps/web/.next/standalone"; +const WEB_STATIC_ARTIFACT = "apps/web/.next/static"; const WEB_STANDALONE_APP_NODE_MODULES = "apps/web/node_modules"; // Peer deps the web-standalone after-pack audit looks up through // `createRequire(server.js).resolve(/package.json)`. Next 16 @@ -310,7 +362,8 @@ async function missingWorkspaceBuildOutput(config: ToolPackConfig): Promise Promise, + runPnpm: WorkspaceBuildRunner, + materializeWebSourcemaps: WorkspaceBuildMaterializer = processWebSourcemaps, ): Promise { const key = await createWorkspaceBuildCacheKey(config); const nodeId = `${config.platform}.workspace-build`; @@ -329,7 +382,9 @@ export async function ensureWorkspaceBuildArtifacts( }); const materialize = artifacts.map((artifact) => ({ from: artifact.cachePath, - reuse: true, + // Sourcemap processing removes maps from the workspace copy. Restore the + // pristine cached static tree before every materialization-time pass. + reuse: artifact.workspacePath !== WEB_STATIC_ARTIFACT, reuseRequiredPaths: artifact.requiredPathGroups, to: join(config.workspaceRoot, artifact.workspacePath), })); @@ -342,7 +397,7 @@ export async function ensureWorkspaceBuildArtifacts( outputs: ["stamp.json", ...artifacts.map((artifact) => artifact.cachePath)], invalidate: async () => null, build: async ({ entryRoot }) => { - await build(); + await runWorkspaceBuild(config, runPnpm); const missingOutput = await missingWorkspaceBuildOutput(config); if (missingOutput != null) { throw new Error(`workspace build completed but output is missing: ${missingOutput}`); @@ -369,5 +424,9 @@ export async function ensureWorkspaceBuildArtifacts( }, seedFrom: versionFamilyAlias == null ? [] : [{ aliasKey: versionFamilyAlias, materialize }], }); + // Sourcemap injection/upload depends on release credentials and appVersion, + // and upload is a materialization side effect. Keep pristine JS/map pairs in + // the internal cache, then process the materialized copy on every hit/miss. + await materializeWebSourcemaps(config); return key; } diff --git a/tools/pack/tests/linux.test.ts b/tools/pack/tests/linux.test.ts index 9b99ed663c..5cb59545c4 100644 --- a/tools/pack/tests/linux.test.ts +++ b/tools/pack/tests/linux.test.ts @@ -6,22 +6,50 @@ import { dirname, join, resolve } from "node:path"; import { posix } from "node:path"; import { fileURLToPath } from "node:url"; -import { requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; +import { findSidecarProcesses, getSidecarStatus, invokeSidecar, stopSidecar } from "@open-design/sidecar"; import { APP_KEYS, OPEN_DESIGN_SIDECAR_CONTRACT, + SIDECAR_MESSAGES, SIDECAR_MODES, SIDECAR_SOURCES, } from "@open-design/sidecar-proto"; import { describe, expect, it, vi } from "vitest"; +const stopSidecarMock = vi.hoisted(() => vi.fn(async (_stamp?: unknown, _options?: unknown) => ({ + alreadyStopped: true, + forcedPids: [], + gracefulAccepted: false, + matchedPids: [], + remainingPids: [], + stoppedPids: [], +}))); +const stopSidecarsMock = vi.hoisted(() => vi.fn(async (requests: Array<{ options?: unknown; stamp: unknown }>) => { + const results = await Promise.all(requests.map(async ({ options, stamp }) => ({ + result: await stopSidecarMock(stamp, options), + stamp, + }))); + const stopped = results.map(({ result }) => result); + return { + alreadyStopped: stopped.every(({ alreadyStopped }) => alreadyStopped), + forcedPids: [], + gracefulAccepted: stopped.some(({ gracefulAccepted }) => gracefulAccepted), + matchedPids: [...new Set(stopped.flatMap(({ matchedPids }) => matchedPids))], + remainingPids: [...new Set(stopped.flatMap(({ remainingPids }) => remainingPids))], + results, + stoppedPids: [...new Set(stopped.flatMap(({ stoppedPids }) => stoppedPids))], + }; +})); + vi.mock("@open-design/sidecar", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - requestJsonIpc: vi.fn(async () => { - throw new Error("requestJsonIpc should not be called for invalid headless inspect options"); - }), + findSidecarProcesses: vi.fn(async () => []), + getSidecarStatus: vi.fn(async () => { throw new Error("status unavailable"); }), + invokeSidecar: vi.fn(async () => { throw new Error("invoke unavailable"); }), + stopSidecar: stopSidecarMock, + stopSidecars: stopSidecarsMock, }; }); @@ -343,11 +371,7 @@ describe("stopPackedLinuxHeadless", () => { const markerPath = join(namespaceRoot, "runtime", "desktop-root.json"); const stamp = { app: APP_KEYS.DESKTOP, - ipc: resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace, - }), + ipc: "/deprecated-derived-identity", mode: SIDECAR_MODES.RUNTIME, namespace, source: SIDECAR_SOURCES.PACKAGED, @@ -375,8 +399,7 @@ describe("stopPackedLinuxHeadless", () => { const result = await stopPackedLinuxHeadless(config); expect(result.status).toBe("not-running"); - expect(result.fallback?.reason).toBe("marker-not-found"); - expect(result.fallback?.markerPath).toBe(join(namespaceRoot, "runtime", "headless-root.json")); + expect(result.fallback).toBeUndefined(); } finally { await rm(root, { force: true, recursive: true }); } @@ -404,11 +427,7 @@ describe("stopPackedLinuxHeadless", () => { const markerPath = join(namespaceRoot, "runtime", "desktop-root.json"); const stamp = { app: APP_KEYS.DESKTOP, - ipc: resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace, - }), + ipc: "/deprecated-derived-identity", mode: SIDECAR_MODES.RUNTIME, namespace, source: SIDECAR_SOURCES.PACKAGED, @@ -466,41 +485,11 @@ describe("stopPackedLinuxHeadless", () => { }, }, }; - const markerPath = join(namespaceRoot, "runtime", "desktop-root.json"); - const stamp = { - app: APP_KEYS.DESKTOP, - ipc: resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace, - }), - mode: SIDECAR_MODES.RUNTIME, - namespace, - source: SIDECAR_SOURCES.PACKAGED, - }; - try { - await mkdir(dirname(markerPath), { recursive: true }); - // Use the test runner's own PID -- guaranteed to be in the live snapshot - // table returned by listProcessSnapshots, so validateDesktopAppImageMarker - // returns a non-"not-running" status. The cleanup defense skips on any - // status other than "not-running", regardless of whether stamp/exe match. - await writeFile( - markerPath, - `${JSON.stringify({ - appPath: "/tmp/Open-Design.AppImage", - executablePath: "/tmp/.mount_od/AppRun", - logPath: join(namespaceRoot, "logs", "desktop", "latest.log"), - namespaceRoot, - pid: process.pid, - ppid: 1, - stamp, - startedAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - version: 1, - })}\n`, - "utf8", + vi.mocked(findSidecarProcesses).mockImplementation(async (stamp) => + stamp.mode === SIDECAR_MODES.RUNTIME ? [{ command: "desktop", pid: process.pid, ppid: 1 }] : [], ); + await mkdir(namespaceRoot, { recursive: true }); await mkdir(config.roots.output.namespaceRoot, { recursive: true }); const result = await cleanupPackedLinuxNamespace(config, { headless: true }); @@ -508,109 +497,37 @@ describe("stopPackedLinuxHeadless", () => { expect(result.skipped).toBe(true); expect(result.removedOutputRoot).toBe(false); expect(result.removedRuntimeNamespaceRoot).toBe(false); - expect(await pathExists(markerPath)).toBe(true); expect(await pathExists(namespaceRoot)).toBe(true); expect(await pathExists(config.roots.output.namespaceRoot)).toBe(true); } finally { + vi.mocked(findSidecarProcesses).mockResolvedValue([]); await rm(root, { force: true, recursive: true }); } }); }); describe("stopPackedLinuxApp", () => { - linuxOnlyIt("treats a direct AppRun-launched Electron process as owned", async () => { - const root = await mkdtemp(join(tmpdir(), "od-linux-direct-apprun-")); - const namespace = "direct-apprun"; - const outputNamespaceRoot = join(root, "out", "linux", "namespaces", namespace); - const runtimeNamespaceBaseRoot = join(root, "runtime", "linux", "namespaces"); - const runtimeNamespaceRoot = join(runtimeNamespaceBaseRoot, namespace); - const config: ToolPackConfig = { - ...makeConfig(), - namespace, - roots: { - ...makeConfig().roots, - output: { - ...makeConfig().roots.output, - appBuilderRoot: join(outputNamespaceRoot, "builder"), - namespaceRoot: outputNamespaceRoot, - }, - runtime: { - namespaceBaseRoot: runtimeNamespaceBaseRoot, - namespaceRoot: runtimeNamespaceRoot, - }, - }, - }; - const appDir = join(root, "AppDir"); - const executablePath = join(appDir, "Open Design"); - const appRunPath = join(appDir, "AppRun"); - const markerPath = join(runtimeNamespaceRoot, "runtime", "desktop-root.json"); - const stamp = { - app: APP_KEYS.DESKTOP, - ipc: resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace, - }), - mode: SIDECAR_MODES.RUNTIME, - namespace, - source: SIDECAR_SOURCES.PACKAGED, - }; - let child: ChildProcess | null = null; - - try { - await mkdir(appDir, { recursive: true }); - await copyFile(process.execPath, executablePath); - await chmod(executablePath, 0o755); - await writeFile(appRunPath, "#!/bin/sh\n", "utf8"); - await mkdir(config.roots.output.appBuilderRoot, { recursive: true }); - await writeFile(join(config.roots.output.appBuilderRoot, "Open-Design.direct-apprun.AppImage"), "", "utf8"); - - child = spawn(executablePath, ["-e", "setInterval(() => {}, 1000)"], { - env: { ...process.env, APPIMAGE: appRunPath }, - stdio: "ignore", - }); - await new Promise((resolve, reject) => { - child?.once("spawn", resolve); - child?.once("error", reject); - }); - expect(child.pid).toEqual(expect.any(Number)); - await access(`/proc/${child.pid}/exe`); - - await mkdir(dirname(markerPath), { recursive: true }); - await writeFile( - markerPath, - `${JSON.stringify({ - appPath: "/", - executablePath, - logPath: join(runtimeNamespaceRoot, "logs", "desktop", "latest.log"), - namespaceRoot: runtimeNamespaceRoot, - pid: child.pid, - ppid: process.pid, - stamp, - startedAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - version: 1, - })}\n`, - "utf8", - ); - - vi.mocked(requestJsonIpc).mockRejectedValue(new Error("shutdown unavailable in test")); - const result = await stopPackedLinuxApp(config); - - expect(result.status).toBe("stopped"); - expect(result.stoppedPids).toContain(child.pid); - expect(result.remainingPids).toEqual([]); - } finally { - if (child?.pid != null && child.exitCode == null && child.signalCode == null) { - try { - process.kill(child.pid, "SIGKILL"); - } catch { - // Already gone. - } - await waitForChildExit(child, 1000); - } - await rm(root, { force: true, recursive: true }); - } + it("stops both exact launch sources without consulting derived files", async () => { + const stop = vi.mocked(stopSidecar); + stop.mockClear(); + stop.mockResolvedValue({ + alreadyStopped: true, + forcedPids: [], + gracefulAccepted: false, + matchedPids: [], + remainingPids: [], + stoppedPids: [], + }); + await expect(stopPackedLinuxApp(makeConfig())).resolves.toMatchObject({ status: "not-running" }); + expect(stop).toHaveBeenCalledTimes(12); + expect(stop).toHaveBeenCalledWith( + expect.objectContaining({ source: SIDECAR_SOURCES.TOOLS_PACK }), + undefined, + ); + expect(stop).toHaveBeenCalledWith( + expect.objectContaining({ source: SIDECAR_SOURCES.PACKAGED }), + undefined, + ); }); }); @@ -823,7 +740,7 @@ describe("createLinuxDesktopLaunchEnv", () => { const config = makeConfig(); const stamp = { app: APP_KEYS.DESKTOP, - ipc: "/tmp/open-design/ipc/default/desktop.sock", + channel: "stable", mode: SIDECAR_MODES.RUNTIME, namespace: "default", source: SIDECAR_SOURCES.TOOLS_PACK, @@ -836,7 +753,7 @@ describe("createLinuxDesktopLaunchEnv", () => { expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); expect(env.KEEP_ME).toBe("yes"); - expect(env.OD_SIDECAR_BASE).toBe(resolve(config.roots.runtime.namespaceRoot, "runtime")); + expect(env.OD_SIDECAR_BASE).toBeUndefined(); }); }); @@ -883,8 +800,8 @@ describe("shouldRejectLinuxHeadlessInspectOptions", () => { describe("inspectPackedLinuxApp", () => { it("rejects unsupported headless inspect options before opening IPC", async () => { - const requestJsonIpcMock = vi.mocked(requestJsonIpc); - requestJsonIpcMock.mockClear(); + const getStatus = vi.mocked(getSidecarStatus); + getStatus.mockClear(); await expect( inspectPackedLinuxApp(makeConfig(), { @@ -892,14 +809,15 @@ describe("inspectPackedLinuxApp", () => { headless: true, }), ).rejects.toThrow("linux inspect --headless supports status only; omit --expr and --path"); - expect(requestJsonIpcMock).not.toHaveBeenCalled(); + expect(getStatus).not.toHaveBeenCalled(); }); it("allows desktop inspect eval and screenshot options when headless is omitted", async () => { - const requestJsonIpcMock = vi.mocked(requestJsonIpc); - requestJsonIpcMock.mockReset(); - requestJsonIpcMock - .mockResolvedValueOnce({ state: "running", url: "od://app/" }) + vi.mocked(getSidecarStatus).mockImplementation(async (stamp) => { + if (stamp.source === SIDECAR_SOURCES.TOOLS_PACK) return { state: "running", url: "od://app/" }; + throw new Error("packaged status unavailable"); + }); + vi.mocked(invokeSidecar) .mockResolvedValueOnce({ ok: true, value: "Open Design" }) .mockResolvedValueOnce({ path: "/tmp/open-design-linux.png" }); @@ -913,7 +831,29 @@ describe("inspectPackedLinuxApp", () => { screenshot: { path: "/tmp/open-design-linux.png" }, status: { state: "running", url: "od://app/" }, }); - expect(requestJsonIpcMock).toHaveBeenCalledTimes(3); + expect(getSidecarStatus).toHaveBeenCalledTimes(2); + expect(invokeSidecar).toHaveBeenCalledTimes(2); + }); + + it("targets packaged IPC when a tools-pack process marker is stale", async () => { + vi.mocked(findSidecarProcesses).mockImplementation(async (stamp) => + stamp.source === SIDECAR_SOURCES.TOOLS_PACK ? [{ command: "desktop", pid: 42, ppid: 1 }] : [], + ); + vi.mocked(getSidecarStatus).mockImplementation(async (stamp) => { + if (stamp.source === SIDECAR_SOURCES.PACKAGED) return { state: "running", url: "od://app/" }; + throw new Error("stale tools-pack endpoint"); + }); + vi.mocked(invokeSidecar).mockResolvedValueOnce({ ok: true, value: "Open Design" }); + + const result = await inspectPackedLinuxApp(makeConfig(), { expr: "document.title" }); + + expect(result.status).toEqual({ state: "running", url: "od://app/" }); + expect(invokeSidecar).toHaveBeenCalledWith( + expect.objectContaining({ source: SIDECAR_SOURCES.PACKAGED }), + SIDECAR_MESSAGES.EVAL, + { expression: "document.title" }, + { timeoutMs: 5000 }, + ); }); }); diff --git a/tools/pack/tests/mac-lifecycle.test.ts b/tools/pack/tests/mac-lifecycle.test.ts index 63176240f0..7c99f3ab36 100644 --- a/tools/pack/tests/mac-lifecycle.test.ts +++ b/tools/pack/tests/mac-lifecycle.test.ts @@ -6,13 +6,18 @@ import type { ChildProcess } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { DesktopStatusSnapshot } from "@open-design/sidecar-proto"; +import type { SidecarStopResult } from "@open-design/sidecar"; import type { ToolPackConfig } from "@/config/index.js"; import { resolveMacPaths } from "@/mac/paths.js"; -const requestJsonIpc = vi.fn(async (): Promise => ({ state: "running" })); -const resolveAppIpcPath = vi.fn(() => "/tmp/open-design/ipc/test/desktop.sock"); -const createSidecarLaunchEnv = vi.fn(({ extraEnv }: { extraEnv: NodeJS.ProcessEnv }) => extraEnv); +const getSidecarStatus = vi.fn(async ( + _stamp: { source: string }, + _options?: { timeoutMs?: number }, +): Promise => ({ state: "running" })); +const findSidecarProcesses = vi.fn(async (stamp: { source: string }) => + stamp.source === "tools-pack" ? [{ pid: 1234 }] : [], +); const collectProcessTreePids = vi.fn( (_processes: unknown[], rootPids: Array) => rootPids.filter((pid): pid is number => typeof pid === "number"), @@ -27,11 +32,48 @@ const spawnLoggedProcess = vi.fn(async ({ env }: { env: NodeJS.ProcessEnv }) => unref: vi.fn(), }) as unknown as ChildProcess & { env: NodeJS.ProcessEnv }; }); +const defaultConvergeSidecarLaunch = async (request: { env: NodeJS.ProcessEnv; stamp: Record }) => ({ + attempts: 1, + description: { ready: true, resources: { pid: 1234 }, stamp: request.stamp }, + launcherProcess: await spawnLoggedProcess(request), +}); +const convergeSidecarLaunch = vi.fn(defaultConvergeSidecarLaunch); +const defaultStopSidecar = async ( + stamp: { app: string; mode: string; source: string }, + _options?: Record, +): Promise => ({ + alreadyStopped: stamp.source !== "packaged" || stamp.mode !== "headless", + forcedPids: [], + gracefulAccepted: stamp.source === "packaged" && stamp.mode === "headless", + matchedPids: stamp.source === "packaged" && stamp.mode === "headless" ? [4242] : [], + remainingPids: [], + stoppedPids: stamp.source === "packaged" && stamp.mode === "headless" ? [4242] : [], +}); +const stopSidecar = vi.fn(defaultStopSidecar); +const stopSidecars = vi.fn(async (requests: Array<{ options?: Record; stamp: { app: string; mode: string; source: string } }>) => { + const results = await Promise.all(requests.map(async ({ options, stamp }) => ({ + result: await stopSidecar(stamp, options ?? {}), + stamp, + }))); + const stopped = results.map(({ result }) => result); + return { + alreadyStopped: stopped.every(({ alreadyStopped }) => alreadyStopped), + forcedPids: [...new Set(stopped.flatMap(({ forcedPids }) => forcedPids))], + gracefulAccepted: stopped.some(({ gracefulAccepted }) => gracefulAccepted), + matchedPids: [...new Set(stopped.flatMap(({ matchedPids }) => matchedPids))], + remainingPids: [...new Set(stopped.flatMap(({ remainingPids }) => remainingPids))], + results, + stoppedPids: [...new Set(stopped.flatMap(({ stoppedPids }) => stoppedPids))], + }; +}); -vi.mock("@open-design/sidecar", () => ({ - createSidecarLaunchEnv, - requestJsonIpc, - resolveAppIpcPath, +vi.mock("@open-design/sidecar", async () => ({ + ...(await vi.importActual("@open-design/sidecar")), + findSidecarProcesses, + getSidecarStatus, + convergeSidecarLaunch, + stopSidecar, + stopSidecars, })); vi.mock("@open-design/platform", () => ({ @@ -45,7 +87,7 @@ vi.mock("@open-design/platform", () => ({ stopProcesses, })); -const { startPackedMacApp, stopPackedMacApp } = await import("@/mac/lifecycle.js"); +const { cleanupPackedMacNamespace, inspectPackedMacApp, startPackedMacApp, stopPackedMacApp } = await import("@/mac/lifecycle.js"); function makeConfig(root: string, overrides: Partial = {}): ToolPackConfig { return { @@ -87,7 +129,10 @@ function makeConfig(root: string, overrides: Partial = {}): Tool afterEach(() => { vi.clearAllMocks(); - requestJsonIpc.mockResolvedValue({ state: "running" }); + getSidecarStatus.mockResolvedValue({ state: "running" }); + findSidecarProcesses.mockImplementation(async (stamp: { source: string }) => + stamp.source === "tools-pack" ? [{ pid: 1234 }] : [], + ); listProcessSnapshots.mockResolvedValue([]); matchesStampedProcess.mockReturnValue(false); collectProcessTreePids.mockImplementation( @@ -95,6 +140,9 @@ afterEach(() => { rootPids.filter((pid): pid is number => typeof pid === "number"), ); stopProcesses.mockImplementation(async (pids: number[]) => ({ remainingPids: [], stoppedPids: pids })); + stopSidecar.mockImplementation(defaultStopSidecar); + stopSidecars.mockClear(); + convergeSidecarLaunch.mockImplementation(defaultConvergeSidecarLaunch); }); describe("startPackedMacApp", () => { @@ -109,7 +157,16 @@ describe("startPackedMacApp", () => { await mkdir(join(paths.installedAppPath, "Contents", "MacOS"), { recursive: true }); await writeFile(executablePath, "#!/bin/sh\nexit 0\n", "utf8"); await chmod(executablePath, 0o755); - requestJsonIpc.mockResolvedValue({ pid: delegatedPid, state: "running" }); + getSidecarStatus.mockImplementation(async (stamp: { source: string }) => { + if (stamp.source === "packaged") return { pid: delegatedPid, state: "running" }; + throw new Error("tools-pack desktop endpoint is gone after delegation"); + }); + findSidecarProcesses.mockResolvedValue([]); + convergeSidecarLaunch.mockImplementationOnce(async (request) => ({ + attempts: 1, + description: { ready: true, resources: { pid: delegatedPid }, stamp: request.stamp }, + launcherProcess: await spawnLoggedProcess(request), + })); spawnLoggedProcess.mockImplementationOnce(async ({ env }: { env: NodeJS.ProcessEnv }) => { const child = Object.assign(new EventEmitter(), { env, @@ -124,6 +181,10 @@ describe("startPackedMacApp", () => { expect(result.pid).toBe(delegatedPid); expect(result.status).toEqual({ pid: delegatedPid, state: "running" }); + expect(getSidecarStatus).toHaveBeenCalledWith( + expect.objectContaining({ source: "packaged" }), + { timeoutMs: 1000 }, + ); } finally { await rm(root, { force: true, recursive: true }); } @@ -139,17 +200,13 @@ describe("startPackedMacApp", () => { await mkdir(join(paths.installedAppPath, "Contents", "MacOS"), { recursive: true }); await writeFile(executablePath, "#!/bin/sh\nexit 1\n", "utf8"); await chmod(executablePath, 0o755); - spawnLoggedProcess.mockImplementationOnce(async ({ env }: { env: NodeJS.ProcessEnv }) => { - const child = Object.assign(new EventEmitter(), { - env, - pid: 1234, - unref: vi.fn(), - }) as unknown as ChildProcess & { env: NodeJS.ProcessEnv }; - setTimeout(() => child.emit("exit", 1, null), 10); - return child; - }); + convergeSidecarLaunch.mockRejectedValueOnce( + new Error("sidecar launcher exited before convergence code=1 signal=null"), + ); - await expect(startPackedMacApp(config)).rejects.toThrow("process exited early code=1 signal=null"); + await expect(startPackedMacApp(config)).rejects.toThrow( + "sidecar launcher exited before convergence code=1 signal=null", + ); } finally { await rm(root, { force: true, recursive: true }); } @@ -249,19 +306,7 @@ describe("stopPackedMacApp", () => { const payloadDesktop = { command: "payload-desktop", pid: 4242, ppid: 1 }; try { - requestJsonIpc.mockResolvedValue({ state: "running" }); - listProcessSnapshots - .mockResolvedValueOnce([payloadDesktop]) - .mockResolvedValueOnce([payloadDesktop]) - .mockResolvedValueOnce([]); - matchesStampedProcess.mockImplementation((processInfo, criteria) => { - const sidecarCriteria = criteria as { namespace?: string; source?: string }; - return ( - processInfo.command === payloadDesktop.command && - sidecarCriteria.namespace === config.namespace && - sidecarCriteria.source === "packaged" - ); - }); + stopSidecar.mockClear(); await expect(stopPackedMacApp(config)).resolves.toMatchObject({ gracefulRequested: true, @@ -270,15 +315,80 @@ describe("stopPackedMacApp", () => { status: "stopped", stoppedPids: [payloadDesktop.pid], }); - expect(listProcessSnapshots).toHaveBeenCalledTimes(3); - expect(matchesStampedProcess).toHaveBeenCalledWith( - payloadDesktop, + expect(stopSidecar).toHaveBeenCalledWith( expect.objectContaining({ namespace: config.namespace, source: "packaged" }), - expect.anything(), + expect.any(Object), + ); + expect(stopSidecar).toHaveBeenCalledWith( + expect.objectContaining({ mode: "headless", source: "packaged" }), + expect.any(Object), + ); + expect(stopSidecar).toHaveBeenCalledWith( + expect.objectContaining({ app: "web", mode: "runtime", source: "packaged" }), + { gracefulRequestTimeoutMs: 500, killGraceMs: 750, termGraceMs: 750 }, + ); + expect(stopSidecar).toHaveBeenCalledWith( + expect.objectContaining({ app: "daemon", mode: "runtime", source: "packaged" }), + expect.any(Object), ); expect(stopProcesses).not.toHaveBeenCalled(); } finally { await rm(root, { force: true, recursive: true }); } }); + + it("keeps output and runtime roots when a packaged generation survives cleanup", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-mac-lifecycle-")); + const config = makeConfig(root); + const outputMarker = join(config.roots.output.namespaceRoot, "artifact.txt"); + const runtimeMarker = join(config.roots.runtime.namespaceRoot, "runtime.txt"); + + try { + await mkdir(config.roots.output.namespaceRoot, { recursive: true }); + await mkdir(config.roots.runtime.namespaceRoot, { recursive: true }); + await writeFile(outputMarker, "artifact", "utf8"); + await writeFile(runtimeMarker, "runtime", "utf8"); + stopSidecar.mockImplementation(async (stamp: { app: string; mode: string; source: string }) => ({ + alreadyStopped: stamp.app !== "web" || stamp.source !== "packaged", + forcedPids: [], + gracefulAccepted: false, + matchedPids: stamp.app === "web" && stamp.source === "packaged" ? [5252] : [], + remainingPids: stamp.app === "web" && stamp.source === "packaged" ? [5252] : [], + stoppedPids: [], + })); + + await expect(cleanupPackedMacNamespace(config)).rejects.toThrow( + "cannot cleanup packaged namespace while sidecar processes remain: 5252", + ); + await expect(readFile(outputMarker, "utf8")).resolves.toBe("artifact"); + await expect(readFile(runtimeMarker, "utf8")).resolves.toBe("runtime"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); + +describe("inspectPackedMacApp", () => { + it("targets the reachable packaged sidecar when a tools-pack process marker is stale", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-mac-lifecycle-")); + try { + findSidecarProcesses.mockImplementation(async (stamp: { source: string }) => + stamp.source === "tools-pack" ? [{ pid: 1234 }] : [], + ); + getSidecarStatus.mockImplementation(async (stamp: { source: string }) => { + if (stamp.source === "packaged") return { pid: 5678, state: "running" }; + throw new Error("stale tools-pack endpoint"); + }); + + const result = await inspectPackedMacApp(makeConfig(root), {}); + + expect(result.status).toEqual({ pid: 5678, state: "running" }); + expect(getSidecarStatus).toHaveBeenCalledWith( + expect.objectContaining({ source: "packaged" }), + { timeoutMs: 2000 }, + ); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); }); diff --git a/tools/pack/tests/mac.test.ts b/tools/pack/tests/mac.test.ts index 4fdda88db5..738835a2b9 100644 --- a/tools/pack/tests/mac.test.ts +++ b/tools/pack/tests/mac.test.ts @@ -1,4 +1,4 @@ -import { access, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { access, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; import os, { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import process from "node:process"; @@ -11,6 +11,7 @@ import { copyResourceTree, createMacElectronRebuildOptions, renderMacPackagedConfig, + toRelativeImportSpecifier, validateMacNativeRebuildOutput, } from "@/mac/app.js"; import macBuilderSource from "@/mac/builder.ts?raw"; @@ -75,6 +76,26 @@ afterEach(() => { } }); +describe("mac prebundle entrypoints", () => { + it("canonicalizes symlinked roots before rendering relative imports", async () => { + const root = await mkdtemp(join(tmpdir(), "od-mac-prebundle-path-")); + const physicalRoot = join(root, "physical"); + const linkedRoot = join(root, "linked"); + const fromDirectory = join(physicalRoot, "entrypoints"); + const targetPath = join(physicalRoot, "dist", "entry.js"); + await mkdir(fromDirectory, { recursive: true }); + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, "export {};\n", "utf8"); + await symlink(physicalRoot, linkedRoot, "dir"); + try { + await expect(toRelativeImportSpecifier(join(linkedRoot, "entrypoints"), targetPath)) + .resolves.toBe("../dist/entry.js"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); + describe("resolveSeededAppConfigPaths", () => { it("declares the Workspace invite URL scheme in the packaged app metadata", () => { expect(macBuilderSource).toContain("protocols: ["); diff --git a/tools/pack/tests/prebundle/macos.test.ts b/tools/pack/tests/prebundle/macos.test.ts index 51670835e0..6c19cbcc1a 100644 --- a/tools/pack/tests/prebundle/macos.test.ts +++ b/tools/pack/tests/prebundle/macos.test.ts @@ -48,7 +48,6 @@ describe("mac standalone prebundle policy", () => { "@open-design/daemon", "@open-design/desktop", "@open-design/packaged", - "@open-design/sidecar", "@open-design/sidecar-proto", "@open-design/web", ]) { @@ -59,25 +58,23 @@ describe("mac standalone prebundle policy", () => { }), ).toBe(false); } - expect( - shouldInstallInternalPackageForMacPrebundle({ - packageName: "@open-design/contracts", - webOutputMode: "standalone", - }), - ).toBe(true); - expect( - shouldInstallInternalPackageForMacPrebundle({ - packageName: "@open-design/platform", - webOutputMode: "standalone", - }), - ).toBe(true); + for (const packageName of [ + "@open-design/contracts", + "@open-design/platform", + "@open-design/sidecar", + ]) { + expect( + shouldInstallInternalPackageForMacPrebundle({ packageName, webOutputMode: "standalone" }), + ).toBe(true); + } }); it("documents the explicit code-level bundle boundaries", () => { expect(MAC_PREBUNDLE_ESBUILD_TARGET).toBe("node24"); - expect(MAC_PREBUNDLE_POLICIES.packagedMain.externals).toEqual(["electron"]); + expect(MAC_PREBUNDLE_POLICIES.packagedMain.externals).toEqual(["@open-design/sidecar", "electron"]); expect(MAC_PREBUNDLE_POLICIES.daemonCli.externals).toEqual([ "@ffmpeg-installer/ffmpeg", + "@open-design/sidecar", "better-sqlite3", "blake3-wasm", "fsevents", @@ -86,13 +83,14 @@ describe("mac standalone prebundle policy", () => { ]); expect(MAC_PREBUNDLE_POLICIES.daemonSidecar.externals).toEqual([ "@ffmpeg-installer/ffmpeg", + "@open-design/sidecar", "better-sqlite3", "blake3-wasm", "fsevents", "hyperframes", "node-pty", ]); - expect(MAC_PREBUNDLE_POLICIES.webSidecar.externals).toEqual([]); + expect(MAC_PREBUNDLE_POLICIES.webSidecar.externals).toEqual(["@open-design/sidecar"]); expect(MAC_DAEMON_PREBUNDLE_ESM_REQUIRE_BANNER).toContain("createRequire"); // Must match apps/daemon/package.json / the pnpm lockfile, or // electron-builder's collector drops the module from the shipped app and diff --git a/tools/pack/tests/prebundle/windows.test.ts b/tools/pack/tests/prebundle/windows.test.ts index 68b56fdd4d..acb8353e51 100644 --- a/tools/pack/tests/prebundle/windows.test.ts +++ b/tools/pack/tests/prebundle/windows.test.ts @@ -45,7 +45,6 @@ describe("win standalone prebundle policy", () => { "@open-design/daemon", "@open-design/desktop", "@open-design/packaged", - "@open-design/sidecar", "@open-design/sidecar-proto", "@open-design/web", ]) { @@ -56,25 +55,23 @@ describe("win standalone prebundle policy", () => { }), ).toBe(false); } - expect( - shouldInstallInternalPackageForWinPrebundle({ - packageName: "@open-design/contracts", - webOutputMode: "standalone", - }), - ).toBe(true); - expect( - shouldInstallInternalPackageForWinPrebundle({ - packageName: "@open-design/platform", - webOutputMode: "standalone", - }), - ).toBe(true); + for (const packageName of [ + "@open-design/contracts", + "@open-design/platform", + "@open-design/sidecar", + ]) { + expect( + shouldInstallInternalPackageForWinPrebundle({ packageName, webOutputMode: "standalone" }), + ).toBe(true); + } }); it("documents the explicit code-level bundle boundaries", () => { expect(WIN_PREBUNDLE_ESBUILD_TARGET).toBe("node24"); - expect(WIN_PREBUNDLE_POLICIES.packagedMain.externals).toEqual(["electron"]); + expect(WIN_PREBUNDLE_POLICIES.packagedMain.externals).toEqual(["@open-design/sidecar", "electron"]); expect(WIN_PREBUNDLE_POLICIES.daemonCli.externals).toEqual([ "@ffmpeg-installer/ffmpeg", + "@open-design/sidecar", "better-sqlite3", "blake3-wasm", "hyperframes", @@ -82,12 +79,13 @@ describe("win standalone prebundle policy", () => { ]); expect(WIN_PREBUNDLE_POLICIES.daemonSidecar.externals).toEqual([ "@ffmpeg-installer/ffmpeg", + "@open-design/sidecar", "better-sqlite3", "blake3-wasm", "hyperframes", "node-pty", ]); - expect(WIN_PREBUNDLE_POLICIES.webSidecar.externals).toEqual([]); + expect(WIN_PREBUNDLE_POLICIES.webSidecar.externals).toEqual(["@open-design/sidecar"]); expect(WIN_DAEMON_PREBUNDLE_ESM_REQUIRE_BANNER).toContain("createRequire"); // Must match apps/daemon/package.json / the pnpm lockfile, or // electron-builder's collector drops the module from the shipped app and diff --git a/tools/pack/tests/release-workflows.test.ts b/tools/pack/tests/release-workflows.test.ts index 3a99e4c403..14ffeb962f 100644 --- a/tools/pack/tests/release-workflows.test.ts +++ b/tools/pack/tests/release-workflows.test.ts @@ -5,7 +5,9 @@ import { describe, expect, it } from "vitest"; import linuxPack from "@/linux.ts?raw"; import macBuild from "@/mac/build.ts?raw"; import macFs from "@/mac/fs.ts?raw"; +import macLifecycle from "@/mac/lifecycle.ts?raw"; import macWorkspace from "@/mac/workspace.ts?raw"; +import workspaceBuild from "@/workspace-build.ts?raw"; import winApp from "@/win/app.ts?raw"; import winLifecycle from "@/win/lifecycle.ts?raw"; @@ -173,21 +175,25 @@ describe("release workflows", () => { expect(winLifecycle).toContain("const launcher = resolveToolPackLauncherLayout(config)"); expect(winLifecycle).toContain("await removeTree(launcher.paths.namespaceRoot)"); expect(winLifecycle).toContain("removedLauncherNamespaceRoot"); + expect(macLifecycle).toContain("const launcher = resolveToolPackLauncherLayout(config)"); + expect(macLifecycle).toContain("await rm(launcher.paths.namespaceRoot, { force: true, recursive: true })"); + expect(macLifecycle).toContain("removedLauncherNamespaceRoot"); expect(buildWin).toContain('Measure-Step "validate launcher payload artifact"'); expect(buildWin).toContain('Measure-Step "validate launcher payload update fixture"'); expect(buildWin).toContain('Test-JsonString $manifest.entry.executable "entry.executable" "payload/Open Design.exe"'); - for (const workspaceBuild of [winApp, macWorkspace, linuxPack]) { - const sidecarProtoBuild = 'await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"])'; - const launcherProtoBuild = 'await runPnpm(config, ["--filter", "@open-design/launcher-proto", "build"])'; - const sidecarBuild = 'await runPnpm(config, ["--filter", "@open-design/sidecar", "build"])'; - const dshRuntimeBuild = 'await runPnpm(config, ["--filter", "@open-design/dsh-runtime", "build"])'; - const daemonBuild = 'await runPnpm(config, ["--filter", "@open-design/daemon", "build"])'; - expect(workspaceBuild).toContain(launcherProtoBuild); - expect(workspaceBuild).toContain(dshRuntimeBuild); - expect(workspaceBuild.indexOf(sidecarProtoBuild)).toBeLessThan(workspaceBuild.indexOf(launcherProtoBuild)); - expect(workspaceBuild.indexOf(launcherProtoBuild)).toBeLessThan(workspaceBuild.indexOf(sidecarBuild)); - expect(workspaceBuild.indexOf(dshRuntimeBuild)).toBeLessThan(workspaceBuild.indexOf(daemonBuild)); + expect(winApp).toContain("return ensureWorkspaceBuildArtifacts("); + expect(macWorkspace).toContain("await ensureWorkspaceBuildArtifacts("); + expect(linuxPack).toContain("await runWorkspaceBuild("); + for (const buildSource of [winApp, macWorkspace, linuxPack]) { + expect(buildSource).not.toContain('["--filter", "@open-design/platform", "build"]'); + expect(buildSource).not.toContain('["--filter", "@open-design/sidecar", "build"]'); } + const dependencyClosureBuild = '"--filter", "@open-design/packaged^..."'; + const webSidecarBuild = '"--filter", "@open-design/web", "run", "build:sidecar"'; + const packagedBuild = '"--filter", "@open-design/packaged", "run", "build"'; + expect(workspaceBuild).toContain('"--filter", "@open-design/dsh-runtime..."'); + expect(workspaceBuild.indexOf(dependencyClosureBuild)).toBeLessThan(workspaceBuild.indexOf(webSidecarBuild)); + expect(workspaceBuild.indexOf(webSidecarBuild)).toBeLessThan(workspaceBuild.indexOf(packagedBuild)); expect(prerelease).toContain("name: release-prerelease"); expect(prerelease).toContain("pnpm exec tools-release prepare prerelease"); expect(prerelease).toContain("OPEN_DESIGN_PRERELEASE_METADATA_URL"); diff --git a/tools/pack/tests/sidecar-stamps.test.ts b/tools/pack/tests/sidecar-stamps.test.ts new file mode 100644 index 0000000000..b22b54b216 --- /dev/null +++ b/tools/pack/tests/sidecar-stamps.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import type { ToolPackConfig } from "@/config/index.js"; +import { + allPackagedSidecarStopRequests, + packagedSidecarStopRequests, +} from "@/config/sidecar-stamps.js"; + +describe("packaged sidecar resource declaration", () => { + it("declares both launch sources, every runtime app, and both desktop modes", () => { + const config = { + appVersion: "0.10.0-beta.1", + namespace: "release-beta-win", + } as ToolPackConfig; + + const stamps = allPackagedSidecarStopRequests(config).map(({ stamp }) => stamp); + + expect(stamps).toHaveLength(12); + expect(new Set(stamps.map(({ channel }) => channel))).toEqual(new Set(["beta"])); + expect(new Set(stamps.map(({ source }) => source))).toEqual(new Set(["tools-pack", "packaged"])); + expect(stamps.filter(({ app, mode }) => app === "desktop" && mode === "runtime")).toHaveLength(2); + expect(stamps.filter(({ app, mode }) => app === "desktop" && mode === "headless")).toHaveLength(2); + expect(stamps.filter(({ app, mode }) => app === "web" && mode === "runtime")).toHaveLength(2); + expect(stamps.filter(({ app, mode }) => app === "daemon" && mode === "runtime")).toHaveLength(2); + expect(stamps.filter(({ app, mode }) => app === "web" && mode === "headless")).toHaveLength(2); + expect(stamps.filter(({ app, mode }) => app === "daemon" && mode === "headless")).toHaveLength(2); + }); + + it("keeps exact runtime and headless stop sets disjoint for every app and source", () => { + const config = { + appVersion: "0.10.0-beta.1", + namespace: "release-beta-win", + } as ToolPackConfig; + const key = ({ stamp }: ReturnType[number]) => + [stamp.app, stamp.channel, stamp.mode, stamp.namespace, stamp.source].join(":"); + const runtime = new Set(packagedSidecarStopRequests(config, "runtime").map(key)); + const headless = new Set(packagedSidecarStopRequests(config, "headless").map(key)); + + expect(runtime.size).toBe(6); + expect(headless.size).toBe(6); + expect([...runtime].filter((stamp) => headless.has(stamp))).toEqual([]); + }); +}); diff --git a/tools/pack/tests/web-standalone-after-pack.test.ts b/tools/pack/tests/web-standalone-after-pack.test.ts index fb51bb9edf..1b23d9fe1d 100644 --- a/tools/pack/tests/web-standalone-after-pack.test.ts +++ b/tools/pack/tests/web-standalone-after-pack.test.ts @@ -106,6 +106,12 @@ async function writeHyperframesRuntimeFixture(options: { ? join(appPath, "Open Design.exe") : join(appPath, "Contents", "MacOS", "Open Design"); await mkdir(path.dirname(bundledNodePath), { recursive: true }); + if (process.platform !== "win32") { + const quotedNodePath = process.execPath.replaceAll("'", "'\\''"); + await writeFile(bundledNodePath, `#!/bin/sh\nexec '${quotedNodePath}' "$@"\n`, "utf8"); + await chmod(bundledNodePath, 0o755); + return; + } try { await link(process.execPath, bundledNodePath); } catch { diff --git a/tools/pack/tests/win-lifecycle.test.ts b/tools/pack/tests/win-lifecycle.test.ts index e026adac4f..2f32a96fe0 100644 --- a/tools/pack/tests/win-lifecycle.test.ts +++ b/tools/pack/tests/win-lifecycle.test.ts @@ -7,7 +7,10 @@ import { describe, expect, it, vi } from "vitest"; import type { ToolPackConfig } from "@/config/index.js"; -const requestJsonIpc = vi.hoisted(() => vi.fn()); +const requestSidecar = vi.hoisted(() => vi.fn()); +const findSidecarProcesses = vi.hoisted(() => vi.fn(async (stamp: { source: string }) => + stamp.source === "tools-pack" ? [{ pid: 1234 }] : [], +)); const listProcessSnapshots = vi.hoisted(() => vi.fn(async () => []), ); @@ -15,6 +18,38 @@ const matchesStampedProcess = vi.hoisted(() => vi.fn(() => false), ); const spawnBackgroundProcess = vi.hoisted(() => vi.fn(async () => ({ pid: 12345 }))); +const convergeSidecarLaunch = vi.hoisted(() => vi.fn(async ( + request: { stamp: Record }, + _options?: { ownerStamps?: Array>; timeoutMs?: number }, +) => { + const spawned = await spawnBackgroundProcess(); + return { + attempts: 1, + description: { ready: true, resources: { pid: spawned.pid }, stamp: request.stamp }, + launcherProcess: { pid: spawned.pid }, + }; +})); +const stopSidecar = vi.hoisted(() => vi.fn(async (stamp: { mode: string; source: string }) => ({ + alreadyStopped: stamp.source !== "packaged" || stamp.mode !== "headless", + forcedPids: [] as number[], + gracefulAccepted: stamp.source === "packaged" && stamp.mode === "headless", + matchedPids: stamp.source === "packaged" && stamp.mode === "headless" ? [4242] : [], + remainingPids: [] as number[], + stoppedPids: stamp.source === "packaged" && stamp.mode === "headless" ? [4242] : [], +}))); +const stopSidecars = vi.hoisted(() => vi.fn(async (requests: Array<{ stamp: { mode: string; source: string } }>) => { + const results = await Promise.all(requests.map(async ({ stamp }) => ({ result: await stopSidecar(stamp), stamp }))); + const stopped = results.map(({ result }) => result); + return { + alreadyStopped: stopped.every(({ alreadyStopped }) => alreadyStopped), + forcedPids: [] as number[], + gracefulAccepted: stopped.some(({ gracefulAccepted }) => gracefulAccepted), + matchedPids: [...new Set(stopped.flatMap(({ matchedPids }) => matchedPids))], + remainingPids: [...new Set(stopped.flatMap(({ remainingPids }) => remainingPids))], + results, + stoppedPids: [...new Set(stopped.flatMap(({ stoppedPids }) => stoppedPids))], + }; +})); const stopProcesses = vi.hoisted(() => vi.fn(async () => undefined)); const invokeNsis = vi.hoisted(() => vi.fn()); const queryWinRegistryEntries = vi.hoisted(() => @@ -28,7 +63,13 @@ vi.mock("@open-design/sidecar", async () => { const actual = await vi.importActual("@open-design/sidecar"); return { ...actual, - requestJsonIpc, + convergeSidecarLaunch, + findSidecarProcesses, + getSidecarStatus: async (stamp: { app: string; source: string }) => await requestSidecar(stamp.app, { type: SIDECAR_MESSAGES.STATUS }, stamp.source), + invokeSidecar: async (stamp: { app: string; source: string }, action: string, input: unknown) => await requestSidecar(stamp.app, { input, type: action }, stamp.source), + launchSidecar: spawnBackgroundProcess, + stopSidecar, + stopSidecars, }; }); @@ -60,7 +101,7 @@ vi.mock("@/win/registry.js", async () => { }; }); -const { diagnosePackedWinIpc, inspectPackedWinApp, installPackedWinApp, stopPackedWinApp } = await import( +const { cleanupPackedWinNamespace, diagnosePackedWinIpc, inspectPackedWinApp, installPackedWinApp, startPackedWinApp, stopPackedWinApp } = await import( "@/win/lifecycle.js" ); const { resolveWinPaths } = await import("@/win/paths.js"); @@ -173,15 +214,47 @@ describe("installPackedWinApp", () => { }); describe("inspectPackedWinApp", () => { + it("targets reachable packaged peers when a tools-pack process marker is stale", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); + + try { + findSidecarProcesses.mockImplementation(async (stamp: { source: string }) => + stamp.source === "tools-pack" ? [{ pid: 1234 }] : [], + ); + requestSidecar.mockReset(); + requestSidecar.mockImplementation(async (_app: string, payload: { type?: string }, source: string) => { + if (payload.type === SIDECAR_MESSAGES.STATUS && source === "tools-pack") { + throw new Error("stale tools-pack endpoint"); + } + return { state: "running" }; + }); + + await inspectPackedWinApp(createConfig(root), {}); + + expect(requestSidecar).toHaveBeenCalledWith( + expect.any(String), + { type: SIDECAR_MESSAGES.STATUS }, + "packaged", + ); + expect(requestSidecar.mock.calls.filter((call) => call[1]?.type === SIDECAR_MESSAGES.STATUS)) + .toHaveLength(4); + } finally { + await rm(root, { force: true, recursive: true }); + findSidecarProcesses.mockImplementation(async (stamp: { source: string }) => + stamp.source === "tools-pack" ? [{ pid: 1234 }] : [], + ); + } + }); + it("returns status and diagnostics when eval IPC times out", async () => { const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); try { - requestJsonIpc.mockReset(); - requestJsonIpc.mockImplementation(async (ipc: string, payload: { type?: string }) => { + requestSidecar.mockReset(); + requestSidecar.mockImplementation(async (app: string, payload: { type?: string }) => { if (payload.type === SIDECAR_MESSAGES.STATUS) { - if (ipc.includes("daemon")) return { state: "running", url: "http://127.0.0.1:1234" }; - if (ipc.includes("web")) return { state: "running", url: "http://127.0.0.1:5678" }; + if (app.includes("daemon")) return { state: "running", url: "http://127.0.0.1:1234" }; + if (app.includes("web")) return { state: "running", url: "http://127.0.0.1:5678" }; return { state: "running", url: "od://app/" }; } if (payload.type === SIDECAR_MESSAGES.EVAL) { @@ -210,11 +283,11 @@ describe("inspectPackedWinApp", () => { const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); try { - requestJsonIpc.mockReset(); - requestJsonIpc.mockImplementation(async (ipc: string, payload: { type?: string }) => { + requestSidecar.mockReset(); + requestSidecar.mockImplementation(async (app: string, payload: { type?: string }) => { if (payload.type === SIDECAR_MESSAGES.STATUS) { - if (ipc.includes("daemon")) return { state: "running", url: "http://127.0.0.1:1234" }; - if (ipc.includes("web")) return { state: "running", url: "http://127.0.0.1:5678" }; + if (app.includes("daemon")) return { state: "running", url: "http://127.0.0.1:1234" }; + if (app.includes("web")) return { state: "running", url: "http://127.0.0.1:5678" }; throw new Error("IPC request timed out: test-pipe"); } throw new Error(`unexpected IPC message: ${String(payload.type)}`); @@ -237,11 +310,11 @@ describe("inspectPackedWinApp", () => { const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); try { - requestJsonIpc.mockReset(); - requestJsonIpc.mockImplementation(async (ipc: string, payload: { type?: string }) => { + requestSidecar.mockReset(); + requestSidecar.mockImplementation(async (app: string, payload: { type?: string }) => { if (payload.type === SIDECAR_MESSAGES.STATUS) { - if (ipc.includes("daemon")) return { state: "running", url: "http://127.0.0.1:1234" }; - if (ipc.includes("web")) return { state: "running", url: "http://127.0.0.1:5678" }; + if (app.includes("daemon")) return { state: "running", url: "http://127.0.0.1:1234" }; + if (app.includes("web")) return { state: "running", url: "http://127.0.0.1:5678" }; throw new Error("IPC request timed out: test-pipe"); } throw new Error(`unexpected IPC message: ${String(payload.type)}`); @@ -272,15 +345,15 @@ describe("inspectPackedWinApp", () => { try { await writeFakeUnpackedExe(config); - requestJsonIpc.mockReset(); + requestSidecar.mockReset(); spawnBackgroundProcess.mockClear(); stopProcesses.mockClear(); listProcessSnapshots.mockClear(); process.env.OD_JSON_IPC_TRACE = "already-on"; - requestJsonIpc.mockImplementation(async (ipc: string, payload: { type?: string }) => { + requestSidecar.mockImplementation(async (app: string, payload: { type?: string }) => { if (payload.type === SIDECAR_MESSAGES.STATUS) { - if (ipc.includes("daemon")) return { state: "running", url: "http://127.0.0.1:1234" }; - if (ipc.includes("web")) return { state: "running", url: "http://127.0.0.1:5678" }; + if (app.includes("daemon")) return { state: "running", url: "http://127.0.0.1:1234" }; + if (app.includes("web")) return { state: "running", url: "http://127.0.0.1:5678" }; return { state: "running", url: "od://app/" }; } if (payload.type === SIDECAR_MESSAGES.SHUTDOWN) return { accepted: true }; @@ -312,29 +385,84 @@ describe("inspectPackedWinApp", () => { }); }); +describe("startPackedWinApp", () => { + it("uses an extended convergence budget for packaged Windows cold starts", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); + const config = createConfig(root); + + try { + await writeFakeUnpackedExe(config); + convergeSidecarLaunch.mockClear(); + + await expect(startPackedWinApp(config, { waitForStatus: false })).resolves.toMatchObject({ + pid: 12345, + status: null, + }); + expect(convergeSidecarLaunch).toHaveBeenCalledWith(expect.any(Object), { + ownerStamps: [ + expect.objectContaining({ app: "desktop", source: "tools-pack" }), + expect.objectContaining({ app: "desktop", source: "packaged" }), + ], + timeoutMs: 90_000, + }); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); + describe("stopPackedWinApp", () => { + it.runIf(process.platform === "win32")("lets a start invoked during an in-flight stop launch after that stop completes", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); + const config = createConfig(root); + let releaseStop!: () => void; + let markStopEntered!: () => void; + const stopEntered = new Promise((resolve) => { markStopEntered = resolve; }); + const stopRelease = new Promise((resolve) => { releaseStop = resolve; }); + + try { + await writeFakeUnpackedExe(config); + convergeSidecarLaunch.mockClear(); + stopSidecars.mockImplementationOnce(async (requests) => { + markStopEntered(); + await stopRelease; + const results = await Promise.all(requests.map(async ({ stamp }) => ({ result: await stopSidecar(stamp), stamp }))); + const stopped = results.map(({ result }) => result); + return { + alreadyStopped: stopped.every(({ alreadyStopped }) => alreadyStopped), + forcedPids: [], + gracefulAccepted: stopped.some(({ gracefulAccepted }) => gracefulAccepted), + matchedPids: [...new Set(stopped.flatMap(({ matchedPids }) => matchedPids))], + remainingPids: [], + results, + stoppedPids: [...new Set(stopped.flatMap(({ stoppedPids }) => stoppedPids))], + }; + }); + + const stopping = stopPackedWinApp(config); + await stopEntered; + const starting = startPackedWinApp(config, { waitForStatus: false }); + await new Promise((resolve) => setImmediate(resolve)); + expect(convergeSidecarLaunch).not.toHaveBeenCalled(); + + releaseStop(); + await expect(stopping).resolves.toMatchObject({ status: "stopped" }); + await expect(starting).resolves.toMatchObject({ pid: 12345, status: null }); + expect(convergeSidecarLaunch).toHaveBeenCalledOnce(); + } finally { + releaseStop(); + await rm(root, { force: true, recursive: true }); + } + }); + it("waits for a packaged-source payload desktop to exit after graceful shutdown", async () => { const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); const config = createConfig(root); const payloadDesktop = { command: "payload-desktop", pid: 4242, ppid: 1 }; try { - requestJsonIpc.mockReset(); - requestJsonIpc.mockResolvedValue({ accepted: true }); - listProcessSnapshots.mockReset(); - listProcessSnapshots - .mockResolvedValueOnce([payloadDesktop]) - .mockResolvedValueOnce([payloadDesktop]) - .mockResolvedValueOnce([]); - matchesStampedProcess.mockReset(); - matchesStampedProcess.mockImplementation((processInfo, criteria) => { - const sidecarCriteria = criteria as { namespace?: string; source?: string }; - return ( - processInfo.command === payloadDesktop.command && - sidecarCriteria.namespace === config.namespace && - sidecarCriteria.source === "packaged" - ); - }); + requestSidecar.mockReset(); + requestSidecar.mockResolvedValue({ accepted: true }); stopProcesses.mockClear(); await expect(stopPackedWinApp(config)).resolves.toEqual({ @@ -344,10 +472,42 @@ describe("stopPackedWinApp", () => { status: "stopped", stoppedPids: [payloadDesktop.pid], }); - expect(listProcessSnapshots).toHaveBeenCalledTimes(3); expect(stopProcesses).not.toHaveBeenCalled(); } finally { await rm(root, { force: true, recursive: true }); } }); + + it("does not delete namespace roots when a packaged generation survives", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); + const config = createConfig(root); + const outputMarker = join(config.roots.output.namespaceRoot, "artifact.txt"); + const runtimeMarker = join(config.roots.runtime.namespaceRoot, "runtime.txt"); + + try { + invokeNsis.mockClear(); + await mkdir(dirname(outputMarker), { recursive: true }); + await mkdir(dirname(runtimeMarker), { recursive: true }); + await writeFile(outputMarker, "artifact", "utf8"); + await writeFile(runtimeMarker, "runtime", "utf8"); + stopSidecars.mockResolvedValueOnce({ + alreadyStopped: false, + forcedPids: [5252], + gracefulAccepted: false, + matchedPids: [5252], + remainingPids: [5252], + results: [], + stoppedPids: [], + }); + + await expect(cleanupPackedWinNamespace(config)).rejects.toThrow( + "cannot cleanup packaged namespace while sidecar processes remain: 5252", + ); + await expect(readFile(outputMarker, "utf8")).resolves.toBe("artifact"); + await expect(readFile(runtimeMarker, "utf8")).resolves.toBe("runtime"); + expect(invokeNsis).not.toHaveBeenCalled(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); }); diff --git a/tools/pack/tests/workspace-build.test.ts b/tools/pack/tests/workspace-build.test.ts index 5d72ff7b28..162c7a3b3f 100644 --- a/tools/pack/tests/workspace-build.test.ts +++ b/tools/pack/tests/workspace-build.test.ts @@ -1,12 +1,22 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { ToolPackCache } from "@/cache/index.js"; import type { ToolPackConfig } from "@/config/index.js"; -import { ensureWorkspaceBuildArtifacts } from "@/workspace-build.js"; +import { + createWorkspaceBuildCacheKey, + createWorkspaceBuildCacheKeyFromInputs, + ensureWorkspaceBuildArtifacts, + runWorkspaceBuild, + WORKSPACE_BUILD_COMMANDS, + WORKSPACE_BUILD_CACHE_SCHEMA_VERSION, + WORKSPACE_BUILD_PACKAGES, + type WorkspaceBuildRunner, +} from "@/workspace-build.js"; const PACKAGE_DIRS = [ "packages/release", @@ -15,8 +25,8 @@ const PACKAGE_DIRS = [ "packages/registry-protocol", "packages/sidecar-proto", "packages/launcher-proto", - "packages/sidecar", "packages/platform", + "packages/sidecar", "packages/download", "packages/host", "packages/agui-adapter", @@ -42,10 +52,10 @@ const OUTPUT_FILES = [ "packages/sidecar-proto/dist/index.d.ts", "packages/launcher-proto/dist/index.mjs", "packages/launcher-proto/dist/index.d.ts", - "packages/sidecar/dist/index.mjs", - "packages/sidecar/dist/index.d.ts", "packages/platform/dist/index.mjs", "packages/platform/dist/index.d.ts", + "packages/sidecar/dist/index.mjs", + "packages/sidecar/dist/index.d.ts", "packages/download/dist/index.mjs", "packages/download/dist/index.d.ts", "packages/host/dist/index.mjs", @@ -74,6 +84,7 @@ const OUTPUT_FILES = [ async function writeWorkspace(root: string): Promise { await writeFile(join(root, "package.json"), `${JSON.stringify({ packageManager: "pnpm@10.33.2" }, null, 2)}\n`, "utf8"); await writeFile(join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8"); + await writeFile(join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - apps/*\n", "utf8"); for (const directory of PACKAGE_DIRS) { await mkdir(join(root, directory, "src"), { recursive: true }); await writeFile(join(root, directory, "package.json"), `${JSON.stringify({ name: directory }, null, 2)}\n`, "utf8"); @@ -81,6 +92,12 @@ async function writeWorkspace(root: string): Promise { } } +function buildRunner(build: () => Promise): WorkspaceBuildRunner { + return async (args) => { + if (args[0] === "--filter" && args[1] === "@open-design/packaged") await build(); + }; +} + async function writeOutputs(root: string, value: string): Promise { for (const file of OUTPUT_FILES) { await mkdir(join(root, file, ".."), { recursive: true }); @@ -144,14 +161,14 @@ describe("ensureWorkspaceBuildArtifacts", () => { try { await writeWorkspace(root); - await ensureWorkspaceBuildArtifacts(config, cache, async () => { + await ensureWorkspaceBuildArtifacts(config, cache, buildRunner(async () => { builds += 1; await writeOutputs(root, `build-${builds}`); - }); - await ensureWorkspaceBuildArtifacts(config, cache, async () => { + })); + await ensureWorkspaceBuildArtifacts(config, cache, buildRunner(async () => { builds += 1; await writeOutputs(root, `build-${builds}`); - }); + })); expect(builds).toBe(1); expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "hit"]); @@ -165,6 +182,57 @@ describe("ensureWorkspaceBuildArtifacts", () => { } }); + it("processes sourcemaps after both a credentialless fill and a credentialed hit", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-workspace-build-sourcemaps-")); + const cache = new ToolPackCache(join(root, ".cache")); + const initialConfig = createConfig(root, cache.root); + const releaseConfig: ToolPackConfig = { + ...initialConfig, + appVersion: "1.2.3-beta.4", + posthogCliApiKey: "test-api-key", + posthogCliProjectId: "test-project", + }; + const processing: Array<{ hadMap: boolean; injected: boolean; uploaded: boolean; version?: string }> = []; + let builds = 0; + const processMaterialized = async (config: ToolPackConfig) => { + const chunks = join(root, "apps/web/.next/static"); + const mapPath = join(chunks, "chunk.js.map"); + const hadMap = await readFile(mapPath, "utf8").then(() => true, () => false); + const hasCredentials = Boolean(config.posthogCliApiKey && config.posthogCliProjectId); + if (hasCredentials) { + await writeFile(join(chunks, "chunk.js"), `chunk\n//# chunkId=${config.appVersion}\n`, "utf8"); + } + processing.push({ hadMap, injected: hasCredentials, uploaded: hasCredentials, version: config.appVersion }); + await rm(mapPath, { force: true }); + }; + + try { + await writeWorkspace(root); + await ensureWorkspaceBuildArtifacts(initialConfig, cache, buildRunner(async () => { + builds += 1; + await writeOutputs(root, "build"); + await writeFile(join(root, "apps/web/.next/static/chunk.js.map"), "map\n", "utf8"); + }), processMaterialized); + await expect(readFile(join(root, "apps/web/.next/static/chunk.js.map"), "utf8")).rejects.toThrow(); + + await ensureWorkspaceBuildArtifacts(releaseConfig, cache, buildRunner(async () => { + builds += 1; + }), processMaterialized); + + expect(builds).toBe(1); + expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "hit"]); + expect(processing).toEqual([ + { hadMap: true, injected: false, uploaded: false, version: undefined }, + { hadMap: true, injected: true, uploaded: true, version: "1.2.3-beta.4" }, + ]); + expect(await readFile(join(root, "apps/web/.next/static/chunk.js"), "utf8")) + .toContain("//# chunkId=1.2.3-beta.4"); + await expect(readFile(join(root, "apps/web/.next/static/chunk.js.map"), "utf8")).rejects.toThrow(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + it("writes a Windows version-family alias after a successful build", async () => { const root = await mkdtemp(join(tmpdir(), "open-design-workspace-build-alias-")); const cache = new ToolPackCache(join(root, ".cache")); @@ -172,9 +240,9 @@ describe("ensureWorkspaceBuildArtifacts", () => { try { await writeWorkspace(root); - await ensureWorkspaceBuildArtifacts(config, cache, async () => { + await ensureWorkspaceBuildArtifacts(config, cache, buildRunner(async () => { await writeOutputs(root, "build"); - }); + })); const aliasesRoot = join(cache.root, "aliases", "win.workspace-build"); const aliasBuckets = await readdir(aliasesRoot); @@ -192,10 +260,10 @@ describe("ensureWorkspaceBuildArtifacts", () => { try { await writeWorkspace(root); - await ensureWorkspaceBuildArtifacts(config, cache, async () => { + await ensureWorkspaceBuildArtifacts(config, cache, buildRunner(async () => { await writeOutputs(root, "build"); await writeStandalonePeerDeps(root); - }); + })); expect(await readFile(join(root, "apps/web/.next/standalone/apps/web/node_modules/react/package.json"), "utf8")) .toContain('"name": "react"'); @@ -215,9 +283,9 @@ describe("ensureWorkspaceBuildArtifacts", () => { try { await writeWorkspace(root); - await ensureWorkspaceBuildArtifacts(config, cache, async () => { + await ensureWorkspaceBuildArtifacts(config, cache, buildRunner(async () => { await writeOutputs(root, "build"); - }); + })); await expect(readdir(join(cache.root, "aliases", "mac.workspace-build"))).rejects.toThrow(); } finally { @@ -233,15 +301,15 @@ describe("ensureWorkspaceBuildArtifacts", () => { try { await writeWorkspace(root); - await ensureWorkspaceBuildArtifacts(config, cache, async () => { + await ensureWorkspaceBuildArtifacts(config, cache, buildRunner(async () => { builds += 1; await writeOutputs(root, `build-${builds}`); - }); + })); await rm(join(root, "apps/web/dist/sidecar/index.js"), { force: true }); - await ensureWorkspaceBuildArtifacts(config, cache, async () => { + await ensureWorkspaceBuildArtifacts(config, cache, buildRunner(async () => { builds += 1; await writeOutputs(root, `build-${builds}`); - }); + })); expect(builds).toBe(1); expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "hit"]); @@ -259,15 +327,15 @@ describe("ensureWorkspaceBuildArtifacts", () => { try { await writeWorkspace(root); - await ensureWorkspaceBuildArtifacts(config, cache, async () => { + await ensureWorkspaceBuildArtifacts(config, cache, buildRunner(async () => { builds += 1; await writeOutputs(root, `build-${builds}`); - }); + })); await rm(join(root, "packages/host/dist/index.mjs"), { force: true }); - await ensureWorkspaceBuildArtifacts(config, cache, async () => { + await ensureWorkspaceBuildArtifacts(config, cache, buildRunner(async () => { builds += 1; await writeOutputs(root, `build-${builds}`); - }); + })); expect(builds).toBe(1); expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "hit"]); @@ -300,12 +368,12 @@ describe("ensureWorkspaceBuildArtifacts", () => { try { await writeWorkspace(root); - await ensureWorkspaceBuildArtifacts(winConfig, cache, async () => { + await ensureWorkspaceBuildArtifacts(winConfig, cache, buildRunner(async () => { await writeOutputs(root, "win-build"); - }); - await ensureWorkspaceBuildArtifacts(macConfig, cache, async () => { + })); + await ensureWorkspaceBuildArtifacts(macConfig, cache, buildRunner(async () => { await writeOutputs(root, "mac-build"); - }); + })); expect(cache.report().entries.map((entry) => entry.nodeId)).toEqual([ "win.workspace-build", @@ -318,3 +386,154 @@ describe("ensureWorkspaceBuildArtifacts", () => { } }); }); + +describe("runWorkspaceBuild", () => { + it("keeps the cache/artifact contract aligned with the packaged dependency closure", async () => { + const workspaceRoot = fileURLToPath(new URL("../../../", import.meta.url)); + const packages = new Map }>(); + for (const scope of ["packages", "apps"]) { + for (const entry of await readdir(join(workspaceRoot, scope), { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const manifest = JSON.parse( + await readFile(join(workspaceRoot, scope, entry.name, "package.json"), "utf8"), + ) as { name?: string; dependencies?: Record; optionalDependencies?: Record }; + if (manifest.name != null) { + packages.set(manifest.name, { + dependencies: { ...manifest.dependencies, ...manifest.optionalDependencies }, + }); + } + } + } + const closure = new Set(); + const pending = ["@open-design/packaged"]; + while (pending.length > 0) { + const name = pending.pop()!; + if (closure.has(name)) continue; + closure.add(name); + for (const dependency of Object.keys(packages.get(name)?.dependencies ?? {})) { + if (packages.has(dependency)) pending.push(dependency); + } + } + closure.add("@open-design/dsh-runtime"); + + expect(WORKSPACE_BUILD_PACKAGES.map(({ name }) => name).sort()).toEqual([...closure].sort()); + }); + + it("leaves dependency order to pnpm while retaining packaging stages", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-workspace-runner-")); + const config = createConfig(root, join(root, ".cache")); + const calls: Array<{ args: string[]; env?: NodeJS.ProcessEnv }> = []; + + try { + await mkdir(join(root, "apps/web"), { recursive: true }); + await writeFile(join(root, "apps/web/next-env.d.ts"), "original\n", "utf8"); + await runWorkspaceBuild(config, async (args, env) => { + calls.push({ args, env }); + await writeFile(join(root, "apps/web/next-env.d.ts"), "generated\n", "utf8"); + }); + + expect(calls.map((call) => call.args)).toEqual(WORKSPACE_BUILD_COMMANDS.map((command) => [...command.args])); + expect(calls[1]?.env).toMatchObject({ OD_WEB_OUTPUT_MODE: "standalone" }); + expect(await readFile(join(root, "apps/web/next-env.d.ts"), "utf8")).toBe("original\n"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("restores generated Next typings when a build stage fails", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-workspace-runner-failure-")); + const config = createConfig(root, join(root, ".cache")); + + try { + await mkdir(join(root, "apps/web"), { recursive: true }); + await expect(runWorkspaceBuild(config, async () => { + await writeFile(join(root, "apps/web/next-env.d.ts"), "generated\n", "utf8"); + throw new Error("build failed"); + })).rejects.toThrow("build failed"); + await expect(readFile(join(root, "apps/web/next-env.d.ts"), "utf8")).rejects.toThrow(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); + +describe("createWorkspaceBuildCacheKey", () => { + it("witnesses every declared package source and ignores generated outputs", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-workspace-key-")); + const config = createConfig(root, join(root, ".cache")); + + try { + await writeWorkspace(root); + const baseline = await createWorkspaceBuildCacheKey(config); + await mkdir(join(root, "packages/sidecar/dist"), { recursive: true }); + await writeFile(join(root, "packages/sidecar/dist/generated.js"), "generated\n", "utf8"); + expect(await createWorkspaceBuildCacheKey(config)).toBe(baseline); + + for (const packageInfo of WORKSPACE_BUILD_PACKAGES) { + const sourcePath = join(root, packageInfo.directory, "src/index.ts"); + await writeFile(sourcePath, `export const value = ${JSON.stringify(packageInfo.name)};\n`, "utf8"); + expect(await createWorkspaceBuildCacheKey(config), packageInfo.name).not.toBe(baseline); + await writeFile(sourcePath, "export const value = 1;\n", "utf8"); + } + + expect(await createWorkspaceBuildCacheKey(config)).toBe(baseline); + + await writeFile(join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n", "utf8"); + const topologyKey = await createWorkspaceBuildCacheKey(config); + expect(topologyKey).not.toBe(baseline); + + await writeFile(join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.1'\n", "utf8"); + const lockKey = await createWorkspaceBuildCacheKey(config); + expect(lockKey).not.toBe(topologyKey); + + await writeFile(join(root, "package.json"), `${JSON.stringify({ packageManager: "pnpm@10.34.0" })}\n`, "utf8"); + const packageManagerKey = await createWorkspaceBuildCacheKey(config); + expect(packageManagerKey).not.toBe(lockKey); + expect(await createWorkspaceBuildCacheKey({ ...config, webOutputMode: "server" })).not.toBe(packageManagerKey); + expect(await createWorkspaceBuildCacheKey({ ...config, platform: "mac" })).not.toBe(packageManagerKey); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("witnesses every pure command, runtime, and schema determinant", () => { + const inputs = { + buildCommands: WORKSPACE_BUILD_COMMANDS, + node: "win.workspace-build", + nodeVersion: "v24.0.0", + packageHashes: Object.fromEntries(WORKSPACE_BUILD_PACKAGES.map(({ name }) => [name, `hash:${name}`])), + packageManager: "pnpm@10.33.2", + platform: "win" as const, + pnpmLock: "lock-hash", + pnpmWorkspace: "workspace-hash", + schemaVersion: WORKSPACE_BUILD_CACHE_SCHEMA_VERSION, + webOutputMode: "standalone" as const, + }; + const baseline = createWorkspaceBuildCacheKeyFromInputs(inputs); + + for (const [index, command] of WORKSPACE_BUILD_COMMANDS.entries()) { + const buildCommands = WORKSPACE_BUILD_COMMANDS.map((entry, entryIndex) => + entryIndex === index ? { ...entry, args: [...entry.args, "--witness"] } : entry, + ); + expect( + createWorkspaceBuildCacheKeyFromInputs({ ...inputs, buildCommands }), + `build command ${JSON.stringify(command.args)}`, + ).not.toBe(baseline); + for (const envName of "env" in command ? command.env : []) { + const envCommands = WORKSPACE_BUILD_COMMANDS.map((entry, entryIndex) => + entryIndex === index + ? { ...entry, env: [...("env" in entry ? entry.env : []), `${envName}_WITNESS`] } + : entry, + ); + expect( + createWorkspaceBuildCacheKeyFromInputs({ ...inputs, buildCommands: envCommands }), + `build command env ${envName}`, + ).not.toBe(baseline); + } + } + + expect(createWorkspaceBuildCacheKeyFromInputs({ ...inputs, node: "mac.workspace-build" })).not.toBe(baseline); + expect(createWorkspaceBuildCacheKeyFromInputs({ ...inputs, nodeVersion: "v24.0.1" })).not.toBe(baseline); + expect(createWorkspaceBuildCacheKeyFromInputs({ ...inputs, schemaVersion: inputs.schemaVersion + 1 })).not.toBe(baseline); + }); +});