diff --git a/package-lock.json b/package-lock.json index 473c90740..6777e1d29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,7 +73,6 @@ "version": "7.28.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -4296,7 +4295,6 @@ "version": "7.20.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -4398,7 +4396,6 @@ "version": "22.19.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4473,7 +4470,6 @@ "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cac": "^6.7.14", "colorette": "^2.0.20", @@ -4556,7 +4552,6 @@ "version": "6.12.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4759,6 +4754,7 @@ "version": "5.3.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -4776,6 +4772,7 @@ "version": "2.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -4796,6 +4793,7 @@ "version": "2.3.8", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -4809,12 +4807,14 @@ "node_modules/archiver-utils/node_modules/safe-buffer": { "version": "5.1.2", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -5128,6 +5128,7 @@ "version": "4.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -5191,7 +5192,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5682,6 +5682,7 @@ "version": "4.1.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -5811,6 +5812,7 @@ "version": "1.2.2", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -5822,6 +5824,7 @@ "version": "4.0.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -6187,7 +6190,6 @@ "version": "24.13.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -6354,6 +6356,7 @@ "version": "24.13.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -6365,6 +6368,7 @@ "version": "10.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -6378,6 +6382,7 @@ "version": "6.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -6389,6 +6394,7 @@ "version": "2.0.1", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -7123,7 +7129,8 @@ "node_modules/fs-constants": { "version": "1.0.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fs-extra": { "version": "8.1.0", @@ -8340,7 +8347,8 @@ "node_modules/isarray": { "version": "1.0.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isbinaryfile": { "version": "5.0.6", @@ -8390,7 +8398,6 @@ "version": "1.21.7", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8547,6 +8554,7 @@ "version": "1.0.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -8558,6 +8566,7 @@ "version": "2.3.8", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8571,12 +8580,14 @@ "node_modules/lazystream/node_modules/safe-buffer": { "version": "5.1.2", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8641,22 +8652,26 @@ "node_modules/lodash.defaults": { "version": "4.2.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.flatten": { "version": "4.4.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.sortby": { "version": "4.7.0", @@ -8668,7 +8683,8 @@ "node_modules/lodash.union": { "version": "4.6.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lowercase-keys": { "version": "2.0.0", @@ -8722,7 +8738,6 @@ "node_modules/marked": { "version": "12.0.2", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -9483,7 +9498,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9631,7 +9645,8 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/process-warning": { "version": "3.0.0", @@ -9880,6 +9895,7 @@ "version": "3.6.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -9893,6 +9909,7 @@ "version": "1.1.3", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -10195,7 +10212,6 @@ "version": "4.52.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10419,7 +10435,6 @@ "node_modules/seroval": { "version": "1.3.2", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -10743,7 +10758,6 @@ "node_modules/solid-js": { "version": "1.9.10", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", @@ -10884,6 +10898,7 @@ "version": "1.3.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -11217,6 +11232,7 @@ "version": "2.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -11409,7 +11425,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11659,7 +11674,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12007,7 +12021,6 @@ "version": "5.4.21", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12866,7 +12879,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13061,7 +13073,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -13350,6 +13361,7 @@ "version": "4.1.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -13363,6 +13375,7 @@ "version": "3.0.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -13382,7 +13395,6 @@ "node_modules/zod": { "version": "3.25.76", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/packages/electron-app/electron/main/client-state-election-child.ts b/packages/electron-app/electron/main/client-state-election-child.ts deleted file mode 100644 index 7be1585a4..000000000 --- a/packages/electron-app/electron/main/client-state-election-child.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { existsSync, writeFileSync } from "node:fs" -import { join } from "node:path" -import { - electClientStateProcess, - isPidAlive, - REGISTRATION_LOCK_WAIT_MS, - removeProcessOwnerLockIfOwned, - removeRunningMarkerIfOwned, - type ProcessOwner, -} from "./client-state-process" -import { getProcessStartIdentity } from "./client-state-process-identity" - -const [directory, runToken, startPath, registrationWaitArgument, primaryPausedPath, primaryReleasePath] = - process.argv.slice(2) -if (!directory || !runToken || !startPath) { - throw new Error("Expected election directory, run token, and start path") -} - -while (!existsSync(startPath)) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) -} - -const owner: ProcessOwner = { - pid: process.pid, - runToken, - processStartIdentity: getProcessStartIdentity(process.pid), -} -const primaryLockPath = join(directory, "client-state.primary.lock") -const registrationLockPath = join(directory, "client-state.registration.lock") -const registrationLockWaitMs = registrationWaitArgument - ? Number(registrationWaitArgument) - : REGISTRATION_LOCK_WAIT_MS -const warnings: string[] = [] -const election = electClientStateProcess( - directory, - owner, - { primaryLockPath, registrationLockPath }, - (message, error) => warnings.push(`${message}: ${String(error)}`), - isPidAlive, - registrationLockWaitMs, - () => { - if (!primaryPausedPath || !primaryReleasePath) { - return - } - try { - writeFileSync(primaryPausedPath, JSON.stringify(owner), { encoding: "utf8", flag: "wx" }) - } catch { - // Only the contender that published the synchronization point owns the pause gate. - return - } - while (!existsSync(primaryReleasePath)) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) - } - }, -) - -process.stdout.write(`${JSON.stringify({ isPrimary: election.isPrimary, owner, warnings })}\n`) -process.stdin.resume() -process.stdin.once("end", () => { - removeRunningMarkerIfOwned(election.runningMarkerPath, owner) - removeProcessOwnerLockIfOwned(primaryLockPath, owner) -}) diff --git a/packages/electron-app/electron/main/client-state-ipc-handlers.test.ts b/packages/electron-app/electron/main/client-state-ipc-handlers.test.ts deleted file mode 100644 index 0762d805f..000000000 --- a/packages/electron-app/electron/main/client-state-ipc-handlers.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import assert from "node:assert/strict" -import { mkdtempSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import test from "node:test" -import { ClientStateManager } from "./client-state" -import { - createClientStateIPCHandlers, - shouldResetRendererAccessTokenForNavigation, -} from "./client-state-ipc-handlers" - -test("only trusted full main-frame navigation resets renderer access", () => { - const trusted = (url: string) => new URL(url).origin === "http://127.0.0.1:3000" - - assert.equal( - shouldResetRendererAccessTokenForNavigation("http://127.0.0.1:3000/reload", false, true, trusted), - true, - ) - assert.equal( - shouldResetRendererAccessTokenForNavigation("http://127.0.0.1:3000/frame", false, false, trusted), - false, - ) - assert.equal( - shouldResetRendererAccessTokenForNavigation("http://127.0.0.1:3000/#route", true, true, trusted), - false, - ) - assert.equal( - shouldResetRendererAccessTokenForNavigation("https://untrusted.example/reload", false, true, trusted), - false, - ) -}) - -test("client-state handlers require the claimed nonempty renderer token", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-ipc-")) - const manager = new ClientStateManager(directory) - const handlers = createClientStateIPCHandlers(manager) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - - assert.throws(() => handlers.claimAccess(""), /nonempty string/) - assert.throws(() => handlers.load("not-claimed"), /has not been claimed/) - assert.equal(handlers.claimAccess("trusted-renderer-token"), true) - assert.equal(handlers.claimAccess("trusted-renderer-token"), true) - assert.throws(() => handlers.claimAccess("child-frame-token"), /does not match/) - - for (const invoke of [ - () => handlers.load("child-frame-token"), - () => handlers.save("child-frame-token", { denied: true }), - () => handlers.setRestoreEnabled("child-frame-token", false), - () => handlers.clear("child-frame-token"), - ]) { - assert.throws(invoke, /has not been claimed/) - } - - assert.equal(await handlers.save("trusted-renderer-token", { shutdownFlush: true }), true) - assert.deepEqual(handlers.load("trusted-renderer-token").snapshot, { shutdownFlush: true }) -}) - -test("trusted renderer navigation reset invalidates the old token before a new claim", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-token-reset-")) - const manager = new ClientStateManager(directory) - const handlers = createClientStateIPCHandlers(manager) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - - handlers.claimAccess("first-document") - manager.resetRendererAccessToken() - - assert.throws(() => handlers.load("first-document"), /has not been claimed/) - assert.equal(handlers.claimAccess("reloaded-document"), true) - assert.equal(handlers.load("reloaded-document").isPrimary, true) -}) diff --git a/packages/electron-app/electron/main/client-state-ipc-handlers.ts b/packages/electron-app/electron/main/client-state-ipc-handlers.ts deleted file mode 100644 index 3cda55b84..000000000 --- a/packages/electron-app/electron/main/client-state-ipc-handlers.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ClientStateManager } from "./client-state" - -export function shouldResetRendererAccessTokenForNavigation( - url: string, - isInPlace: boolean, - isMainFrame: boolean, - isTrustedOrigin: (url: string) => boolean, -): boolean { - return isMainFrame && !isInPlace && isTrustedOrigin(url) -} - -export function createRendererAccessNavigationCommitHandler( - clientState: Pick, - isTrustedOrigin: (url: string) => boolean, -) { - return (url: string, isInPlace: boolean, isMainFrame: boolean): void => { - if (shouldResetRendererAccessTokenForNavigation(url, isInPlace, isMainFrame, isTrustedOrigin)) { - clientState.resetRendererAccessToken() - } - } -} - -export function createClientStateIPCHandlers(clientState: ClientStateManager) { - const requireAccess = (token: unknown) => clientState.assertRendererAccessToken(token) - - return { - claimAccess(token: unknown) { - return clientState.claimClientStateAccess(token) - }, - load(token: unknown) { - requireAccess(token) - return clientState.loadClientState() - }, - save(token: unknown, snapshot: unknown) { - requireAccess(token) - return clientState.saveClientState(snapshot) - }, - setRestoreEnabled(token: unknown, enabled: unknown) { - requireAccess(token) - if (typeof enabled !== "boolean") { - throw new TypeError("Restore enabled must be a boolean") - } - return clientState.setRestoreEnabled(enabled) - }, - clear(token: unknown) { - requireAccess(token) - return clientState.clearClientState() - }, - } -} - -export type ClientStateIPCHandlers = ReturnType diff --git a/packages/electron-app/electron/main/client-state-ipc.ts b/packages/electron-app/electron/main/client-state-ipc.ts deleted file mode 100644 index 34231f9eb..000000000 --- a/packages/electron-app/electron/main/client-state-ipc.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from "electron" -import type { ClientStateManager } from "./client-state" -import { - createClientStateIPCHandlers, - createRendererAccessNavigationCommitHandler, -} from "./client-state-ipc-handlers" -import { isAllowedRendererOrigin } from "./permissions" - -function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow, getAllowedOrigins: () => string[]) { - if ( - mainWindow.isDestroyed() || - event.sender !== mainWindow.webContents || - event.senderFrame !== mainWindow.webContents.mainFrame - ) { - throw new Error("Client state IPC is only available to the local main window") - } - - const allowedOrigins = getAllowedOrigins() - const currentUrl = mainWindow.webContents.getURL() - if ( - !isAllowedRendererOrigin(currentUrl, allowedOrigins) || - !isAllowedRendererOrigin(event.senderFrame.url, allowedOrigins) || - new URL(currentUrl).origin !== new URL(event.senderFrame.url).origin - ) { - throw new Error("Client state IPC is not available to the current renderer origin") - } -} - -export function setupClientStateIPC( - mainWindow: BrowserWindow, - clientState: ClientStateManager, - getAllowedOrigins: () => string[], -) { - const handlers = createClientStateIPCHandlers(clientState) - const handleNavigationCommit = createRendererAccessNavigationCommitHandler( - clientState, - (url) => isAllowedRendererOrigin(url, getAllowedOrigins()), - ) - - ipcMain.handle("client-state:claimAccess", async (event, token: unknown) => { - validateSender(event, mainWindow, getAllowedOrigins) - return handlers.claimAccess(token) - }) - - ipcMain.handle("client-state:load", async (event, token: unknown) => { - validateSender(event, mainWindow, getAllowedOrigins) - return handlers.load(token) - }) - - ipcMain.handle("client-state:save", async (event, token: unknown, snapshot: unknown) => { - validateSender(event, mainWindow, getAllowedOrigins) - return handlers.save(token, snapshot) - }) - - ipcMain.handle("client-state:setRestoreEnabled", async (event, token: unknown, enabled: unknown) => { - validateSender(event, mainWindow, getAllowedOrigins) - return handlers.setRestoreEnabled(token, enabled) - }) - - ipcMain.handle("client-state:clear", async (event, token: unknown) => { - validateSender(event, mainWindow, getAllowedOrigins) - return handlers.clear(token) - }) - - mainWindow.webContents.on("did-navigate", (_event, url) => { - handleNavigationCommit(url, false, true) - }) -} diff --git a/packages/electron-app/electron/main/client-state-lifecycle.test.ts b/packages/electron-app/electron/main/client-state-lifecycle.test.ts deleted file mode 100644 index 8adf1c37f..000000000 --- a/packages/electron-app/electron/main/client-state-lifecycle.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import type { App, BrowserWindow } from "electron" -import { ClientStateLifecycle } from "./client-state-lifecycle" -import type { ClientStateManager } from "./client-state" -import type { CliProcessManager } from "./process-manager" -import type { WindowStateTracker } from "./window-state" - -function createHarness(options: { rendererFlush?: () => Promise; timeoutMs?: number } = {}) { - const handlers = new Map void>() - let nativeFlushes = 0 - let rendererFlushes = 0 - let primaryReleases = 0 - let cliStops = 0 - let exits = 0 - const window = { - on: (event: string, handler: () => void) => { - handlers.set(event, handler) - }, - isDestroyed: () => false, - close: () => {}, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:43123/workspace", - executeJavaScript: () => { - rendererFlushes += 1 - return options.rendererFlush?.() ?? Promise.resolve() - }, - }, - } as unknown as BrowserWindow - const app = { on: () => {}, quit: () => {}, exit: () => { exits += 1 } } as unknown as App - const clientStateManager = { - isPrimary: true, - drainAndReleasePrimary: async () => { primaryReleases += 1 }, - } as ClientStateManager - const cliManager = { stop: async () => { cliStops += 1 } } as unknown as CliProcessManager - const lifecycle = new ClientStateLifecycle({ - app, - clientStateManager, - cliManager, - getMainWindow: () => window, - getAllWindows: () => [window], - getAllowedRendererOrigins: () => ["http://127.0.0.1:43123"], - isTrustedRendererOrigin: () => true, - windowsSessionEndFlushTimeoutMs: options.timeoutMs, - rendererFlushTimeoutMs: options.timeoutMs === undefined ? undefined : options.timeoutMs * 2, - isWindows: true, - }) - const tracker = { flush: async () => { nativeFlushes += 1 } } as unknown as WindowStateTracker - lifecycle.attachMainWindow(window, tracker) - return { - handlers, - lifecycle, - getNativeFlushes: () => nativeFlushes, - getRendererFlushes: () => rendererFlushes, - getPrimaryReleases: () => primaryReleases, - getCliStops: () => cliStops, - getExits: () => exits, - } -} - -test("Windows session termination flushes renderer and native client state once", async () => { - const harness = createHarness() - - let prevented = false - harness.handlers.get("query-session-end")?.({ preventDefault: () => { prevented = true } }) - harness.handlers.get("session-end")?.() - await (harness.lifecycle as any).windowsSessionEndFlush - await new Promise((resolve) => setImmediate(resolve)) - - assert.equal(prevented, true) - assert.equal(harness.getRendererFlushes(), 1) - assert.equal(harness.getNativeFlushes(), 1) - assert.equal(harness.getPrimaryReleases(), 1) - assert.equal(harness.getCliStops(), 1) - assert.equal(harness.getExits(), 1) -}) - -test("Windows session termination flush is globally bounded", async () => { - const harness = createHarness({ rendererFlush: () => new Promise(() => {}), timeoutMs: 20 }) - const startedAt = Date.now() - - harness.handlers.get("query-session-end")?.({ preventDefault: () => {} }) - await (harness.lifecycle as any).windowsSessionEndFlush - await new Promise((resolve) => setImmediate(resolve)) - - assert.ok(Date.now() - startedAt < 500) - assert.equal(harness.getRendererFlushes(), 1) - assert.equal(harness.getNativeFlushes(), 0) - assert.equal(harness.getPrimaryReleases(), 0) - assert.equal(harness.getCliStops(), 0) - assert.equal(harness.getExits(), 1) -}) diff --git a/packages/electron-app/electron/main/client-state-lifecycle.ts b/packages/electron-app/electron/main/client-state-lifecycle.ts deleted file mode 100644 index a52d29c81..000000000 --- a/packages/electron-app/electron/main/client-state-lifecycle.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { App, BrowserWindow, Event } from "electron" -import type { ClientStateManager } from "./client-state" -import { MainWindowCloseController } from "./main-window-close" -import type { CliProcessManager } from "./process-manager" -import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush" -import type { WindowStateTracker } from "./window-state" - -const WINDOWS_SESSION_END_FLUSH_TIMEOUT_MS = 1_500 - -interface ClientStateLifecycleDependencies { - app: App - clientStateManager: ClientStateManager - cliManager: CliProcessManager - getMainWindow(): BrowserWindow | null - getAllWindows(): BrowserWindow[] - getAllowedRendererOrigins(window?: BrowserWindow | null): string[] - isTrustedRendererOrigin(url: string, allowedOrigins: string[]): boolean - windowsSessionEndFlushTimeoutMs?: number - rendererFlushTimeoutMs?: number - isWindows?: boolean -} - -export class ClientStateLifecycle { - private shutdownStarted = false - private shutdownExitAllowed = false - private trackedMainWindow: BrowserWindow | null = null - private windowStateTracker: WindowStateTracker | null = null - private windowsSessionEndFlush: Promise | null = null - private windowsSessionEndExitAllowed = false - - constructor(private readonly dependencies: ClientStateLifecycleDependencies) {} - - attachMainWindow(window: BrowserWindow, windowStateTracker: WindowStateTracker | null): void { - this.trackedMainWindow = window - this.windowStateTracker = windowStateTracker - - const closeController = new MainWindowCloseController({ - flushRenderer: () => this.flushRenderer(window, "main-window close"), - flushNative: () => this.flushNative(), - closeWindow: () => window.close(), - reportError: (stage, error) => { - console.warn(`[client-state] ${stage} main-window close flush failed; continuing close`, error) - }, - }) - - window.on("close", (event) => { - if (this.shutdownExitAllowed) return - if (this.shutdownStarted) { - event.preventDefault() - return - } - - const hasOtherWindow = this.dependencies - .getAllWindows() - .some((candidate) => candidate !== window && !candidate.isDestroyed()) - if (hasOtherWindow) { - const decision = closeController.handleClose() - if (!decision.allow) { - event.preventDefault() - } - return - } - - event.preventDefault() - this.dependencies.app.quit() - }) - - if (this.dependencies.isWindows ?? process.platform === "win32") { - window.on("query-session-end", (event: Event) => { - if (this.windowsSessionEndExitAllowed) return - event.preventDefault() - this.startWindowsSessionEnd(window) - }) - window.on("session-end", () => this.startWindowsSessionEnd(window)) - } - } - - detachMainWindow(window: BrowserWindow): void { - if (this.trackedMainWindow !== window) return - this.trackedMainWindow = null - this.windowStateTracker = null - } - - registerAppEvents(): void { - const { app } = this.dependencies - app.on("before-quit", (event) => this.handleBeforeQuit(event)) - app.on("window-all-closed", () => { - // Closing the final remaining window should quit the app on all platforms. - app.quit() - }) - } - - private async handleBeforeQuit(event: { preventDefault(): void }): Promise { - event.preventDefault() - if (this.shutdownStarted) return - this.shutdownStarted = true - - try { - await this.flushRenderer(this.dependencies.getMainWindow(), "shutdown") - } catch (error) { - console.warn("[client-state] renderer shutdown flush failed; continuing shutdown", error) - } - - try { - await this.flushNative() - } catch (error) { - console.warn("[client-state] failed to flush state during shutdown", error) - } - - try { - await this.dependencies.clientStateManager.drainAndReleasePrimary() - } catch (error) { - console.warn("[client-state] failed to drain state before releasing primary ownership", error) - } - - await this.dependencies.cliManager.stop().catch(() => {}) - this.shutdownExitAllowed = true - this.dependencies.app.exit(0) - } - - private async flushRenderer(window: BrowserWindow | null, context: "main-window close" | "shutdown"): Promise { - const result = await flushRendererClientStateBeforeShutdown( - window, - this.dependencies.clientStateManager.isPrimary, - (url) => - this.dependencies.isTrustedRendererOrigin(url, this.dependencies.getAllowedRendererOrigins(window)), - this.dependencies.rendererFlushTimeoutMs, - ) - if (result === "untrusted-origin") { - console.warn(`[client-state] skipped renderer ${context} flush for an untrusted origin`) - } - } - - private async flushNative(): Promise { - if (this.windowStateTracker) { - await this.windowStateTracker.flush() - } else { - await this.dependencies.clientStateManager.flush() - } - } - - private flushForWindowsSessionEnd(window: BrowserWindow): Promise { - if (this.windowsSessionEndFlush) return this.windowsSessionEndFlush - - const flush = async () => { - try { - await this.flushRenderer(window, "shutdown") - } catch (error) { - console.warn("[client-state] Windows session-end renderer flush failed", error) - } - try { - await this.flushNative() - } catch (error) { - console.warn("[client-state] Windows session-end native flush failed", error) - } - try { - await this.dependencies.clientStateManager.drainAndReleasePrimary() - } catch (error) { - console.warn("[client-state] Windows session-end primary release failed", error) - } - try { - await this.dependencies.cliManager.stop() - } catch (error) { - console.warn("[client-state] Windows session-end CLI stop failed", error) - } - } - this.windowsSessionEndFlush = Promise.race([ - flush(), - new Promise((resolve) => setTimeout( - resolve, - this.dependencies.windowsSessionEndFlushTimeoutMs ?? WINDOWS_SESSION_END_FLUSH_TIMEOUT_MS, - )), - ]) - return this.windowsSessionEndFlush - } - - private startWindowsSessionEnd(window: BrowserWindow): void { - if (this.windowsSessionEndFlush) return - void this.flushForWindowsSessionEnd(window).finally(() => { - this.windowsSessionEndExitAllowed = true - this.dependencies.app.exit(0) - }) - } -} diff --git a/packages/electron-app/electron/main/client-state-navigation.test.ts b/packages/electron-app/electron/main/client-state-navigation.test.ts deleted file mode 100644 index 29068a7f7..000000000 --- a/packages/electron-app/electron/main/client-state-navigation.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import assert from "node:assert/strict" -import { mkdtempSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import test from "node:test" -import { - ClientStateIPCHandlers, - createClientStateIPCHandlers, - createRendererAccessNavigationCommitHandler, -} from "./client-state-ipc-handlers" -import { ClientStateNavigationController } from "./client-state-navigation" -import { ClientStateManager } from "./client-state" - -test("edit then immediate reload persists the latest snapshot and rotates renderer access", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-navigation-")) - const manager = new ClientStateManager(directory) - const handlers: ClientStateIPCHandlers = createClientStateIPCHandlers(manager) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - - handlers.claimAccess("outgoing-document") - let navigated = false - const window = { - isDestroyed: () => false, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:3000/app", - executeJavaScript: async () => { - await handlers.save("outgoing-document", { revision: 7, editor: "latest" }) - }, - }, - } - const controller = new ClientStateNavigationController({ - clientStateManager: manager, - getWindow: () => window as never, - isTrustedOrigin: () => true, - reportFlushError: (error) => assert.fail(String(error)), - }) - const commitNavigation = createRendererAccessNavigationCommitHandler(manager, () => true) - - await controller.navigate(async () => { - navigated = true - assert.deepEqual(handlers.load("outgoing-document").snapshot, { revision: 7, editor: "latest" }) - commitNavigation("http://127.0.0.1:3000/app", false, true) - }) - - assert.equal(navigated, true) - assert.throws(() => handlers.load("outgoing-document"), /has not been claimed/) - handlers.claimAccess("new-document") - assert.deepEqual(handlers.load("new-document").snapshot, { revision: 7, editor: "latest" }) -}) - -test("failed loadURL retains access for the current document", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-navigation-failure-")) - const manager = new ClientStateManager(directory) - const handlers = createClientStateIPCHandlers(manager) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - handlers.claimAccess("current-document") - - const controller = new ClientStateNavigationController({ - clientStateManager: manager, - getWindow: () => ({ - isDestroyed: () => false, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:3000/app", - executeJavaScript: async () => {}, - }, - }) as never, - isTrustedOrigin: () => true, - reportFlushError: (error) => assert.fail(String(error)), - }) - - await assert.rejects(controller.navigate(() => Promise.reject(new Error("loadURL failed"))), /loadURL failed/) - assert.equal(await handlers.save("current-document", { retained: "after-loadURL" }), true) - assert.deepEqual(handlers.load("current-document").snapshot, { retained: "after-loadURL" }) -}) - -test("failed reload retains access for the current document", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-reload-failure-")) - const manager = new ClientStateManager(directory) - const handlers = createClientStateIPCHandlers(manager) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - handlers.claimAccess("current-document") - - const controller = new ClientStateNavigationController({ - clientStateManager: manager, - getWindow: () => ({ - isDestroyed: () => false, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:3000/app", - executeJavaScript: async () => {}, - }, - }) as never, - isTrustedOrigin: () => true, - reportFlushError: (error) => assert.fail(String(error)), - }) - - await assert.rejects(controller.navigate(() => { throw new Error("reload failed") }), /reload failed/) - assert.equal(await handlers.save("current-document", { retained: "after-reload" }), true) -}) - -test("hung renderer flush is bounded and does not deadlock reload", async () => { - const manager = { - isPrimary: true, - resetRendererAccessTokenCalls: 0, - resetRendererAccessToken() { - this.resetRendererAccessTokenCalls += 1 - }, - } - let navigated = false - let reported = false - const controller = new ClientStateNavigationController({ - clientStateManager: manager, - getWindow: () => ({ - isDestroyed: () => false, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:3000/app", - executeJavaScript: () => new Promise(() => {}), - }, - }) as never, - isTrustedOrigin: () => true, - reportFlushError: () => { - reported = true - }, - }) - - const startedAt = Date.now() - await controller.navigate(() => { - navigated = true - }) - const elapsedMs = Date.now() - startedAt - - assert.equal(reported, true) - assert.equal(manager.resetRendererAccessTokenCalls, 0) - assert.equal(navigated, true) - assert.ok(elapsedMs >= 900, `flush timeout ended too early after ${elapsedMs}ms`) - assert.ok(elapsedMs < 2_000, `flush timeout was not bounded: ${elapsedMs}ms`) -}) diff --git a/packages/electron-app/electron/main/client-state-navigation.ts b/packages/electron-app/electron/main/client-state-navigation.ts deleted file mode 100644 index 8e27331e4..000000000 --- a/packages/electron-app/electron/main/client-state-navigation.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { BrowserWindow } from "electron" -import type { ClientStateManager } from "./client-state" -import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush" - -interface ClientStateNavigationDependencies { - clientStateManager: Pick - getWindow(): BrowserWindow | null - isTrustedOrigin(url: string): boolean - reportFlushError(error: unknown): void -} - -export class ClientStateNavigationController { - private queue: Promise = Promise.resolve() - - constructor(private readonly dependencies: ClientStateNavigationDependencies) {} - - navigate(operation: (window: BrowserWindow) => void | Promise): Promise { - const request = this.queue.catch(() => {}).then(() => this.performNavigation(operation)) - this.queue = request - return request - } - - private async performNavigation(operation: (window: BrowserWindow) => void | Promise): Promise { - const window = this.dependencies.getWindow() - if (!window || window.isDestroyed() || window.webContents.isDestroyed()) return - - try { - await flushRendererClientStateBeforeShutdown( - window, - this.dependencies.clientStateManager.isPrimary, - this.dependencies.isTrustedOrigin, - ) - } catch (error) { - this.dependencies.reportFlushError(error) - } - - if (window.isDestroyed() || window.webContents.isDestroyed()) return - await operation(window) - } -} diff --git a/packages/electron-app/electron/main/client-state-process-identity.ts b/packages/electron-app/electron/main/client-state-process-identity.ts deleted file mode 100644 index 7c189c7fc..000000000 --- a/packages/electron-app/electron/main/client-state-process-identity.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { spawnSync } from "node:child_process" -import { readFileSync } from "node:fs" - -export type ProcessStartIdentityLookup = (pid: number) => string | undefined - -function readLinuxProcessStartIdentity(pid: number): string | undefined { - const stat = readFileSync(`/proc/${pid}/stat`, "utf8") - const commandEnd = stat.lastIndexOf(")") - if (commandEnd < 0) return undefined - - // Fields after the command begin with field 3; process start time is field 22. - const fields = stat.slice(commandEnd + 1).trim().split(/\s+/) - const startTicks = fields[19] - if (!startTicks) return undefined - - const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() - return bootId ? `linux:${bootId}:${startTicks}` : undefined -} - -function readCommandIdentity(command: string, args: string[], prefix: string): string | undefined { - for (let attempt = 0; attempt < 2; attempt += 1) { - const result = spawnSync(command, args, { - encoding: "utf8", - windowsHide: true, - timeout: 5_000, - }) - if (result.status === 0 && !result.error) { - const value = result.stdout.trim() - if (value) return `${prefix}:${value}` - } - } - return undefined -} - -export function getProcessStartIdentity(pid: number): string | undefined { - if (!Number.isInteger(pid) || pid <= 0) return undefined - - try { - if (process.platform === "linux") { - return readLinuxProcessStartIdentity(pid) - } - if (process.platform === "darwin") { - return readCommandIdentity("ps", ["-p", String(pid), "-o", "lstart="], "darwin") - } - if (process.platform === "win32") { - return readCommandIdentity( - "powershell.exe", - [ - "-NoProfile", - "-NonInteractive", - "-Command", - `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`, - ], - "win32", - ) - } - } catch { - // Identity lookup is best-effort; callers preserve election safety when it is unavailable. - } - - return undefined -} diff --git a/packages/electron-app/electron/main/client-state-process.test.ts b/packages/electron-app/electron/main/client-state-process.test.ts deleted file mode 100644 index 796e63c61..000000000 --- a/packages/electron-app/electron/main/client-state-process.test.ts +++ /dev/null @@ -1,483 +0,0 @@ -import assert from "node:assert/strict" -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" -import { randomUUID } from "node:crypto" -import { once } from "node:events" -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { fileURLToPath } from "node:url" -import test from "node:test" -import { - cleanStaleRunningMarkers, - createRunningMarker, - electClientStateProcess, - getRunningMarkerPath, - REGISTRATION_LOCK_WAIT_MS, - removeProcessOwnerLockIfOwned, - removeRunningMarkerIfOwned, - type ProcessOwner, -} from "./client-state-process" -import { getProcessStartIdentity } from "./client-state-process-identity" - -function withTempDirectory(testContext: test.TestContext): string { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-")) - testContext.after(() => rmSync(directory, { recursive: true, force: true })) - return directory -} - -interface ElectionChild { - process: ChildProcessWithoutNullStreams - result: Promise -} - -interface ElectionChildResult { - isPrimary: boolean - owner: ProcessOwner - warnings: string[] -} - -interface ElectionChildOptions { - registrationLockWaitMs?: number - primaryPausedPath?: string - primaryReleasePath?: string -} - -function startElectionChild( - directory: string, - startPath: string, - options: ElectionChildOptions = {}, -): ElectionChild { - const childPath = fileURLToPath(new URL("./client-state-election-child.ts", import.meta.url)) - const child = spawn(process.execPath, [ - "--import", - "tsx", - childPath, - directory, - randomUUID(), - startPath, - options.registrationLockWaitMs?.toString() ?? "", - options.primaryPausedPath ?? "", - options.primaryReleasePath ?? "", - ]) - child.stdout.setEncoding("utf8") - child.stderr.setEncoding("utf8") - - const result = new Promise((resolve, reject) => { - let stdout = "" - let stderr = "" - let settled = false - child.stdout.on("data", (chunk: string) => { - stdout += chunk - const newline = stdout.indexOf("\n") - if (newline < 0 || settled) { - return - } - settled = true - resolve(JSON.parse(stdout.slice(0, newline)) as ElectionChildResult) - }) - child.stderr.on("data", (chunk: string) => { - stderr += chunk - }) - child.once("error", (error) => { - if (!settled) { - settled = true - reject(error) - } - }) - child.once("exit", (code) => { - if (!settled) { - settled = true - reject(new Error(`Election child exited with code ${code}: ${stderr}`)) - } - }) - }) - - return { process: child, result } -} - -async function stopElectionChildren(children: ElectionChild[]) { - const exits = children.map((child) => once(child.process, "exit")) - for (const child of children) { - child.process.stdin.end() - } - await Promise.all(exits) -} - -async function waitForFile(path: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs - while (!existsSync(path)) { - if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for ${path}`) - } - await new Promise((resolve) => setTimeout(resolve, 5)) - } -} - -test("the current process exposes a stable OS start identity", () => { - const first = getProcessStartIdentity(process.pid) - const second = getProcessStartIdentity(process.pid) - - assert.ok(first, `process start identity is unavailable on ${process.platform}`) - assert.equal(second, first) -}) - -test("a live secondary marker prevents a successor from becoming primary", (testContext) => { - const directory = withTempDirectory(testContext) - const exitedPrimary: ProcessOwner = { pid: 101, runToken: "exited-primary" } - const liveSecondary: ProcessOwner = { pid: 202, runToken: "live-secondary" } - const successor: ProcessOwner = { pid: 303, runToken: "successor" } - - createRunningMarker(directory, exitedPrimary) - createRunningMarker(directory, liveSecondary) - createRunningMarker(directory, successor) - - const hasOtherLiveProcess = cleanStaleRunningMarkers(directory, successor, (pid) => pid === liveSecondary.pid) - - assert.equal(hasOtherLiveProcess, true) - assert.equal(existsSync(getRunningMarkerPath(directory, exitedPrimary)), false) - assert.equal(existsSync(getRunningMarkerPath(directory, liveSecondary)), true) - assert.equal(existsSync(getRunningMarkerPath(directory, successor)), true) -}) - -test("a reused PID marker with a different run token is stale", (testContext) => { - const directory = withTempDirectory(testContext) - const oldRun: ProcessOwner = { pid: 404, runToken: "old-run" } - const currentRun: ProcessOwner = { pid: 404, runToken: "current-run" } - - createRunningMarker(directory, oldRun) - createRunningMarker(directory, currentRun) - - assert.equal(cleanStaleRunningMarkers(directory, currentRun, () => true), false) - assert.equal(existsSync(getRunningMarkerPath(directory, oldRun)), false) - assert.equal(existsSync(getRunningMarkerPath(directory, currentRun)), true) -}) - -test("a marker from a crashed process is stale when its PID has been reused", (testContext) => { - const directory = withTempDirectory(testContext) - const crashedOwner: ProcessOwner = { - pid: 404, - runToken: "crashed-run", - processStartIdentity: "start:old", - } - const currentOwner: ProcessOwner = { - pid: 505, - runToken: "current-run", - processStartIdentity: "start:current", - } - createRunningMarker(directory, crashedOwner) - - const hasOtherLiveProcess = cleanStaleRunningMarkers( - directory, - currentOwner, - () => true, - (pid) => pid === crashedOwner.pid ? "start:reused" : currentOwner.processStartIdentity, - ) - - assert.equal(hasOtherLiveProcess, false) - assert.equal(existsSync(getRunningMarkerPath(directory, crashedOwner)), false) -}) - -test("an identity lookup failure remains conservative for election safety", (testContext) => { - const directory = withTempDirectory(testContext) - const unverifiedOwner: ProcessOwner = { - pid: 606, - runToken: "unverified-run", - processStartIdentity: "start:missing", - } - const currentOwner: ProcessOwner = { pid: 707, runToken: "current-run" } - createRunningMarker(directory, unverifiedOwner) - - assert.equal(cleanStaleRunningMarkers(directory, currentOwner, () => true, () => undefined), true) - assert.equal(existsSync(getRunningMarkerPath(directory, unverifiedOwner)), true) -}) - -test("a secondary that observed the current primary does not make it relinquish ownership", (testContext) => { - const directory = withTempDirectory(testContext) - const currentPrimary: ProcessOwner = { pid: 303, runToken: "current-primary" } - const overlappingSecondary: ProcessOwner = { pid: 404, runToken: "overlapping-secondary" } - createRunningMarker(directory, overlappingSecondary, currentPrimary) - - const hasBlockingProcess = cleanStaleRunningMarkers( - directory, - currentPrimary, - (pid) => pid === overlappingSecondary.pid, - ) - - assert.equal(hasBlockingProcess, false) - assert.equal(existsSync(getRunningMarkerPath(directory, overlappingSecondary)), true) -}) - -test("shutdown does not remove a marker whose ownership changed", (testContext) => { - const directory = withTempDirectory(testContext) - const owner: ProcessOwner = { pid: 505, runToken: "owned-run" } - const replacement: ProcessOwner = { pid: 606, runToken: "replacement-run" } - const markerPath = createRunningMarker(directory, owner) - writeFileSync(markerPath, JSON.stringify(replacement), "utf8") - - assert.equal(removeRunningMarkerIfOwned(markerPath, owner), false) - assert.deepEqual(JSON.parse(readFileSync(markerPath, "utf8")), replacement) -}) - -test("simultaneous process registration elects exactly one primary", async (testContext) => { - const directory = withTempDirectory(testContext) - const startPath = join(directory, "start") - const children = [startElectionChild(directory, startPath), startElectionChild(directory, startPath)] - writeFileSync(startPath, "", "utf8") - - const roles = await Promise.all(children.map((child) => child.result)) - await stopElectionChildren(children) - - assert.equal(roles.filter((role) => role.isPrimary).length, 1) -}) - -test("an unrelated live PID in a stale registration file still elects exactly one primary", async (testContext) => { - const directory = withTempDirectory(testContext) - const registrationLockPath = join(directory, "client-state.registration.lock") - writeFileSync( - registrationLockPath, - JSON.stringify({ pid: process.pid, runToken: "unrelated-live-process" }), - "utf8", - ) - const startPath = join(directory, "start") - const children = [startElectionChild(directory, startPath), startElectionChild(directory, startPath)] - writeFileSync(startPath, "", "utf8") - - const roles = await Promise.all(children.map((child) => child.result)) - await stopElectionChildren(children) - - assert.equal(roles.filter((role) => role.isPrimary).length, 1) -}) - -async function assertStaleLivePidPrimaryRecovery(testContext: test.TestContext, round: number) { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const crashedOwner: ProcessOwner = { - pid: process.pid, - runToken: `reused-live-primary-${round}`, - processStartIdentity: `old-process-start-${round}`, - } - writeFileSync( - primaryLockPath, - JSON.stringify(crashedOwner), - "utf8", - ) - createRunningMarker(directory, crashedOwner) - const startPath = join(directory, "start") - const options: ElectionChildOptions = { registrationLockWaitMs: 40 } - const children = [startElectionChild(directory, startPath, options), startElectionChild(directory, startPath, options)] - let stopped = false - try { - writeFileSync(startPath, "", "utf8") - const roles = await Promise.all(children.map((child) => child.result)) - assert.equal( - roles.filter((role) => role.isPrimary).length, - 1, - JSON.stringify({ round, roles }), - ) - await stopElectionChildren(children) - stopped = true - } finally { - if (!stopped) await stopElectionChildren(children) - } -} - -test("a crash-left primary marker whose PID was reused still elects exactly one primary", async (testContext) => { - await assertStaleLivePidPrimaryRecovery(testContext, 0) -}) - -test("simultaneous reused-PID recovery remains single-primary under stress", async (testContext) => { - for (let round = 1; round <= 20; round += 1) { - await assertStaleLivePidPrimaryRecovery(testContext, round) - } -}) - -test("overlapping stale-registration recovery cannot leave every contender secondary", async (testContext) => { - const directory = withTempDirectory(testContext) - const registrationLockPath = join(directory, "client-state.registration.lock") - const startPath = join(directory, "start") - const primaryPausedPath = join(directory, "primary-paused") - const primaryReleasePath = join(directory, "primary-release") - writeFileSync( - registrationLockPath, - JSON.stringify({ pid: process.pid, runToken: "unrelated-live-process" }), - "utf8", - ) - const options: ElectionChildOptions = { - registrationLockWaitMs: 40, - primaryPausedPath, - primaryReleasePath, - } - const children = [startElectionChild(directory, startPath, options), startElectionChild(directory, startPath, options)] - let stopped = false - try { - writeFileSync(startPath, "", "utf8") - - await waitForFile(primaryPausedPath, 2_000) - await Promise.race(children.map((child) => child.result)) - writeFileSync(primaryReleasePath, "", "utf8") - const roles = await Promise.all(children.map((child) => child.result)) - const electionFiles = Object.fromEntries( - readdirSync(directory) - .filter((filename) => filename.startsWith("client-state.")) - .map((filename) => [filename, readFileSync(join(directory, filename), "utf8")]), - ) - assert.equal( - roles.filter((role) => role.isPrimary).length, - 1, - JSON.stringify({ roles, electionFiles }), - ) - await stopElectionChildren(children) - stopped = true - } finally { - if (!existsSync(primaryReleasePath)) { - writeFileSync(primaryReleasePath, "", "utf8") - } - if (!stopped) { - await stopElectionChildren(children) - } - } -}) - -test("a new process remains secondary after primary exits while an older secondary lives", async (testContext) => { - const directory = withTempDirectory(testContext) - const firstStart = join(directory, "start-first") - const first = startElectionChild(directory, firstStart) - writeFileSync(firstStart, "", "utf8") - assert.equal((await first.result).isPrimary, true) - - const secondStart = join(directory, "start-second") - const second = startElectionChild(directory, secondStart) - writeFileSync(secondStart, "", "utf8") - assert.equal((await second.result).isPrimary, false) - await stopElectionChildren([first]) - - const thirdStart = join(directory, "start-third") - const third = startElectionChild(directory, thirdStart) - writeFileSync(thirdStart, "", "utf8") - const thirdRole = await third.result - await stopElectionChildren([second, third]) - - assert.equal(thirdRole.isPrimary, false) -}) - -test("a primary lock with the current PID and an old token is reclaimed", (testContext) => { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const registrationLockPath = join(directory, "client-state.registration.lock") - const owner: ProcessOwner = { pid: process.pid, runToken: "current-run" } - writeFileSync(primaryLockPath, JSON.stringify({ pid: process.pid, runToken: "old-run" }), "utf8") - - const election = electClientStateProcess(directory, owner, { primaryLockPath, registrationLockPath }) - - assert.equal(election.isPrimary, true) - removeRunningMarkerIfOwned(election.runningMarkerPath, owner) - removeProcessOwnerLockIfOwned(primaryLockPath, owner) -}) - -test("a malformed marker-backed lock cannot keep the outer recovery loop alive", (testContext) => { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const registrationLockPath = join(directory, "client-state.registration.lock") - const owner: ProcessOwner = { pid: process.pid, runToken: "bounded-recovery" } - writeFileSync(registrationLockPath, "malformed", "utf8") - - const startedAt = Date.now() - const election = electClientStateProcess( - directory, - owner, - { primaryLockPath, registrationLockPath }, - () => {}, - () => true, - 0, - ) - - assert.ok(Date.now() - startedAt < 500) - assert.equal(election.isPrimary, false) - removeRunningMarkerIfOwned(election.runningMarkerPath, owner) -}) - -test("a live registration-lock owner with a matching marker remains conservative", (testContext) => { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const registrationLockPath = join(directory, "client-state.registration.lock") - const stuckOwner: ProcessOwner = { pid: 8181, runToken: "stuck-registration" } - const currentOwner: ProcessOwner = { pid: 9191, runToken: "current-registration" } - const boundedTestWaitMs = 30 - writeFileSync(registrationLockPath, JSON.stringify(stuckOwner), "utf8") - createRunningMarker(directory, stuckOwner) - - const startedAt = Date.now() - const election = electClientStateProcess( - directory, - currentOwner, - { primaryLockPath, registrationLockPath }, - () => {}, - (pid) => pid === stuckOwner.pid, - boundedTestWaitMs, - ) - const elapsedMs = Date.now() - startedAt - - assert.equal(election.isPrimary, false) - assert.ok(elapsedMs >= boundedTestWaitMs - 5, `registration wait ended too early after ${elapsedMs}ms`) - assert.ok(elapsedMs < 500, `registration wait was not bounded: ${elapsedMs}ms`) - assert.equal(existsSync(election.runningMarkerPath), true) - assert.deepEqual(JSON.parse(readFileSync(registrationLockPath, "utf8")), stuckOwner) - assert.equal(REGISTRATION_LOCK_WAIT_MS, 1_000) - removeRunningMarkerIfOwned(election.runningMarkerPath, currentOwner) -}) - -test("a verified live registration owner is not stolen before publishing its marker", (testContext) => { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const registrationLockPath = join(directory, "client-state.registration.lock") - const registeringOwner: ProcessOwner = { - pid: 8282, - runToken: "registering-process", - processStartIdentity: "start:registering", - } - const currentOwner: ProcessOwner = { pid: 9292, runToken: "current-process" } - writeFileSync(registrationLockPath, JSON.stringify(registeringOwner), "utf8") - - const election = electClientStateProcess( - directory, - currentOwner, - { primaryLockPath, registrationLockPath }, - () => {}, - (pid) => pid === registeringOwner.pid, - 30, - () => {}, - (pid) => pid === registeringOwner.pid ? registeringOwner.processStartIdentity : undefined, - ) - - assert.equal(election.isPrimary, false) - assert.deepEqual(JSON.parse(readFileSync(registrationLockPath, "utf8")), registeringOwner) - removeRunningMarkerIfOwned(election.runningMarkerPath, currentOwner) -}) - -test("a live primary-lock owner with a matching marker remains conservative", (testContext) => { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const registrationLockPath = join(directory, "client-state.registration.lock") - const primaryOwner: ProcessOwner = { pid: 7171, runToken: "live-primary" } - const currentOwner: ProcessOwner = { pid: 8181, runToken: "current-process" } - const boundedTestWaitMs = 30 - writeFileSync(primaryLockPath, JSON.stringify(primaryOwner), "utf8") - createRunningMarker(directory, primaryOwner) - - const startedAt = Date.now() - const election = electClientStateProcess( - directory, - currentOwner, - { primaryLockPath, registrationLockPath }, - () => {}, - (pid) => pid === primaryOwner.pid, - boundedTestWaitMs, - ) - const elapsedMs = Date.now() - startedAt - - assert.equal(election.isPrimary, false) - assert.ok(elapsedMs >= boundedTestWaitMs - 5, `primary wait ended too early after ${elapsedMs}ms`) - assert.ok(elapsedMs < 500, `primary wait was not bounded: ${elapsedMs}ms`) - assert.deepEqual(JSON.parse(readFileSync(primaryLockPath, "utf8")), primaryOwner) - removeRunningMarkerIfOwned(election.runningMarkerPath, currentOwner) -}) diff --git a/packages/electron-app/electron/main/client-state-process.ts b/packages/electron-app/electron/main/client-state-process.ts deleted file mode 100644 index 55003374e..000000000 --- a/packages/electron-app/electron/main/client-state-process.ts +++ /dev/null @@ -1,565 +0,0 @@ -import { closeSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs" -import { basename, join } from "node:path" -import { - getProcessStartIdentity, - type ProcessStartIdentityLookup, -} from "./client-state-process-identity" - -const RUNNING_MARKER_PREFIX = "client-state.running." -const RUNNING_MARKER_SUFFIX = ".json" -const PRIMARY_LOCK_ACQUIRE_ATTEMPTS = 5 -const LOCK_RETRY_DELAY_MS = 10 -export const REGISTRATION_LOCK_WAIT_MS = 1_000 - -export interface ProcessOwner { - pid: number - runToken: string - processStartIdentity?: string -} - -export type RunningMarkerStatus = "current" | "other-live" | "stale" - -export interface ClientStateElectionPaths { - primaryLockPath: string - registrationLockPath: string -} - -export interface ClientStateElectionResult { - isPrimary: boolean - runningMarkerPath: string -} - -interface ProcessOwnerLockAcquisition { - acquired: boolean - liveOwner?: { - owner: ProcessOwner - observed: string - } -} - -export function hasErrorCode(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code -} - -function isTransientFileContentionError(error: unknown): boolean { - return hasErrorCode(error, "EPERM") || hasErrorCode(error, "EACCES") || hasErrorCode(error, "EBUSY") -} - -export function parseProcessOwner(value: string): ProcessOwner | undefined { - try { - return normalizeProcessOwner(JSON.parse(value)) - } catch { - // Incomplete process files are handled conservatively using their filename owner. - } - return undefined -} - -function normalizeProcessOwner(candidate: unknown): ProcessOwner | undefined { - if (!candidate || typeof candidate !== "object") { - return undefined - } - const owner = candidate as Partial - if (Number.isInteger(owner.pid) && Number(owner.pid) > 0 && typeof owner.runToken === "string" && owner.runToken) { - return { - pid: Number(owner.pid), - runToken: owner.runToken, - ...(typeof owner.processStartIdentity === "string" && owner.processStartIdentity - ? { processStartIdentity: owner.processStartIdentity } - : {}), - } - } - return undefined -} - -function parseAcknowledgedPrimary(value: string): ProcessOwner | undefined { - try { - const candidate = JSON.parse(value) as { primaryOwner?: unknown } - return normalizeProcessOwner(candidate.primaryOwner) - } catch { - return undefined - } -} - -export function isSameProcessOwner(left: ProcessOwner, right: ProcessOwner): boolean { - return left.pid === right.pid && left.runToken === right.runToken -} - -export function isPidAlive(pid: number): boolean { - if (pid === process.pid) { - return true - } - try { - process.kill(pid, 0) - return true - } catch (error) { - return !hasErrorCode(error, "ESRCH") - } -} - -export function classifyRunningMarker( - markerOwner: ProcessOwner, - currentOwner: ProcessOwner, - pidAlive: (pid: number) => boolean = isPidAlive, - processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, -): RunningMarkerStatus { - if (isSameProcessOwner(markerOwner, currentOwner)) { - return "current" - } - // Two live processes cannot share a PID. A different token therefore belongs to an old run. - if (markerOwner.pid === currentOwner.pid) { - return "stale" - } - if (!pidAlive(markerOwner.pid)) return "stale" - if (markerOwner.processStartIdentity) { - const liveIdentity = processStartIdentity(markerOwner.pid) - if (liveIdentity && liveIdentity !== markerOwner.processStartIdentity) return "stale" - } - return "other-live" -} - -export function getRunningMarkerPath(userDataPath: string, owner: ProcessOwner): string { - return join(userDataPath, `${RUNNING_MARKER_PREFIX}${owner.pid}.${owner.runToken}${RUNNING_MARKER_SUFFIX}`) -} - -function parseRunningMarkerFilename(filename: string): ProcessOwner | undefined { - if (!filename.startsWith(RUNNING_MARKER_PREFIX) || !filename.endsWith(RUNNING_MARKER_SUFFIX)) { - return undefined - } - - const value = filename.slice(RUNNING_MARKER_PREFIX.length, -RUNNING_MARKER_SUFFIX.length) - const separator = value.indexOf(".") - if (separator < 1) { - return undefined - } - const pid = Number(value.slice(0, separator)) - const runToken = value.slice(separator + 1) - if (!Number.isInteger(pid) || pid <= 0 || !runToken) { - return undefined - } - return { pid, runToken } -} - -export function createRunningMarker( - userDataPath: string, - owner: ProcessOwner, - primaryOwner?: ProcessOwner, -): string { - const markerPath = getRunningMarkerPath(userDataPath, owner) - let descriptor: number | undefined - let created = false - try { - descriptor = openSync(markerPath, "wx", 0o600) - created = true - writeFileSync(descriptor, JSON.stringify(primaryOwner ? { ...owner, primaryOwner } : owner), "utf8") - closeSync(descriptor) - return markerPath - } catch (error) { - if (descriptor !== undefined) { - try { - closeSync(descriptor) - } catch { - // Ignore cleanup errors after a failed marker write. - } - } - if (created) { - try { - unlinkSync(markerPath) - } catch { - // The unique run-token path cannot be owned by another process. - } - } - throw error - } -} - -function removeFileIfUnchanged(path: string, observed: string): boolean { - try { - if (readFileSync(path, "utf8") !== observed) { - return false - } - unlinkSync(path) - return true - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - return false - } - throw error - } -} - -function waitForLockRetry(delayMs = LOCK_RETRY_DELAY_MS) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, delayMs)) -} - -function acquireProcessOwnerLockWithStatus( - path: string, - owner: ProcessOwner, - waitForLiveOwner: boolean, - pidAlive: (pid: number) => boolean = isPidAlive, - liveOwnerWaitMs = REGISTRATION_LOCK_WAIT_MS, - processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, -): ProcessOwnerLockAcquisition { - const serializedOwner = JSON.stringify(owner) - const waitDeadline = Date.now() + Math.max(0, liveOwnerWaitMs) - let liveOwner: ProcessOwnerLockAcquisition["liveOwner"] - - for (let attempt = 0; ; attempt += 1) { - if ( - (!waitForLiveOwner && attempt >= PRIMARY_LOCK_ACQUIRE_ATTEMPTS) || - (waitForLiveOwner && attempt > 0 && Date.now() >= waitDeadline) - ) { - return { acquired: false, liveOwner } - } - - let descriptor: number | undefined - let created = false - try { - descriptor = openSync(path, "wx", 0o600) - created = true - writeFileSync(descriptor, serializedOwner, "utf8") - closeSync(descriptor) - return { acquired: true } - } catch (error) { - if (descriptor !== undefined) { - try { - closeSync(descriptor) - } catch { - // Ignore cleanup errors after a failed exclusive create. - } - } - if (created) { - try { - unlinkSync(path) - } catch { - // The lock remains conservative if cleanup fails. - } - } - if (!hasErrorCode(error, "EEXIST")) { - throw error - } - } - - let observed: string - try { - observed = readFileSync(path, "utf8") - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - continue - } - throw error - } - - const existingOwner = parseProcessOwner(observed) - if (existingOwner) { - const status = classifyRunningMarker(existingOwner, owner, pidAlive, processStartIdentity) - if (status === "other-live") { - liveOwner = { owner: existingOwner, observed } - if (!waitForLiveOwner) { - return { acquired: false, liveOwner } - } - const remainingWaitMs = waitDeadline - Date.now() - if (remainingWaitMs <= 0) { - return { acquired: false, liveOwner } - } - waitForLockRetry(Math.min(LOCK_RETRY_DELAY_MS, remainingWaitMs)) - continue - } - } else if (waitForLiveOwner && attempt < PRIMARY_LOCK_ACQUIRE_ATTEMPTS - 1) { - // The owner may still be writing a newly-created lock file. - waitForLockRetry() - continue - } - - try { - removeFileIfUnchanged(path, observed) - } catch (error) { - if (!isTransientFileContentionError(error)) { - throw error - } - waitForLockRetry() - } - } - - return { acquired: false, liveOwner } -} - -export function acquireProcessOwnerLock( - path: string, - owner: ProcessOwner, - waitForLiveOwner: boolean, - pidAlive: (pid: number) => boolean = isPidAlive, - liveOwnerWaitMs = REGISTRATION_LOCK_WAIT_MS, - processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, -): boolean { - return acquireProcessOwnerLockWithStatus( - path, - owner, - waitForLiveOwner, - pidAlive, - liveOwnerWaitMs, - processStartIdentity, - ).acquired -} - -export function removeProcessOwnerLockIfOwned(path: string, owner: ProcessOwner): boolean { - try { - const observed = readFileSync(path, "utf8") - const current = parseProcessOwner(observed) - return Boolean(current && isSameProcessOwner(current, owner) && removeFileIfUnchanged(path, observed)) - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - return false - } - throw error - } -} - -export function isProcessOwnerLockOwned(path: string, owner: ProcessOwner): boolean { - try { - const current = parseProcessOwner(readFileSync(path, "utf8")) - return Boolean(current && isSameProcessOwner(current, owner)) - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - return false - } - throw error - } -} - -export function removeRunningMarkerIfOwned(markerPath: string, owner: ProcessOwner): boolean { - const filenameOwner = parseRunningMarkerFilename(basename(markerPath)) - if (!filenameOwner || !isSameProcessOwner(filenameOwner, owner)) { - return false - } - - try { - const observed = readFileSync(markerPath, "utf8") - const storedOwner = parseProcessOwner(observed) - return Boolean(storedOwner && isSameProcessOwner(storedOwner, owner) && removeFileIfUnchanged(markerPath, observed)) - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - return false - } - throw error - } -} - -export function cleanStaleRunningMarkers( - userDataPath: string, - currentOwner: ProcessOwner, - pidAlive: (pid: number) => boolean = isPidAlive, - processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, -): boolean { - let hasOtherLiveProcess = false - - for (const filename of readdirSync(userDataPath)) { - const filenameOwner = parseRunningMarkerFilename(filename) - if (!filenameOwner) { - continue - } - - const markerPath = join(userDataPath, filename) - let observed: string - try { - observed = readFileSync(markerPath, "utf8") - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - continue - } - throw error - } - - const storedOwner = parseProcessOwner(observed) - if (storedOwner && !isSameProcessOwner(storedOwner, filenameOwner)) { - if (classifyRunningMarker(storedOwner, currentOwner, pidAlive, processStartIdentity) === "other-live") { - hasOtherLiveProcess = true - } - continue - } - - const markerOwner = storedOwner ?? filenameOwner - const status = classifyRunningMarker(markerOwner, currentOwner, pidAlive, processStartIdentity) - if (status === "other-live") { - const acknowledgedPrimary = parseAcknowledgedPrimary(observed) - if (!acknowledgedPrimary || !isSameProcessOwner(acknowledgedPrimary, currentOwner)) { - hasOtherLiveProcess = true - } - } else if (status === "stale") { - removeFileIfUnchanged(markerPath, observed) - } - } - - return hasOtherLiveProcess -} - -function hasMatchingLiveRunningMarker( - userDataPath: string, - owner: ProcessOwner, - pidAlive: (pid: number) => boolean, - processStartIdentity: ProcessStartIdentityLookup, -): boolean { - if (!pidAlive(owner.pid)) { - return false - } - - try { - const markerOwner = parseProcessOwner(readFileSync(getRunningMarkerPath(userDataPath, owner), "utf8")) - if (!markerOwner || !isSameProcessOwner(markerOwner, owner)) return false - if (markerOwner.processStartIdentity) { - const liveIdentity = processStartIdentity(markerOwner.pid) - if (liveIdentity && liveIdentity !== markerOwner.processStartIdentity) return false - } - return true - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - return false - } - throw error - } -} - -function acquireMarkerBackedProcessOwnerLock( - userDataPath: string, - path: string, - owner: ProcessOwner, - pidAlive: (pid: number) => boolean, - liveOwnerWaitMs: number, - processStartIdentity: ProcessStartIdentityLookup, -): ProcessOwnerLockAcquisition { - let lastAcquisition: ProcessOwnerLockAcquisition = { acquired: false } - for (let recoveryAttempt = 0; recoveryAttempt < PRIMARY_LOCK_ACQUIRE_ATTEMPTS; recoveryAttempt += 1) { - const acquisition = acquireProcessOwnerLockWithStatus( - path, - owner, - true, - pidAlive, - liveOwnerWaitMs, - processStartIdentity, - ) - lastAcquisition = acquisition - if (acquisition.acquired || !acquisition.liveOwner) { - if (acquisition.acquired) return acquisition - continue - } - // An identity-backed owner only reaches this point after an exact identity match - // or an inconclusive lookup. Never steal its lock while its PID remains live. - if (acquisition.liveOwner.owner.processStartIdentity) { - return acquisition - } - if (hasMatchingLiveRunningMarker( - userDataPath, - acquisition.liveOwner.owner, - pidAlive, - processStartIdentity, - )) { - return acquisition - } - try { - removeFileIfUnchanged(path, acquisition.liveOwner.observed) - } catch (error) { - if (!isTransientFileContentionError(error)) { - throw error - } - waitForLockRetry() - } - } - return lastAcquisition -} - -export function electClientStateProcess( - userDataPath: string, - owner: ProcessOwner, - paths: ClientStateElectionPaths, - onWarning: (message: string, error: unknown) => void = () => {}, - pidAlive: (pid: number) => boolean = isPidAlive, - registrationLockWaitMs = REGISTRATION_LOCK_WAIT_MS, - onPrimaryLockAcquired: () => void = () => {}, - processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, -): ClientStateElectionResult { - const runningMarkerPath = getRunningMarkerPath(userDataPath, owner) - let registrationAcquired = false - let registeringOwner: ProcessOwner | undefined - - try { - const registration = acquireMarkerBackedProcessOwnerLock( - userDataPath, - paths.registrationLockPath, - owner, - pidAlive, - registrationLockWaitMs, - processStartIdentity, - ) - registrationAcquired = registration.acquired - registeringOwner = registration.liveOwner?.owner - } catch (error) { - onWarning("failed to acquire registration lock", error) - } - - if (!registrationAcquired) { - try { - createRunningMarker(userDataPath, owner, registeringOwner) - } catch (error) { - onWarning("failed to create running marker", error) - } - return { isPrimary: false, runningMarkerPath } - } - - try { - let isPrimary = false - let acknowledgedPrimary: ProcessOwner | undefined - try { - const acquisition = acquireMarkerBackedProcessOwnerLock( - userDataPath, - paths.primaryLockPath, - owner, - pidAlive, - registrationLockWaitMs, - processStartIdentity, - ) - isPrimary = acquisition.acquired - acknowledgedPrimary = acquisition.liveOwner?.owner - } catch (error) { - onWarning("failed to acquire primary lock", error) - } - - if (isPrimary) { - try { - onPrimaryLockAcquired() - if (cleanStaleRunningMarkers(userDataPath, owner, pidAlive, processStartIdentity)) { - removeProcessOwnerLockIfOwned(paths.primaryLockPath, owner) - isPrimary = false - } - } catch (error) { - onWarning("failed to inspect running markers", error) - try { - removeProcessOwnerLockIfOwned(paths.primaryLockPath, owner) - } catch (releaseError) { - onWarning("failed to release primary lock", releaseError) - } - isPrimary = false - } - } - - try { - createRunningMarker(userDataPath, owner, acknowledgedPrimary) - } catch (error) { - onWarning("failed to create running marker", error) - try { - if (isPrimary) { - removeProcessOwnerLockIfOwned(paths.primaryLockPath, owner) - } - } catch (releaseError) { - onWarning("failed to release primary lock", releaseError) - } - return { isPrimary: false, runningMarkerPath } - } - - return { isPrimary, runningMarkerPath } - } finally { - try { - removeProcessOwnerLockIfOwned(paths.registrationLockPath, owner) - } catch (error) { - onWarning("failed to release registration lock", error) - } - } -} diff --git a/packages/electron-app/electron/main/client-state.test.ts b/packages/electron-app/electron/main/client-state.test.ts deleted file mode 100644 index 9523cf6e9..000000000 --- a/packages/electron-app/electron/main/client-state.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import assert from "node:assert/strict" -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" -import test from "node:test" -import { ClientStateManager, type ClientStateWriter } from "./client-state" - -function createManager(testContext: test.TestContext) { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-manager-")) - let failWrites = false - let writeCount = 0 - const writer: ClientStateWriter = async (temporaryPath, serializedState) => { - writeCount += 1 - if (failWrites) { - throw new Error("injected write failure") - } - await writeFile(temporaryPath, serializedState, "utf8") - } - const manager = new ClientStateManager(directory, writer) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - return { - directory, - manager, - failWrites: (fail: boolean) => { - failWrites = fail - }, - writeCount: () => writeCount, - } -} - -test("failed restore setting write rolls in-memory state back", async (testContext) => { - const harness = createManager(testContext) - await harness.manager.saveClientState({ kept: true }) - harness.failWrites(true) - - await assert.rejects(harness.manager.setRestoreEnabled(false), /injected write failure/) - - assert.deepEqual(harness.manager.loadClientState(), { - isPrimary: true, - restoreEnabled: true, - snapshot: { kept: true }, - }) - const persisted = JSON.parse(readFileSync(join(harness.directory, "client-state.json"), "utf8")) - assert.equal(persisted.restoreEnabled, true) -}) - -test("failed clear restores snapshot and suppression state", async (testContext) => { - const harness = createManager(testContext) - await harness.manager.saveClientState({ kept: true }) - harness.failWrites(true) - - await assert.rejects(harness.manager.clearClientState(), /injected write failure/) - - harness.failWrites(false) - await harness.manager.setRestoreEnabled(true) - assert.deepEqual(harness.manager.loadClientState().snapshot, { kept: true }) - - const writesBeforeSave = harness.writeCount() - await harness.manager.saveClientState({ replacement: true }) - assert.equal(harness.writeCount(), writesBeforeSave + 1) - assert.deepEqual(harness.manager.loadClientState().snapshot, { replacement: true }) -}) - -test("successful clear keeps later snapshot saves as suppressed no-ops", async (testContext) => { - const harness = createManager(testContext) - await harness.manager.saveClientState({ kept: true }) - await harness.manager.clearClientState() - const writesAfterClear = harness.writeCount() - - assert.equal(await harness.manager.saveClientState({ ignored: true }), true) - assert.equal(harness.writeCount(), writesAfterClear) - assert.equal(harness.manager.loadClientState().snapshot, null) -}) - -test("failed restore re-enable keeps successful clear suppression active", async (testContext) => { - const harness = createManager(testContext) - await harness.manager.saveClientState({ cleared: true }) - await harness.manager.clearClientState() - harness.failWrites(true) - - await assert.rejects(harness.manager.setRestoreEnabled(true), /injected write failure/) - const writesAfterFailure = harness.writeCount() - - assert.equal(await harness.manager.saveClientState({ ignored: true }), true) - assert.equal(harness.writeCount(), writesAfterFailure) - assert.equal(harness.manager.loadClientState().snapshot, null) -}) - -test("disabling restore atomically removes snapshot and window state", async (testContext) => { - const harness = createManager(testContext) - await harness.manager.saveClientState({ kept: true }) - await harness.manager.saveWindowState({ - bounds: { x: 10, y: 20, width: 1200, height: 800 }, - maximized: true, - fullscreen: false, - zoomFactor: 1.25, - }) - const writesBeforeDisable = harness.writeCount() - - assert.equal(await harness.manager.setRestoreEnabled(false), true) - - assert.equal(harness.writeCount(), writesBeforeDisable + 1) - assert.deepEqual(harness.manager.loadClientState(), { - isPrimary: true, - restoreEnabled: false, - snapshot: null, - }) - assert.equal(harness.manager.getWindowState(), undefined) - assert.deepEqual(JSON.parse(readFileSync(join(harness.directory, "client-state.json"), "utf8")), { - version: 1, - restoreEnabled: false, - }) -}) - -test("restore-disabled state remains write-suppressed after restart", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-disabled-restart-")) - const statePath = join(directory, "client-state.json") - const disabledState = JSON.stringify({ version: 1, restoreEnabled: false }) - writeFileSync(statePath, disabledState, "utf8") - const manager = new ClientStateManager(directory) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - - assert.equal(await manager.saveWindowState({ - bounds: { x: 10, y: 20, width: 1200, height: 800 }, - maximized: false, - fullscreen: false, - zoomFactor: 1, - }), true) - assert.equal(readFileSync(statePath, "utf8"), disabledState) -}) - -test("drain freezes new mutations and waits for every admitted write before release", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-drain-")) - let resolveWriterStarted!: () => void - let continueWriter!: () => void - const writerStarted = new Promise((resolve) => { - resolveWriterStarted = resolve - }) - const writerGate = new Promise((resolve) => { - continueWriter = resolve - }) - const writer: ClientStateWriter = async (temporaryPath, serializedState) => { - await writeFile(temporaryPath, serializedState, "utf8") - resolveWriterStarted() - await writerGate - } - const manager = new ClientStateManager(directory, writer) - testContext.after(async () => { - continueWriter() - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - - const admittedWrite = manager.saveClientState({ admitted: true }) - await writerStarted - let drainSettled = false - const drain = manager.drainAndReleasePrimary().finally(() => { - drainSettled = true - }) - - await assert.rejects(manager.saveClientState({ tooLate: true }), /frozen for shutdown/) - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(drainSettled, false) - assert.equal(manager.isPrimary, true) - - continueWriter() - await admittedWrite - await drain - - assert.equal(manager.isPrimary, false) - assert.deepEqual(JSON.parse(readFileSync(join(directory, "client-state.json"), "utf8")).snapshot, { - admitted: true, - }) -}) - -test("a delayed old writer cannot replace state after a successor takes ownership", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-owner-race-")) - let resolveWriterStarted!: () => void - let continueWriter!: () => void - const writerStarted = new Promise((resolve) => { - resolveWriterStarted = resolve - }) - const writerGate = new Promise((resolve) => { - continueWriter = resolve - }) - const oldManager = new ClientStateManager(directory, async (temporaryPath, serializedState) => { - await writeFile(temporaryPath, serializedState, "utf8") - resolveWriterStarted() - await writerGate - }) - let successor: ClientStateManager | undefined - testContext.after(async () => { - continueWriter() - await oldManager.drainAndReleasePrimary().catch(() => {}) - await successor?.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - - const staleWrite = oldManager.saveClientState({ stale: true }) - await writerStarted - const oldDrain = oldManager.drainAndReleasePrimary() - - // A different run token with the same PID deterministically models PID reuse by a successor. - successor = new ClientStateManager(directory) - assert.equal(successor.isPrimary, true) - await successor.saveClientState({ successor: true }) - - continueWriter() - await assert.rejects(staleWrite, /ownership changed before atomic replacement/) - await assert.rejects(oldDrain, /ownership changed before atomic replacement/) - assert.deepEqual(JSON.parse(readFileSync(join(directory, "client-state.json"), "utf8")).snapshot, { - successor: true, - }) -}) - -test("future outer envelopes survive normal persistence and clear explicitly unblocks v1", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-future-")) - const statePath = join(directory, "client-state.json") - const futureEnvelope = { - version: 2, - restoreEnabled: false, - snapshot: { future: true }, - window: { futureShape: true }, - futurePreference: "preserve-me", - } - writeFileSync(statePath, JSON.stringify(futureEnvelope), "utf8") - const managers: ClientStateManager[] = [] - const manager = new ClientStateManager(directory) - managers.push(manager) - testContext.after(async () => { - await Promise.all(managers.map((activeManager) => activeManager.drainAndReleasePrimary().catch(() => {}))) - rmSync(directory, { recursive: true, force: true }) - }) - - assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: null }) - assert.equal(await manager.saveClientState({ ignored: true }), true) - assert.equal(await manager.setRestoreEnabled(false), false) - assert.equal(await manager.setRestoreEnabled(true), false) - assert.equal( - await manager.saveWindowState({ - bounds: { x: 0, y: 0, width: 1000, height: 700 }, - maximized: false, - fullscreen: false, - zoomFactor: 1, - }), - true, - ) - assert.deepEqual(JSON.parse(readFileSync(statePath, "utf8")), futureEnvelope) - - await manager.drainAndReleasePrimary() - const restartedManager = new ClientStateManager(directory) - managers.push(restartedManager) - assert.deepEqual(restartedManager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: null }) - assert.deepEqual(JSON.parse(readFileSync(statePath, "utf8")), futureEnvelope) - - assert.equal(await restartedManager.clearClientState(), true) - assert.deepEqual(JSON.parse(readFileSync(statePath, "utf8")), { version: 1, restoreEnabled: true }) - assert.equal(await restartedManager.saveClientState({ nowSupported: true }), true) - assert.deepEqual(JSON.parse(readFileSync(statePath, "utf8")).snapshot, { nowSupported: true }) -}) - -test("failed future-envelope clear keeps normal persistence blocked", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-future-failure-")) - const statePath = join(directory, "client-state.json") - const serializedFutureEnvelope = JSON.stringify({ version: 7, future: true }) - writeFileSync(statePath, serializedFutureEnvelope, "utf8") - let writes = 0 - const manager = new ClientStateManager(directory, async () => { - writes += 1 - throw new Error("injected future clear failure") - }) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - - await assert.rejects(manager.clearClientState(), /injected future clear failure/) - assert.equal(await manager.setRestoreEnabled(false), false) - assert.equal(await manager.saveClientState({ ignored: true }), true) - assert.equal(writes, 1) - assert.equal(readFileSync(statePath, "utf8"), serializedFutureEnvelope) -}) diff --git a/packages/electron-app/electron/main/client-state.ts b/packages/electron-app/electron/main/client-state.ts deleted file mode 100644 index c710101de..000000000 --- a/packages/electron-app/electron/main/client-state.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { randomUUID } from "node:crypto" -import { mkdirSync, readFileSync } from "node:fs" -import { rename, rm, writeFile } from "node:fs/promises" -import { join } from "node:path" -import { - electClientStateProcess, - getRunningMarkerPath, - hasErrorCode, - isProcessOwnerLockOwned, - removeProcessOwnerLockIfOwned, - type ProcessOwner, - removeRunningMarkerIfOwned, -} from "./client-state-process" -import { getProcessStartIdentity } from "./client-state-process-identity" -import { normalizeNativeWindowState } from "./window-state" - -const CLIENT_STATE_VERSION = 1 -const CLIENT_STATE_FILENAME = "client-state.json" -const PRIMARY_LOCK_FILENAME = "client-state.primary.lock" -const REGISTRATION_LOCK_FILENAME = "client-state.registration.lock" - -export const MAX_CLIENT_SNAPSHOT_BYTES = 1024 * 1024 - -export interface WindowBounds { - x: number - y: number - width: number - height: number -} - -export interface NativeWindowState { - bounds: WindowBounds - maximized: boolean - fullscreen: boolean - zoomFactor: number -} - -export interface ClientStateLoadResult { - isPrimary: boolean - restoreEnabled: boolean - snapshot: unknown | null -} - -interface PersistedClientState { - version: typeof CLIENT_STATE_VERSION - restoreEnabled: boolean - snapshot?: unknown - window?: NativeWindowState -} - -export type ClientStateWriter = ( - temporaryPath: string, - serializedState: string, -) => Promise - -async function writeClientStateTemporary(temporaryPath: string, serializedState: string): Promise { - await writeFile(temporaryPath, serializedState, { encoding: "utf8", mode: 0o600 }) -} - -interface ParsedClientState { - state: PersistedClientState - unsupportedFutureEnvelope: boolean -} - -function parseClientState(value: string): ParsedClientState { - const defaults: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true } - try { - const candidate = JSON.parse(value) as Record - if (candidate && typeof candidate.version === "number" && candidate.version > CLIENT_STATE_VERSION) { - return { state: defaults, unsupportedFutureEnvelope: true } - } - if (!candidate || candidate.version !== CLIENT_STATE_VERSION) { - return { state: defaults, unsupportedFutureEnvelope: false } - } - - const state: PersistedClientState = { - version: CLIENT_STATE_VERSION, - restoreEnabled: typeof candidate.restoreEnabled === "boolean" ? candidate.restoreEnabled : true, - } - if (Object.prototype.hasOwnProperty.call(candidate, "snapshot")) { - state.snapshot = candidate.snapshot - } - const windowState = normalizeNativeWindowState(candidate.window) - if (windowState) { - state.window = windowState - } - return { state, unsupportedFutureEnvelope: false } - } catch (error) { - console.warn("[client-state] ignored invalid state file", error) - return { state: defaults, unsupportedFutureEnvelope: false } - } -} - -export class ClientStateManager { - private readonly userDataPath: string - private readonly statePath: string - private readonly lockPath: string - private readonly registrationLockPath: string - private readonly owner: ProcessOwner = { - pid: process.pid, - runToken: randomUUID(), - processStartIdentity: getProcessStartIdentity(process.pid), - } - private readonly runningMarkerPath: string - private state: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true } - private writeQueue: Promise = Promise.resolve() - private drainAndReleasePromise: Promise | undefined - private primary = false - private persistenceSuppressed = false - private unsupportedFutureEnvelope = false - private ownershipGeneration = 1 - private frozenGeneration: number | undefined - private rendererAccessToken: string | undefined - - constructor(userDataPath: string, private readonly writeState: ClientStateWriter = writeClientStateTemporary) { - mkdirSync(userDataPath, { recursive: true }) - this.userDataPath = userDataPath - this.statePath = join(userDataPath, CLIENT_STATE_FILENAME) - this.lockPath = join(userDataPath, PRIMARY_LOCK_FILENAME) - this.registrationLockPath = join(userDataPath, REGISTRATION_LOCK_FILENAME) - this.runningMarkerPath = getRunningMarkerPath(userDataPath, this.owner) - - const election = electClientStateProcess( - userDataPath, - this.owner, - { primaryLockPath: this.lockPath, registrationLockPath: this.registrationLockPath }, - (message, error) => console.warn(`[client-state] ${message}`, error), - ) - this.primary = election.isPrimary - if (this.primary) { - const persisted = this.readState() - this.state = persisted.state - this.persistenceSuppressed = !this.state.restoreEnabled - this.unsupportedFutureEnvelope = persisted.unsupportedFutureEnvelope - } - } - - get isPrimary(): boolean { - return this.primary - } - - loadClientState(): ClientStateLoadResult { - if (!this.primary) { - return { isPrimary: false, restoreEnabled: true, snapshot: null } - } - return { - isPrimary: true, - restoreEnabled: this.state.restoreEnabled, - snapshot: this.state.restoreEnabled ? (this.state.snapshot ?? null) : null, - } - } - - getWindowState(): NativeWindowState | undefined { - return this.primary && !this.unsupportedFutureEnvelope && this.state.restoreEnabled ? this.state.window : undefined - } - - claimClientStateAccess(token: unknown): true { - this.validateRendererAccessTokenValue(token) - if (this.rendererAccessToken === undefined) { - this.rendererAccessToken = token - return true - } - if (this.rendererAccessToken !== token) { - throw new Error("Client state access token does not match the claimed renderer") - } - return true - } - - assertRendererAccessToken(token: unknown): void { - this.validateRendererAccessTokenValue(token) - if (this.rendererAccessToken === undefined || this.rendererAccessToken !== token) { - throw new Error("Client state access has not been claimed by this renderer") - } - } - - resetRendererAccessToken(): void { - this.rendererAccessToken = undefined - } - - saveClientState(snapshot: unknown): Promise { - const disposition = this.getMutationDisposition() - if (disposition) return disposition - - const serialized = JSON.stringify(snapshot) - if (serialized === undefined) { - throw new TypeError("Client snapshot must be JSON-serializable") - } - if (Buffer.byteLength(serialized, "utf8") > MAX_CLIENT_SNAPSHOT_BYTES) { - throw new RangeError("Client snapshot exceeds the 1 MiB limit") - } - - const normalizedSnapshot = JSON.parse(serialized) as unknown - return this.mutateAndPersist((state) => { - state.snapshot = normalizedSnapshot - }, true) - } - - setRestoreEnabled(enabled: boolean): Promise { - const disposition = this.getMutationDisposition(false) - if (disposition) return disposition - - if (typeof enabled !== "boolean") { - throw new TypeError("Restore enabled must be a boolean") - } - - return this.mutateAndPersist((state) => { - state.restoreEnabled = enabled - if (enabled) { - this.persistenceSuppressed = false - } else { - delete state.snapshot - delete state.window - this.persistenceSuppressed = true - } - }) - } - - clearClientState(): Promise { - if (!this.primary) { - return Promise.resolve(false) - } - if (this.frozenGeneration !== undefined) { - return Promise.reject(new Error("Client state persistence is frozen for shutdown")) - } - - const clearingFutureEnvelope = this.unsupportedFutureEnvelope - - return this.mutateAndPersist((state) => { - delete state.snapshot - delete state.window - this.unsupportedFutureEnvelope = false - this.persistenceSuppressed = !clearingFutureEnvelope - }) - } - - saveWindowState(windowState: NativeWindowState): Promise { - const disposition = this.getMutationDisposition() - if (disposition) return disposition - - const normalized = normalizeNativeWindowState(windowState) - if (!normalized) { - return Promise.resolve(false) - } - return this.mutateAndPersist((state) => { - state.window = normalized - }, true) - } - - async flush(): Promise { - await this.writeQueue - } - - drainAndReleasePrimary(): Promise { - if (this.drainAndReleasePromise) { - return this.drainAndReleasePromise - } - - this.frozenGeneration = this.ownershipGeneration - this.drainAndReleasePromise = (async () => { - try { - await this.writeQueue - } finally { - this.primary = false - this.ownershipGeneration += 1 - this.releaseOwnedProcessFiles() - } - })() - return this.drainAndReleasePromise - } - - private readState(): ParsedClientState { - try { - return parseClientState(readFileSync(this.statePath, "utf8")) - } catch (error) { - if (!hasErrorCode(error, "ENOENT")) { - console.warn("[client-state] failed to read state", error) - } - return { - state: { version: CLIENT_STATE_VERSION, restoreEnabled: true }, - unsupportedFutureEnvelope: false, - } - } - } - - private getMutationDisposition(futureEnvelopeResult = true): Promise | undefined { - if (!this.primary) { - return Promise.resolve(false) - } - if (this.frozenGeneration !== undefined) { - return Promise.reject(new Error("Client state persistence is frozen for shutdown")) - } - if (this.unsupportedFutureEnvelope) { - return Promise.resolve(futureEnvelopeResult) - } - return undefined - } - - private mutateAndPersist( - mutate: (state: PersistedClientState) => void, - skipWhenSuppressed = false, - ): Promise { - const admittedGeneration = this.ownershipGeneration - const operation = this.writeQueue.catch(() => {}).then(async () => { - if (skipWhenSuppressed && this.persistenceSuppressed) { - return - } - - const previousState = { ...this.state } - const previousPersistenceSuppressed = this.persistenceSuppressed - const previousUnsupportedFutureEnvelope = this.unsupportedFutureEnvelope - try { - mutate(this.state) - await this.writeAtomically(JSON.stringify(this.state), admittedGeneration) - } catch (error) { - this.state = previousState - this.persistenceSuppressed = previousPersistenceSuppressed - this.unsupportedFutureEnvelope = previousUnsupportedFutureEnvelope - throw error - } - }) - this.writeQueue = operation - return operation.then(() => true) - } - - private async writeAtomically(serializedState: string, admittedGeneration: number): Promise { - const temporaryPath = join( - this.userDataPath, - `.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.tmp`, - ) - try { - await this.writeState(temporaryPath, serializedState) - this.assertReplacementAllowed(admittedGeneration) - await rename(temporaryPath, this.statePath) - } catch (error) { - await rm(temporaryPath, { force: true }).catch(() => {}) - throw error - } - } - - private assertReplacementAllowed(admittedGeneration: number): void { - if ( - !this.primary || - admittedGeneration !== this.ownershipGeneration || - (this.frozenGeneration !== undefined && admittedGeneration !== this.frozenGeneration) || - !isProcessOwnerLockOwned(this.lockPath, this.owner) - ) { - throw new Error("Client state ownership changed before atomic replacement") - } - } - - private releaseOwnedProcessFiles(): void { - try { - removeRunningMarkerIfOwned(this.runningMarkerPath, this.owner) - } catch (error) { - console.warn("[client-state] failed to remove running marker", error) - } - - try { - removeProcessOwnerLockIfOwned(this.lockPath, this.owner) - } catch (error) { - console.warn("[client-state] failed to release primary lock", error) - } - } - - private validateRendererAccessTokenValue(token: unknown): asserts token is string { - if (typeof token !== "string" || token.trim().length === 0) { - throw new TypeError("Client state access token must be a nonempty string") - } - } -} diff --git a/packages/electron-app/electron/main/main-window-close.test.ts b/packages/electron-app/electron/main/main-window-close.test.ts deleted file mode 100644 index c1bcf4409..000000000 --- a/packages/electron-app/electron/main/main-window-close.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { MainWindowCloseController } from "./main-window-close" - -test("a close-only request flushes renderer and native state before approving close", async () => { - const calls: string[] = [] - let controller: MainWindowCloseController - controller = new MainWindowCloseController({ - flushRenderer: async () => { - calls.push("renderer") - }, - flushNative: async () => { - calls.push("native") - }, - closeWindow: () => { - calls.push("close") - assert.equal(controller.handleClose().allow, true) - }, - reportError: () => assert.fail("close flow should not report an error"), - }) - - const decision = controller.handleClose() - assert.equal(decision.allow, false) - await decision.completion - assert.deepEqual(calls, ["renderer", "native", "close"]) -}) - -test("repeated close requests share one in-progress flush", async () => { - let releaseRenderer: (() => void) | undefined - let rendererFlushes = 0 - let nativeFlushes = 0 - let closes = 0 - const controller = new MainWindowCloseController({ - flushRenderer: () => { - rendererFlushes += 1 - return new Promise((resolve) => { - releaseRenderer = resolve - }) - }, - flushNative: async () => { - nativeFlushes += 1 - }, - closeWindow: () => { - closes += 1 - }, - reportError: () => assert.fail("close flow should not report an error"), - }) - - const first = controller.handleClose() - const second = controller.handleClose() - assert.equal(first.allow, false) - assert.equal(second.allow, false) - assert.equal(second.completion, first.completion) - assert.equal(rendererFlushes, 1) - - releaseRenderer?.() - await first.completion - assert.equal(nativeFlushes, 1) - assert.equal(closes, 1) -}) - -test("renderer flush failure still flushes native state and closes", async () => { - const errors: string[] = [] - let nativeFlushed = false - let closed = false - const controller = new MainWindowCloseController({ - flushRenderer: async () => { - throw new Error("timed out") - }, - flushNative: async () => { - nativeFlushed = true - }, - closeWindow: () => { - closed = true - }, - reportError: (stage) => errors.push(stage), - }) - - await controller.handleClose().completion - assert.equal(nativeFlushed, true) - assert.equal(closed, true) - assert.deepEqual(errors, ["renderer"]) -}) diff --git a/packages/electron-app/electron/main/main-window-close.ts b/packages/electron-app/electron/main/main-window-close.ts deleted file mode 100644 index acaf1a4a9..000000000 --- a/packages/electron-app/electron/main/main-window-close.ts +++ /dev/null @@ -1,54 +0,0 @@ -export interface MainWindowCloseHooks { - flushRenderer(): Promise - flushNative(): Promise - closeWindow(): void - reportError(stage: "renderer" | "native" | "close", error: unknown): void -} - -export interface MainWindowCloseDecision { - allow: boolean - completion?: Promise -} - -export class MainWindowCloseController { - private closeApproved = false - private closeInProgress = false - private completion: Promise | undefined - - constructor(private readonly hooks: MainWindowCloseHooks) {} - - handleClose(): MainWindowCloseDecision { - if (this.closeApproved) { - return { allow: true } - } - if (!this.closeInProgress) { - this.closeInProgress = true - this.completion = this.flushAndClose() - } - return { allow: false, completion: this.completion } - } - - private async flushAndClose(): Promise { - try { - await this.hooks.flushRenderer() - } catch (error) { - this.hooks.reportError("renderer", error) - } - - try { - await this.hooks.flushNative() - } catch (error) { - this.hooks.reportError("native", error) - } - - this.closeApproved = true - try { - this.hooks.closeWindow() - } catch (error) { - this.closeApproved = false - this.closeInProgress = false - this.completion = undefined - this.hooks.reportError("close", error) - } - } -} diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index c599d55cf..c4e01e295 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,24 +1,13 @@ -import { app, BrowserView, BrowserWindow, nativeImage, screen, session, shell } from "electron" +import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron" import http from "node:http" import https from "node:https" import { existsSync, mkdirSync, rmSync } from "fs" import { dirname, join } from "path" import { fileURLToPath } from "url" import { createApplicationMenu } from "./menu" -import { ClientStateManager } from "./client-state" -import { setupClientStateIPC } from "./client-state-ipc" -import { ClientStateLifecycle } from "./client-state-lifecycle" -import { ClientStateNavigationController } from "./client-state-navigation" import { setupCliIPC } from "./ipc" -import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions" +import { configureMediaPermissionHandlers } from "./permissions" import { CliProcessManager } from "./process-manager" -import { - clampWindowBounds, - DEFAULT_WINDOW_HEIGHT, - DEFAULT_WINDOW_WIDTH, - restoreWindowState, - WindowStateTracker, -} from "./window-state" const mainFilename = fileURLToPath(import.meta.url) const mainDirname = dirname(mainFilename) @@ -93,7 +82,6 @@ function cleanupPackagedChromiumStorage() { cleanupPackagedChromiumStorage() -const clientStateManager = new ClientStateManager(app.getPath("userData")) const cliManager = new CliProcessManager() let mainWindow: BrowserWindow | null = null let currentCliUrl: string | null = null @@ -101,18 +89,8 @@ let pendingCliUrl: string | null = null let pendingBootstrapToken: string | null = null let showingLoadingScreen = false let preloadingView: BrowserView | null = null -let mainNavigationController: ClientStateNavigationController | null = null const remoteWindowOrigins = new Map>() const insecureWindowOrigins = new Map>() -const clientStateLifecycle = new ClientStateLifecycle({ - app, - clientStateManager, - cliManager, - getMainWindow: () => mainWindow, - getAllWindows: () => BrowserWindow.getAllWindows(), - getAllowedRendererOrigins, - isTrustedRendererOrigin: isAllowedRendererOrigin, -}) if (isMac) { app.commandLine.appendSwitch("disable-spell-checking") @@ -180,7 +158,7 @@ function loadLoadingScreen(window: BrowserWindow) { ? window.loadURL(target.source) : window.loadFile(target.source) - return loader.catch((error) => { + loader.catch((error) => { if (isIgnorableNavigationError(error)) { return } @@ -232,7 +210,7 @@ function shouldOpenExternally(url: string, window?: BrowserWindow | null): boole } } -function setupNavigationGuards(window: BrowserWindow, navigationController?: ClientStateNavigationController) { +function setupNavigationGuards(window: BrowserWindow) { const handleExternal = (url: string) => { shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error)) } @@ -246,20 +224,6 @@ function setupNavigationGuards(window: BrowserWindow, navigationController?: Cli }) window.webContents.on("will-navigate", (event, url) => { - if (shouldOpenExternally(url, window)) { - event.preventDefault() - handleExternal(url) - } else if (navigationController) { - event.preventDefault() - void navigationController.navigate((target) => target.loadURL(url)).catch((error) => { - if (!isIgnorableNavigationError(error)) { - console.error("[client-state] trusted renderer navigation failed", error) - } - }) - } - }) - - window.webContents.on("will-redirect", (event, url) => { if (shouldOpenExternally(url, window)) { event.preventDefault() handleExternal(url) @@ -356,18 +320,10 @@ function createWindow() { const prefersDark = true const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff" const iconPath = getIconPath() - const savedWindowState = clientStateManager.getWindowState() - const restoredBounds = savedWindowState - ? clampWindowBounds( - savedWindowState.bounds, - screen.getAllDisplays().map((display) => display.workArea), - ) - : undefined mainWindow = new BrowserWindow({ - width: restoredBounds?.width ?? DEFAULT_WINDOW_WIDTH, - height: restoredBounds?.height ?? DEFAULT_WINDOW_HEIGHT, - ...(restoredBounds ? { x: restoredBounds.x, y: restoredBounds.y } : {}), + width: 1400, + height: 900, minWidth: 800, minHeight: 600, backgroundColor, @@ -382,23 +338,8 @@ function createWindow() { }) const window = mainWindow - const navigationController = new ClientStateNavigationController({ - clientStateManager, - getWindow: () => mainWindow, - isTrustedOrigin: (url) => isAllowedRendererOrigin(url, getAllowedRendererOrigins(window)), - reportFlushError: (error) => { - console.warn("[client-state] renderer pre-navigation flush failed; continuing navigation", error) - }, - }) - mainNavigationController = navigationController - let windowStateTracker: WindowStateTracker | null = null - if (clientStateManager.isPrimary) { - restoreWindowState(window, savedWindowState, restoredBounds) - windowStateTracker = new WindowStateTracker(window, clientStateManager, savedWindowState) - } - - setupNavigationGuards(window, navigationController) + setupNavigationGuards(window) if (isMac) { window.webContents.session.setSpellCheckerEnabled(false) @@ -407,34 +348,23 @@ function createWindow() { showingLoadingScreen = true currentCliUrl = null clearWindowAllowedOrigin(window) - void loadLoadingScreen(window) + loadLoadingScreen(window) if (process.env.NODE_ENV === "development") { window.webContents.openDevTools({ mode: "detach" }) } - createApplicationMenu(window, { - reload: () => { - void navigationController.navigate((target) => target.webContents.reload()) - }, - forceReload: () => { - void navigationController.navigate((target) => target.webContents.reloadIgnoringCache()) - }, - }) + createApplicationMenu(window) setupCliIPC(window, cliManager) - setupClientStateIPC(window, clientStateManager, () => getAllowedRendererOrigins(window)) - clientStateLifecycle.attachMainWindow(window, windowStateTracker) window.on("closed", () => { destroyPreloadingView() clearWindowAllowedOrigin(window) clearWindowInsecureOrigin(window) mainWindow = null - if (mainNavigationController === navigationController) mainNavigationController = null currentCliUrl = null pendingCliUrl = null showingLoadingScreen = false - clientStateLifecycle.detachMainWindow(window) }) if (pendingCliUrl) { @@ -453,20 +383,11 @@ function showLoadingScreen(force = false) { return } - const window = mainWindow - showingLoadingScreen = true destroyPreloadingView() + showingLoadingScreen = true currentCliUrl = null pendingCliUrl = null - const navigate = (target: BrowserWindow) => { - clearWindowAllowedOrigin(target) - return loadLoadingScreen(target) - } - if (mainNavigationController) { - void mainNavigationController.navigate(navigate) - } else { - navigate(window) - } + loadLoadingScreen(mainWindow) } function isBootstrapTokenUrl(url: string): boolean { @@ -540,20 +461,15 @@ function finalizeCliSwap(url: string) { } const window = mainWindow - const navigate = async (target: BrowserWindow) => { - showingLoadingScreen = false - currentCliUrl = url - setWindowAllowedOrigin(target, url) - pendingCliUrl = null - await target.loadURL(url) - } - const navigation = mainNavigationController - ? mainNavigationController.navigate(navigate) - : navigate(window) - void navigation.catch((error) => { - if (!isIgnorableNavigationError(error)) { - console.error("[cli] failed to load CLI view:", error) + showingLoadingScreen = false + currentCliUrl = url + setWindowAllowedOrigin(window, url) + pendingCliUrl = null + window.loadURL(url).catch((error) => { + if (isIgnorableNavigationError(error)) { + return } + console.error("[cli] failed to load CLI view:", error) }) } @@ -811,4 +727,13 @@ app.whenReady().then(() => { }) }) -clientStateLifecycle.registerAppEvents() +app.on("before-quit", async (event) => { + event.preventDefault() + await cliManager.stop().catch(() => {}) + app.exit(0) +}) + +app.on("window-all-closed", () => { + // CodeNomad supports a single window; closing it should quit the app on all platforms. + app.quit() +}) diff --git a/packages/electron-app/electron/main/menu.ts b/packages/electron-app/electron/main/menu.ts index 7ecbc195c..37f9e3400 100644 --- a/packages/electron-app/electron/main/menu.ts +++ b/packages/electron-app/electron/main/menu.ts @@ -1,11 +1,6 @@ import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron" -interface ApplicationMenuActions { - reload(): void - forceReload(): void -} - -export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) { +export function createApplicationMenu(mainWindow: BrowserWindow) { const isMac = process.platform === "darwin" const template: MenuItemConstructorOptions[] = [ @@ -56,8 +51,8 @@ export function createApplicationMenu(mainWindow: BrowserWindow, actions: Applic { label: "View", submenu: [ - { label: "Reload", accelerator: "CmdOrCtrl+R", click: actions.reload }, - { label: "Force Reload", accelerator: "CmdOrCtrl+Shift+R", click: actions.forceReload }, + { role: "reload" as const }, + { role: "forceReload" as const }, { role: "toggleDevTools" as const }, { type: "separator" as const }, { role: "resetZoom" as const }, diff --git a/packages/electron-app/electron/main/renderer-client-state-flush.test.ts b/packages/electron-app/electron/main/renderer-client-state-flush.test.ts deleted file mode 100644 index bfbf45bf9..000000000 --- a/packages/electron-app/electron/main/renderer-client-state-flush.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { flushRendererClientStateBeforeShutdown, type RendererFlushWindow } from "./renderer-client-state-flush" - -function createWindow(executeJavaScript: (source: string) => Promise): RendererFlushWindow { - return { - isDestroyed: () => false, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:3000/app", - executeJavaScript, - }, - } -} - -test("renderer flush only executes for a primary trusted window", async () => { - let calls = 0 - const window = createWindow(async () => { - calls += 1 - }) - - assert.equal(await flushRendererClientStateBeforeShutdown(window, false, () => true), "not-primary") - assert.equal(await flushRendererClientStateBeforeShutdown(window, true, () => false), "untrusted-origin") - assert.equal(calls, 0) -}) - -test("renderer flush awaits the registered shutdown callback", async () => { - let source = "" - const window = createWindow(async (value) => { - source = value - }) - - assert.equal(await flushRendererClientStateBeforeShutdown(window, true, () => true), "flushed") - assert.match(source, /__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__/) - assert.match(source, /http:\/\/127\.0\.0\.1:3000/) -}) - -test("renderer flush rejects after its bounded timeout", async () => { - const window = createWindow(() => new Promise(() => {})) - await assert.rejects( - flushRendererClientStateBeforeShutdown(window, true, () => true, 10), - /timed out after 10ms/, - ) -}) diff --git a/packages/electron-app/electron/main/renderer-client-state-flush.ts b/packages/electron-app/electron/main/renderer-client-state-flush.ts deleted file mode 100644 index 1ab48365d..000000000 --- a/packages/electron-app/electron/main/renderer-client-state-flush.ts +++ /dev/null @@ -1,61 +0,0 @@ -export const RENDERER_CLIENT_STATE_FLUSH_TIMEOUT_MS = 1_000 - -const RENDERER_CLIENT_STATE_FLUSH_CALLBACK = "__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__" - -interface RendererFlushWebContents { - isDestroyed(): boolean - getURL(): string - executeJavaScript(source: string, userGesture?: boolean): Promise -} - -export interface RendererFlushWindow { - isDestroyed(): boolean - webContents: RendererFlushWebContents -} - -export type RendererFlushResult = "flushed" | "not-primary" | "window-unavailable" | "untrusted-origin" - -function withTimeout(promise: Promise, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(`Renderer client-state flush timed out after ${timeoutMs}ms`)), - timeoutMs, - ) - promise.then( - (value) => { - clearTimeout(timer) - resolve(value) - }, - (error) => { - clearTimeout(timer) - reject(error) - }, - ) - }) -} - -export async function flushRendererClientStateBeforeShutdown( - window: RendererFlushWindow | null, - isPrimary: boolean, - isTrustedOrigin: (url: string) => boolean, - timeoutMs = RENDERER_CLIENT_STATE_FLUSH_TIMEOUT_MS, -): Promise { - if (!isPrimary) return "not-primary" - if (!window || window.isDestroyed() || window.webContents.isDestroyed()) return "window-unavailable" - - const currentUrl = window.webContents.getURL() - if (!isTrustedOrigin(currentUrl)) return "untrusted-origin" - - const callbackName = JSON.stringify(RENDERER_CLIENT_STATE_FLUSH_CALLBACK) - const expectedOrigin = JSON.stringify(new URL(currentUrl).origin) - await withTimeout( - window.webContents.executeJavaScript(`(() => { - if (window.location.origin !== ${expectedOrigin}) throw new Error("Renderer origin changed before client-state flush"); - const flush = window[${callbackName}]; - if (typeof flush !== "function") throw new Error("Renderer client-state flush callback is unavailable"); - return flush(); - })()`), - timeoutMs, - ) - return "flushed" -} diff --git a/packages/electron-app/electron/main/window-state.test.ts b/packages/electron-app/electron/main/window-state.test.ts deleted file mode 100644 index bf3338d31..000000000 --- a/packages/electron-app/electron/main/window-state.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { clampWindowBounds, normalizeNativeWindowState, normalizeZoomFactor } from "./window-state" - -const primaryDisplay = { x: 0, y: 0, width: 1920, height: 1080 } - -test("rejects invalid persisted bounds", () => { - assert.equal( - normalizeNativeWindowState({ - bounds: { x: 0, y: 0, width: Number.NaN, height: 900 }, - maximized: false, - fullscreen: false, - zoomFactor: 1, - }), - undefined, - ) -}) - -test("moves off-screen bounds onto the nearest display", () => { - assert.deepEqual( - clampWindowBounds({ x: 4000, y: 2000, width: 1400, height: 900 }, [primaryDisplay]), - { x: 520, y: 180, width: 1400, height: 900 }, - ) -}) - -test("clamps dimensions to the selected work area", () => { - assert.deepEqual( - clampWindowBounds({ x: -2000, y: 100, width: 3000, height: 300 }, [ - { x: -1280, y: 0, width: 1280, height: 1024 }, - primaryDisplay, - ]), - { x: -1280, y: 100, width: 1280, height: 600 }, - ) -}) - -test("normalizes unsafe zoom factors", () => { - assert.equal(normalizeZoomFactor(Number.POSITIVE_INFINITY), 1) - assert.equal(normalizeZoomFactor(0.01), 0.25) - assert.equal(normalizeZoomFactor(9), 5) -}) diff --git a/packages/electron-app/electron/main/window-state.ts b/packages/electron-app/electron/main/window-state.ts deleted file mode 100644 index ec5b1eecd..000000000 --- a/packages/electron-app/electron/main/window-state.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { BrowserWindow } from "electron" -import type { ClientStateManager, NativeWindowState, WindowBounds } from "./client-state" - -export const DEFAULT_WINDOW_WIDTH = 1400 -export const DEFAULT_WINDOW_HEIGHT = 900 - -const MIN_WINDOW_WIDTH = 800 -const MIN_WINDOW_HEIGHT = 600 -const MIN_ZOOM_FACTOR = 0.25 -const MAX_ZOOM_FACTOR = 5 -const SAVE_DEBOUNCE_MS = 250 - -export interface DisplayWorkArea { - x: number - y: number - width: number - height: number -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) -} - -function clamp(value: number, minimum: number, maximum: number): number { - return Math.min(Math.max(value, minimum), maximum) -} - -function intersectionArea(bounds: WindowBounds, area: DisplayWorkArea): number { - const width = Math.max(0, Math.min(bounds.x + bounds.width, area.x + area.width) - Math.max(bounds.x, area.x)) - const height = Math.max(0, Math.min(bounds.y + bounds.height, area.y + area.height) - Math.max(bounds.y, area.y)) - return width * height -} - -function centerDistanceSquared(bounds: WindowBounds, area: DisplayWorkArea): number { - const x = bounds.x + bounds.width / 2 - (area.x + area.width / 2) - const y = bounds.y + bounds.height / 2 - (area.y + area.height / 2) - return x * x + y * y -} - -export function normalizeZoomFactor(value: unknown): number { - if (!isFiniteNumber(value) || value <= 0) { - return 1 - } - return clamp(value, MIN_ZOOM_FACTOR, MAX_ZOOM_FACTOR) -} - -export function normalizeNativeWindowState(value: unknown): NativeWindowState | undefined { - if (!value || typeof value !== "object") { - return undefined - } - - const candidate = value as Partial - const bounds = candidate.bounds - if ( - !bounds || - !isFiniteNumber(bounds.x) || - !isFiniteNumber(bounds.y) || - !isFiniteNumber(bounds.width) || - !isFiniteNumber(bounds.height) || - bounds.width <= 0 || - bounds.height <= 0 - ) { - return undefined - } - - return { - bounds: { - x: Math.round(bounds.x), - y: Math.round(bounds.y), - width: Math.round(bounds.width), - height: Math.round(bounds.height), - }, - maximized: candidate.maximized === true, - fullscreen: candidate.fullscreen === true, - zoomFactor: normalizeZoomFactor(candidate.zoomFactor), - } -} - -export function clampWindowBounds(bounds: WindowBounds, displays: DisplayWorkArea[]): WindowBounds | undefined { - const normalized = normalizeNativeWindowState({ bounds, maximized: false, fullscreen: false, zoomFactor: 1 })?.bounds - const usableDisplays = displays.filter( - (area) => - isFiniteNumber(area.x) && - isFiniteNumber(area.y) && - isFiniteNumber(area.width) && - isFiniteNumber(area.height) && - area.width > 0 && - area.height > 0, - ) - if (!normalized || usableDisplays.length === 0) { - return undefined - } - - const display = usableDisplays.reduce((best, area) => { - const bestIntersection = intersectionArea(normalized, best) - const areaIntersection = intersectionArea(normalized, area) - if (areaIntersection !== bestIntersection) { - return areaIntersection > bestIntersection ? area : best - } - return centerDistanceSquared(normalized, area) < centerDistanceSquared(normalized, best) ? area : best - }) - - const maximumWidth = Math.max(1, Math.floor(display.width)) - const maximumHeight = Math.max(1, Math.floor(display.height)) - const minimumWidth = Math.min(MIN_WINDOW_WIDTH, maximumWidth) - const minimumHeight = Math.min(MIN_WINDOW_HEIGHT, maximumHeight) - const width = clamp(normalized.width, minimumWidth, maximumWidth) - const height = clamp(normalized.height, minimumHeight, maximumHeight) - const x = clamp(normalized.x, display.x, display.x + maximumWidth - width) - const y = clamp(normalized.y, display.y, display.y + maximumHeight - height) - - return { x, y, width, height } -} - -export function restoreWindowState(window: BrowserWindow, state: NativeWindowState | undefined, bounds: WindowBounds | undefined) { - if (!state) { - return - } - - if (bounds) { - window.setBounds(bounds) - } - window.webContents.setZoomFactor(normalizeZoomFactor(state.zoomFactor)) - if (state.maximized) { - window.maximize() - } - if (state.fullscreen) { - window.setFullScreen(true) - } -} - -export class WindowStateTracker { - private saveTimer: ReturnType | undefined - private desiredZoomFactor: number - - constructor( - private readonly window: BrowserWindow, - private readonly clientState: ClientStateManager, - initialState?: NativeWindowState, - ) { - this.desiredZoomFactor = normalizeZoomFactor(initialState?.zoomFactor) - - const scheduleSave = () => this.scheduleSave() - window.on("move", scheduleSave) - window.on("resize", scheduleSave) - window.on("maximize", scheduleSave) - window.on("unmaximize", scheduleSave) - window.on("enter-full-screen", scheduleSave) - window.on("leave-full-screen", scheduleSave) - window.webContents.on("zoom-changed", scheduleSave) - window.webContents.on("did-finish-load", () => { - if (!window.webContents.isDestroyed()) { - window.webContents.setZoomFactor(this.desiredZoomFactor) - } - }) - window.on("close", () => { - void this.saveNow() - }) - window.on("closed", () => this.clearTimer()) - } - - async flush(): Promise { - this.clearTimer() - if (!this.window.isDestroyed()) { - await this.captureAndQueue() - } - await this.clientState.flush() - } - - private scheduleSave() { - this.clearTimer() - this.saveTimer = setTimeout(() => { - this.saveTimer = undefined - void this.saveNow() - }, SAVE_DEBOUNCE_MS) - } - - private clearTimer() { - if (this.saveTimer) { - clearTimeout(this.saveTimer) - this.saveTimer = undefined - } - } - - private async saveNow() { - try { - await this.captureAndQueue() - } catch (error) { - console.warn("[client-state] failed to save window state", error) - } - } - - private captureAndQueue(): Promise { - if (this.window.isDestroyed() || this.window.webContents.isDestroyed()) { - return Promise.resolve(false) - } - - this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor()) - return this.clientState.saveWindowState({ - bounds: this.window.getNormalBounds(), - maximized: this.window.isMaximized(), - fullscreen: this.window.isFullScreen(), - zoomFactor: this.desiredZoomFactor, - }) - } -} diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 0a35f6690..b39fdea0d 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -37,12 +37,6 @@ const localElectronAPI = { setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), - claimClientStateAccess: (token) => ipcRenderer.invoke("client-state:claimAccess", token), - loadClientState: (token) => ipcRenderer.invoke("client-state:load", token), - saveClientState: (token, snapshot) => ipcRenderer.invoke("client-state:save", token, snapshot), - setClientStateRestoreEnabled: (token, enabled) => - ipcRenderer.invoke("client-state:setRestoreEnabled", token, Boolean(enabled)), - clearClientState: (token) => ipcRenderer.invoke("client-state:clear", token), } const remoteElectronAPI = { diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 59c2599b6..02c2c4b51 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -24,7 +24,6 @@ "prebuild": "npm run prepare:resources", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", - "test:native": "node --import tsx --test electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc-handlers.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/main-window-close.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/window-state.test.ts", "preview": "electron-vite preview", "build:binaries": "node scripts/build.js", "build:mac": "node scripts/build.js mac", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 69b82a823..b640b90db 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -16,8 +16,6 @@ export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error" export interface WorkspaceDescriptor { id: string - /** Correlates creation events with the client request that initiated them. */ - requestId?: string /** Absolute path on the server host. */ path: string name?: string @@ -40,8 +38,6 @@ export interface WorkspaceDescriptor { export interface WorkspaceCreateRequest { path: string name?: string - binaryPath?: string - requestId?: string forceNew?: boolean } diff --git a/packages/server/src/events/bus.test.ts b/packages/server/src/events/bus.test.ts deleted file mode 100644 index 71757ceb2..000000000 --- a/packages/server/src/events/bus.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { EventBus } from "./bus" -import type { WorkspaceEventPayload } from "../api-types" - -describe("event bus instance status replay", () => { - it("replays the latest instance status to a late subscriber", () => { - const bus = new EventBus() - bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connecting" }) - bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }) - - const received: WorkspaceEventPayload[] = [] - bus.onEvent((event) => received.push(event)) - - assert.deepEqual(received, [ - { type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }, - ]) - }) - - it("delivers terminal disconnects live without replaying stopped workspaces", () => { - const bus = new EventBus() - bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }) - const live: WorkspaceEventPayload[] = [] - bus.onEvent((event) => live.push(event)) - live.length = 0 - - bus.publish({ - type: "instance.eventStatus", - instanceId: "workspace-1", - status: "disconnected", - reason: "workspace stopped", - }) - - assert.deepEqual(live, [{ - type: "instance.eventStatus", - instanceId: "workspace-1", - status: "disconnected", - reason: "workspace stopped", - }]) - const replayed: WorkspaceEventPayload[] = [] - bus.onEvent((event) => replayed.push(event)) - assert.deepEqual(replayed, []) - }) -}) diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index 637aad1d2..7929c7e27 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -3,22 +3,11 @@ import { WorkspaceEventPayload } from "../api-types" import { Logger } from "../logger" export class EventBus extends EventEmitter { - private readonly instanceStatuses = new Map>() - constructor(private readonly logger?: Logger) { super() } publish(event: WorkspaceEventPayload): boolean { - if (event.type === "instance.eventStatus") { - const terminal = event.status === "disconnected" - && (event.reason === "workspace stopped" || event.reason === "workspace error") - if (terminal) { - this.instanceStatuses.delete(event.instanceId) - } else { - this.instanceStatuses.set(event.instanceId, event) - } - } if (event.type !== "instance.event" && event.type !== "instance.eventStatus") { this.logger?.debug({ type: event.type }, "Publishing workspace event") if (this.logger?.isLevelEnabled("trace")) { @@ -44,7 +33,6 @@ export class EventBus extends EventEmitter { this.on("instance.eventStatus", handler) this.on("yolo.stateChanged", handler) this.on("yolo.autoAccepted", handler) - for (const status of this.instanceStatuses.values()) listener(status) return () => { this.off("workspace.created", handler) this.off("workspace.started", handler) diff --git a/packages/server/src/index.test.ts b/packages/server/src/index.test.ts deleted file mode 100644 index e58759515..000000000 --- a/packages/server/src/index.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { installShutdownSignalHandlers } from "./index" -import { createServerShutdownHandler } from "./shutdown" - -describe("CLI shutdown signal registration", () => { - it("routes a second process signal to forced nonzero escalation", async () => { - const listeners = new Map void>() - let finishCleanup!: () => void - const cleanup = new Promise((resolve) => { - finishCleanup = resolve - }) - const exits: number[] = [] - const shutdown = createServerShutdownHandler({ - shutdown: () => cleanup, - logger: { - info: () => undefined, - warn: () => undefined, - error: () => undefined, - }, - forceExit: (code) => exits.push(code), - setExitCode: () => undefined, - }) - - installShutdownSignalHandlers( - { - on: (signal, listener) => listeners.set(signal, listener), - }, - shutdown, - ) - - listeners.get("SIGINT")?.() - listeners.get("SIGTERM")?.() - assert.deepEqual(exits, [1]) - - finishCleanup() - await cleanup - }) -}) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1205fde71..935cc0407 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -32,7 +32,6 @@ import { ClientConnectionManager } from "./clients/connection-manager" import { PluginChannelManager } from "./plugins/channel" import { VoiceModeManager } from "./plugins/voice-mode" import { runCliUpgrade } from "./cli-upgrade" -import { createServerShutdownHandler, orchestrateServerShutdown } from "./shutdown" const require = createRequire(import.meta.url) @@ -75,18 +74,6 @@ const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_HTTPS_PORT = 9898 const DEFAULT_HTTP_PORT = 9899 -interface ShutdownSignalSource { - on: (signal: "SIGINT" | "SIGTERM", listener: () => void) => unknown -} - -export function installShutdownSignalHandlers( - source: ShutdownSignalSource, - shutdown: (signal: NodeJS.Signals) => Promise, -): void { - source.on("SIGINT", () => void shutdown("SIGINT")) - source.on("SIGTERM", () => void shutdown("SIGTERM")) -} - function parseCliOptions(argv: string[]): CliOptions { const program = new Command() .name("codenomad") @@ -568,39 +555,68 @@ async function main() { await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" })) } - const shutdown = createServerShutdownHandler({ - logger, - shutdown: () => - orchestrateServerShutdown( - { - stopInstanceEventBridge: () => instanceEventBridge.shutdown(), - stopSidecars: () => sidecarManager.shutdown(), - stopClientConnections: () => clientConnectionManager.shutdown(), - stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(), - stopWorkspaces: () => workspaceManager.shutdown(), - stopHttpServers: async () => { - const results = await Promise.allSettled(servers.map((srv) => srv.stop())) - const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) - if (failures.length > 0) { - const error = new Error("One or more HTTP servers failed to stop") as Error & { failures: unknown[] } - error.failures = failures - throw error - } - logger.info("HTTP server(s) stopped") - }, - stopReleaseMonitor: () => devReleaseMonitor?.stop(), - }, - logger, - ), - }) + let shuttingDown = false - installShutdownSignalHandlers(process, shutdown) -} + const shutdown = async () => { + if (shuttingDown) { + logger.info("Shutdown already in progress, ignoring signal") + return + } + shuttingDown = true + logger.info("Received shutdown signal, stopping workspaces and server") + + const shutdownWorkspaces = (async () => { + try { + instanceEventBridge.shutdown() + } catch (error) { + logger.warn({ err: error }, "Instance event bridge shutdown failed") + } -if (path.resolve(process.argv[1] ?? "") === __filename) { - main().catch((error) => { - const logger = createLogger({ component: "app" }) - logger.error({ err: error }, "CLI server crashed") - process.exit(1) - }) + try { + await sidecarManager.shutdown() + } catch (error) { + logger.error({ err: error }, "SideCar manager shutdown failed") + } + + try { + clientConnectionManager.shutdown() + } catch (error) { + logger.warn({ err: error }, "Client connection manager shutdown failed") + } + + try { + await workspaceManager.shutdown() + logger.info("Workspace manager shutdown complete") + } catch (error) { + logger.error({ err: error }, "Workspace manager shutdown failed") + } + })() + + const shutdownHttp = (async () => { + try { + await Promise.allSettled(servers.map((srv) => srv.stop())) + logger.info("HTTP server(s) stopped") + } catch (error) { + logger.error({ err: error }, "Failed to stop HTTP server") + } + })() + + await Promise.allSettled([shutdownWorkspaces, shutdownHttp]) + + // no-op: remote UI manifest replaces GitHub release monitor + + devReleaseMonitor?.stop() + + logger.info("Exiting process") + process.exit(0) + } + + process.on("SIGINT", shutdown) + process.on("SIGTERM", shutdown) } + +main().catch((error) => { + const logger = createLogger({ component: "app" }) + logger.error({ err: error }, "CLI server crashed") + process.exit(1) +}) diff --git a/packages/server/src/server/__tests__/remote-proxy.test.ts b/packages/server/src/server/__tests__/remote-proxy.test.ts index e4ac882e1..5daac01af 100644 --- a/packages/server/src/server/__tests__/remote-proxy.test.ts +++ b/packages/server/src/server/__tests__/remote-proxy.test.ts @@ -157,135 +157,20 @@ describe("RemoteProxySessionManager", () => { res.end("ok") }) }) - - it("closes every session listener during shutdown", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager() - const first = await createSession(manager, `${upstreamBaseUrl}/base`) - await createSession(manager, `${upstreamBaseUrl}/other`) - - await manager.shutdown() - - assert.equal((manager as any).sessions.size, 0) - await assert.rejects(proxyFetch(`${first.proxyOrigin}/status`)) - }, (_req, res) => { - res.writeHead(200).end("ok") - }) - }) - - it("waits for in-flight idle cleanup during shutdown", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager({ disposalTimeoutMs: 1_000 }) - const session = await createSession(manager, `${upstreamBaseUrl}/base`) - const internalSession = (manager as any).sessions.get(session.sessionId) - const closeGate = deferred() - const originalClose = internalSession.app.close.bind(internalSession.app) - internalSession.app.close = async () => { - await closeGate.promise - return originalClose() - } - - internalSession.lastAccessAt = Date.now() - 31 * 60_000 - const cleanup = (manager as any).cleanupExpiredSessions() as Promise - assert.equal((manager as any).pendingDisposals.size, 1) - let shutdownSettled = false - const shutdown = manager.shutdown().then(() => { - shutdownSettled = true - }) - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(shutdownSettled, false) - - closeGate.resolve() - await cleanup - await shutdown - assert.equal((manager as any).pendingDisposals.size, 0) - }, (_req, res) => { - res.writeHead(200).end("ok") - }) - }) - - it("aborts a stalled event stream during bounded shutdown", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager({ disposalTimeoutMs: 100 }) - const session = await createSession(manager, `${upstreamBaseUrl}/base`) - await activateSession(session) - - const response = await proxyFetch(`${session.proxyOrigin}/events`) - assert.equal(response.status, 200) - await Promise.race([ - manager.shutdown(), - new Promise((_resolve, reject) => setTimeout(() => reject(new Error("shutdown stalled")), 500)), - ]) - - assert.equal((manager as any).sessions.size, 0) - assert.equal((manager as any).pendingDisposals.size, 0) - }, (req, res) => { - if (req.url === "/base/events") { - res.writeHead(200, { "content-type": "text/event-stream" }) - res.write("data: connected\n\n") - return - } - res.writeHead(200).end("ok") - }) - }) - - it("surfaces listener disposal failures", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager() - const session = await createSession(manager, `${upstreamBaseUrl}/base`) - const internalSession = (manager as any).sessions.get(session.sessionId) - const originalClose = internalSession.app.close.bind(internalSession.app) - internalSession.app.close = async () => { - await originalClose() - throw new Error("close failed") - } - - await assert.rejects(manager.deleteSession(session.sessionId), /Remote proxy disposal failed/) - await assert.rejects(manager.shutdown(), /Remote proxy shutdown failed/) - }, (_req, res) => { - res.writeHead(200).end("ok") - }) - }) - - it("waits for in-flight creation and rejects sessions that cross shutdown", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager() - const creation = manager.createSession(`${upstreamBaseUrl}/base`, false) - const shutdown = manager.shutdown() - - await assert.rejects(creation, /shutting down/) - await shutdown - - assert.equal((manager as any).pendingCreations.size, 0) - assert.equal((manager as any).sessions.size, 0) - await assert.rejects(manager.createSession(`${upstreamBaseUrl}/base`, false), /shutting down/) - }, (_req, res) => { - res.writeHead(200).end("ok") - }) - }) }) -function createSessionManager(options: { disposalTimeoutMs?: number } = {}) { +function createSessionManager() { const manager = new RemoteProxySessionManager({ authManager: { isLoopbackRequest: () => true, } as unknown as AuthManager, logger: createStubLogger(), httpsOptions: sharedHttpsOptions, - ...options, }) managers.add(manager) return manager } -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise - }) - return { promise, resolve } -} - async function createSession(manager: RemoteProxySessionManager, baseUrl: string) { const created = await manager.createSession(baseUrl, false) const windowUrl = new URL(created.windowUrl) @@ -324,7 +209,11 @@ async function proxyFetch(url: string, init?: Parameters[1]) { } async function disposeManager(manager: RemoteProxySessionManager) { - await manager.shutdown().catch(() => undefined) + const sessions = Array.from(((manager as any).sessions as Map).keys()) + for (const sessionId of sessions) { + await manager.deleteSession(sessionId) + } + clearInterval((manager as any).cleanupTimer as NodeJS.Timeout) } async function withUpstreamServer( diff --git a/packages/server/src/server/remote-proxy.ts b/packages/server/src/server/remote-proxy.ts index f0e9c09b0..93e6ab5e3 100644 --- a/packages/server/src/server/remote-proxy.ts +++ b/packages/server/src/server/remote-proxy.ts @@ -10,7 +10,6 @@ const LOOPBACK_HOST = "127.0.0.1" const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token" const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token" const SESSION_IDLE_TTL_MS = 30 * 60_000 -const SESSION_DISPOSAL_TIMEOUT_MS = 5_000 interface RemoteProxySession { id: string @@ -23,8 +22,7 @@ interface RemoteProxySession { activated: boolean cookiePrefix: string app: FastifyInstance - dispatcher: Agent - abortController: AbortController + dispatcher?: Agent createdAt: number lastAccessAt: number } @@ -33,7 +31,6 @@ export interface RemoteProxySessionManagerOptions { authManager: AuthManager logger: Logger httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } - disposalTimeoutMs?: number } export interface RemoteProxySessionCreateResult { @@ -43,38 +40,16 @@ export interface RemoteProxySessionCreateResult { export class RemoteProxySessionManager { private readonly sessions = new Map() - private readonly pendingCreations = new Set>() - private readonly pendingDisposals = new Set>() - private readonly disposalFailures: unknown[] = [] private readonly cleanupTimer: NodeJS.Timeout - private readonly disposalTimeoutMs: number - private shuttingDown = false constructor(private readonly options: RemoteProxySessionManagerOptions) { - this.disposalTimeoutMs = Math.max(1, options.disposalTimeoutMs ?? SESSION_DISPOSAL_TIMEOUT_MS) this.cleanupTimer = setInterval(() => { - void this.cleanupExpiredSessions().catch((error) => { - this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session") - }) + void this.cleanupExpiredSessions() }, 60_000) this.cleanupTimer.unref() } async createSession(baseUrl: string, skipTlsVerify: boolean): Promise { - if (this.shuttingDown) { - throw new Error("Remote proxy session manager is shutting down") - } - - const creation = this.createSessionInternal(baseUrl, skipTlsVerify) - this.pendingCreations.add(creation) - try { - return await creation - } finally { - this.pendingCreations.delete(creation) - } - } - - private async createSessionInternal(baseUrl: string, skipTlsVerify: boolean): Promise { if (!this.options.httpsOptions) { throw new Error("Local HTTPS is required for remote proxy sessions") } @@ -82,9 +57,8 @@ export class RemoteProxySessionManager { const targetBaseUrl = normalizeBaseUrl(baseUrl) const sessionId = randomUUID() const bootstrapToken = randomBytes(32).toString("base64url") - const dispatcher = new Agent(skipTlsVerify ? { connect: { rejectUnauthorized: false } } : {}) - const abortController = new AbortController() - const app = Fastify({ logger: false, https: this.options.httpsOptions, forceCloseConnections: true }) + const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined + const app = Fastify({ logger: false, https: this.options.httpsOptions }) let session: RemoteProxySession | null = null app.removeAllContentTypeParsers() @@ -156,16 +130,6 @@ export class RemoteProxySessionManager { }) const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 }) - if (this.shuttingDown) { - abortController.abort() - try { - await this.disposeResources(app, dispatcher) - } catch (error) { - this.disposalFailures.push(error) - throw error - } - throw new Error("Remote proxy session manager is shutting down") - } const address = new URL(addressInfo) const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`) const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl) @@ -183,7 +147,6 @@ export class RemoteProxySessionManager { cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`, app, dispatcher, - abortController, createdAt: Date.now(), lastAccessAt: Date.now(), } @@ -201,25 +164,6 @@ export class RemoteProxySessionManager { return this.disposeSession(sessionId) } - async shutdown(): Promise { - this.shuttingDown = true - clearInterval(this.cleanupTimer) - const disposals = new Set(this.pendingDisposals) - await Promise.allSettled(Array.from(this.pendingCreations)) - for (const sessionId of this.sessions.keys()) disposals.add(this.disposeSession(sessionId)) - while (this.pendingDisposals.size > 0) { - const pending = Array.from(this.pendingDisposals) - for (const disposal of pending) disposals.add(disposal) - await Promise.allSettled(pending) - } - const results = await Promise.allSettled(disposals) - const failures = [ - ...this.disposalFailures, - ...results.flatMap((result) => result.status === "rejected" ? [result.reason] : []), - ] - if (failures.length > 0) throw createDisposalError("Remote proxy shutdown failed", failures) - } - private async cleanupExpiredSessions() { const now = Date.now() for (const session of Array.from(this.sessions.values())) { @@ -230,55 +174,20 @@ export class RemoteProxySessionManager { } } - private disposeSession(sessionId: string): Promise { + private async disposeSession(sessionId: string): Promise { const session = this.sessions.get(sessionId) if (!session) { - return Promise.resolve(false) + return false } this.sessions.delete(sessionId) - session.abortController.abort() - const disposal = this.disposeResources(session.app, session.dispatcher) - .then(() => { - this.options.logger.info({ sessionId }, "Disposed remote proxy session") - return true - }) - .catch((error) => { - this.disposalFailures.push(error) - throw error - }) - this.pendingDisposals.add(disposal) - void disposal.then( - () => this.pendingDisposals.delete(disposal), - () => this.pendingDisposals.delete(disposal), - ) - return disposal - } - - private async disposeResources(app: FastifyInstance, dispatcher: Agent): Promise { - app.server.closeAllConnections?.() - const disposal = Promise.allSettled([app.close(), dispatcher.destroy()]) - let timeout: NodeJS.Timeout | undefined - const deadline = new Promise((_resolve, reject) => { - timeout = setTimeout(() => reject(new Error( - `Remote proxy disposal timed out after ${this.disposalTimeoutMs}ms`, - )), this.disposalTimeoutMs) - timeout.unref() - }) - try { - const results = await Promise.race([disposal, deadline]) - const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []) - if (failures.length > 0) throw createDisposalError("Remote proxy disposal failed", failures) - } finally { - if (timeout) clearTimeout(timeout) - } + session.dispatcher?.close().catch(() => {}) + await session.app.close().catch(() => {}) + this.options.logger.info({ sessionId }, "Disposed remote proxy session") + return true } } -function createDisposalError(message: string, failures: unknown[]): Error & { failures: unknown[] } { - return Object.assign(new Error(message), { failures }) -} - function normalizeBaseUrl(input: string): URL { const parsed = new URL(input.trim()) if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { @@ -416,7 +325,6 @@ async function proxyRequest(args: { method: request.method, headers, dispatcher: session.dispatcher, - signal: session.abortController.signal, redirect: "manual", } diff --git a/packages/server/src/server/routes/workspaces.test.ts b/packages/server/src/server/routes/workspaces.test.ts deleted file mode 100644 index 063a02d78..000000000 --- a/packages/server/src/server/routes/workspaces.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import Fastify from "fastify" - -import type { WorkspaceDescriptor } from "../../api-types" -import type { WorkspaceManager } from "../../workspaces/manager" -import { registerWorkspaceRoutes } from "./workspaces" - -describe("workspace routes", () => { - it("forwards a validated explicit binary path when creating a workspace", async () => { - const calls: unknown[][] = [] - const app = Fastify({ logger: false }) - const descriptor: WorkspaceDescriptor = { - id: "workspace", - path: "C:/work", - status: "ready", - proxyPath: "/workspaces/workspace/instance", - binaryId: "C:/tools/opencode.exe", - binaryLabel: "opencode.exe", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - } - const workspaceManager = { - create: async (...args: unknown[]) => { - calls.push(args) - return { workspace: descriptor, created: true } - }, - releaseCreationRequest: (workspaceId: string, requestId: string) => - workspaceId === descriptor.id && requestId === "restore-request", - cancelCreationRequest: async (requestId: string) => { - calls.push(["cancel", requestId]) - }, - } as unknown as WorkspaceManager - registerWorkspaceRoutes(app, { workspaceManager }) - - const response = await app.inject({ - method: "POST", - url: "/api/workspaces", - payload: { - path: "C:/work", - name: "Work", - binaryPath: " C:/tools/opencode.exe ", - requestId: " restore-request ", - forceNew: true, - }, - }) - - assert.equal(response.statusCode, 201) - assert.deepEqual(calls, [["C:/work", "Work", { - binaryPath: "C:/tools/opencode.exe", - requestId: "restore-request", - forceNew: true, - }]]) - - const released = await app.inject({ - method: "POST", - url: "/api/workspaces/workspace/creation/release", - payload: { requestId: "restore-request" }, - }) - assert.equal(released.statusCode, 204) - - const wrongRelease = await app.inject({ - method: "POST", - url: "/api/workspaces/workspace/creation/release", - payload: { requestId: "other-request" }, - }) - assert.equal(wrongRelease.statusCode, 404) - - const cancelled = await app.inject({ - method: "POST", - url: "/api/workspaces/creation/cancel", - payload: { requestId: "restore-request" }, - }) - assert.equal(cancelled.statusCode, 204) - assert.deepEqual(calls.at(-1), ["cancel", "restore-request"]) - - const invalid = await app.inject({ - method: "POST", - url: "/api/workspaces", - payload: { path: "C:/work", binaryPath: "x".repeat(4097) }, - }) - assert.equal(invalid.statusCode, 400) - assert.equal(calls.length, 2) - await app.close() - }) -}) diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index e7f052136..5450f38eb 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -14,8 +14,6 @@ interface RouteDeps { const WorkspaceCreateSchema = z.object({ path: z.string(), name: z.string().optional(), - binaryPath: z.string().trim().min(1).max(4096).optional(), - requestId: z.string().trim().min(1).max(128).optional(), forceNew: z.boolean().optional(), }) @@ -25,10 +23,6 @@ const WorkspaceCloneSchema = z.object({ cleanup: z.boolean().optional(), }) -const WorkspaceCreationReleaseSchema = z.object({ - requestId: z.string().trim().min(1).max(128), -}) - const WorkspaceFilesQuerySchema = z.object({ path: z.string().optional(), }) @@ -75,11 +69,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { app.post("/api/workspaces", async (request, reply) => { try { const body = WorkspaceCreateSchema.parse(request.body ?? {}) - const result = await deps.workspaceManager.create(body.path, body.name, { - binaryPath: body.binaryPath, - requestId: body.requestId, - forceNew: body.forceNew, - }) + const result = await deps.workspaceManager.create(body.path, body.name, { forceNew: body.forceNew }) reply.code(201) return result.created ? result.workspace : { ...result.workspace, reused: true as const } } catch (error) { @@ -114,30 +104,6 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { reply.code(204) }) - app.post("/api/workspaces/creation/cancel", async (request, reply) => { - const parsed = WorkspaceCreationReleaseSchema.safeParse(request.body ?? {}) - if (!parsed.success) { - reply.code(400).type("text/plain").send("Invalid workspace creation request") - return - } - await deps.workspaceManager.cancelCreationRequest(parsed.data.requestId) - reply.code(204) - }) - - app.post<{ Params: { id: string } }>("/api/workspaces/:id/creation/release", async (request, reply) => { - const parsed = WorkspaceCreationReleaseSchema.safeParse(request.body ?? {}) - if (!parsed.success) { - reply.code(400).type("text/plain").send("Invalid workspace creation request") - return - } - const body = parsed.data - if (!deps.workspaceManager.releaseCreationRequest(request.params.id, body.requestId)) { - reply.code(404).type("text/plain").send("Workspace creation request not found") - return - } - reply.code(204) - }) - app.get<{ Params: { id: string } Querystring: { path?: string } diff --git a/packages/server/src/settings/binaries.test.ts b/packages/server/src/settings/binaries.test.ts deleted file mode 100644 index 1c4523f2c..000000000 --- a/packages/server/src/settings/binaries.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { BinaryResolver } from "./binaries" -import type { SettingsService } from "./service" - -describe("BinaryResolver", () => { - it("uses an explicit workspace binary without changing the configured default", () => { - const settings = { - getOwner(scope: string, owner: string) { - if (scope === "config" && owner === "server") return { opencodeBinary: "default-opencode" } - if (scope === "state" && owner === "ui") { - return { opencodeBinaries: [{ path: "saved-opencode", label: "Saved", version: "1.2.3" }] } - } - return {} - }, - } as unknown as SettingsService - const resolver = new BinaryResolver(settings) - - assert.deepEqual(resolver.resolve("saved-opencode"), { - path: "saved-opencode", - label: "Saved", - version: "1.2.3", - }) - assert.equal(resolver.resolveDefault().path, "default-opencode") - }) -}) diff --git a/packages/server/src/settings/binaries.ts b/packages/server/src/settings/binaries.ts index d637ac95d..e4b25960b 100644 --- a/packages/server/src/settings/binaries.ts +++ b/packages/server/src/settings/binaries.ts @@ -40,14 +40,10 @@ export class BinaryResolver { } resolveDefault(): ResolvedBinary { - return this.resolve() - } - - resolve(explicitPath?: string): ResolvedBinary { const binaries = this.list() const configuredDefault = readDefaultBinaryPath(this.settings) const fallback = binaries[0]?.path - const path = explicitPath?.trim() || configuredDefault || fallback || "opencode" + const path = configuredDefault ?? fallback ?? "opencode" const entry = binaries.find((b) => b.path === path) return { diff --git a/packages/server/src/shutdown.test.ts b/packages/server/src/shutdown.test.ts deleted file mode 100644 index 4ae5e9f06..000000000 --- a/packages/server/src/shutdown.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { - createServerShutdownHandler, - orchestrateServerShutdown, - ServerShutdownError, - type ServerShutdownOperations, -} from "./shutdown" - -const logger = { - info: () => undefined, - warn: () => undefined, - error: () => undefined, -} - -function operations(overrides: Partial = {}): ServerShutdownOperations { - return { - stopInstanceEventBridge: () => undefined, - stopSidecars: () => undefined, - stopClientConnections: () => undefined, - stopRemoteProxySessions: () => undefined, - stopWorkspaces: () => undefined, - stopHttpServers: () => undefined, - stopReleaseMonitor: () => undefined, - ...overrides, - } -} - -describe("server shutdown orchestration", () => { - it("retries workspace cleanup before reporting success", async () => { - let attempts = 0 - await orchestrateServerShutdown( - operations({ - stopWorkspaces: () => { - attempts += 1 - if (attempts === 1) throw new Error("detached process still alive") - }, - }), - logger, - ) - - assert.equal(attempts, 2) - }) - - it("stops remote proxy sessions before workspace and HTTP shutdown", async () => { - const closed: string[] = [] - await orchestrateServerShutdown(operations({ - stopRemoteProxySessions: () => { closed.push("remote-proxy") }, - stopWorkspaces: () => { closed.push("workspaces") }, - stopHttpServers: () => { closed.push("http") }, - }), logger) - - assert.deepEqual(closed, ["remote-proxy", "workspaces", "http"]) - }) - - it("closes other resources and rejects with the concrete workspace failure", async () => { - const closed: string[] = [] - const cleanupFailure = new Error("workspace abc POSIX process group is still alive") - let attempts = 0 - - await assert.rejects( - orchestrateServerShutdown( - operations({ - stopWorkspaces: () => { - attempts += 1 - throw cleanupFailure - }, - stopHttpServers: () => { - closed.push("http") - }, - stopReleaseMonitor: () => { - closed.push("release-monitor") - }, - }), - logger, - ), - (error: unknown) => { - assert.ok(error instanceof ServerShutdownError) - assert.equal(error.failures[0]?.resource, "Workspace manager") - assert.strictEqual(error.failures[0]?.error, cleanupFailure) - return true - }, - ) - - assert.equal(attempts, 2) - assert.deepEqual(closed, ["http", "release-monitor"]) - }) -}) - -describe("server shutdown signal boundary", () => { - it("forces a nonzero exit after failed bounded cleanup returns", async () => { - const calls: string[] = [] - const exits: number[] = [] - const handler = createServerShutdownHandler({ - shutdown: async () => { - calls.push("cleanup") - throw new Error("retained child survived") - }, - logger: { - info: () => calls.push("info"), - warn: () => undefined, - error: () => calls.push("error"), - }, - forceExit: (code) => { - calls.push("force-exit") - exits.push(code) - }, - }) - - await handler("SIGTERM") - - assert.deepEqual(exits, [1]) - assert.deepEqual(calls, ["info", "cleanup", "error", "force-exit"]) - }) - - it("escalates a second signal while shutdown is still pending", async () => { - let finishCleanup!: () => void - const cleanup = new Promise((resolve) => { - finishCleanup = resolve - }) - const exits: number[] = [] - const exitCodes: number[] = [] - const handler = createServerShutdownHandler({ - shutdown: () => cleanup, - logger, - forceExit: (code) => exits.push(code), - setExitCode: (code) => exitCodes.push(code), - }) - - const first = handler("SIGINT") - const second = handler("SIGTERM") - assert.strictEqual(first, second) - assert.deepEqual(exits, [1]) - - finishCleanup() - await first - assert.deepEqual(exitCodes, [0]) - }) -}) diff --git a/packages/server/src/shutdown.ts b/packages/server/src/shutdown.ts deleted file mode 100644 index 717df67fe..000000000 --- a/packages/server/src/shutdown.ts +++ /dev/null @@ -1,109 +0,0 @@ -interface ShutdownLogger { - info: (data: unknown, message?: string) => void - warn: (data: unknown, message?: string) => void - error: (data: unknown, message?: string) => void -} - -export interface ServerShutdownOperations { - stopInstanceEventBridge: () => void | Promise - stopSidecars: () => void | Promise - stopClientConnections: () => void | Promise - stopRemoteProxySessions: () => void | Promise - stopWorkspaces: () => void | Promise - stopHttpServers: () => void | Promise - stopReleaseMonitor: () => void | Promise -} - -export class ServerShutdownError extends Error { - readonly failures: Array<{ resource: string; error: unknown }> - - constructor(failures: Array<{ resource: string; error: unknown }>) { - super(`Server shutdown failed while stopping: ${failures.map((failure) => failure.resource).join(", ")}`) - this.name = "ServerShutdownError" - this.failures = failures - } -} - -export interface ServerShutdownHandlerOptions { - shutdown: () => Promise - logger: ShutdownLogger - forceExit?: (code: number) => void - setExitCode?: (code: number) => void -} - -export function createServerShutdownHandler(options: ServerShutdownHandlerOptions) { - const forceExit = options.forceExit ?? ((code: number) => process.exit(code)) - const setExitCode = options.setExitCode ?? ((code: number) => { - process.exitCode = code - }) - let shutdownPromise: Promise | undefined - - return (signal: NodeJS.Signals): Promise => { - if (shutdownPromise) { - options.logger.error({ signal }, "Additional shutdown signal received; forcing nonzero exit") - forceExit(1) - return shutdownPromise - } - - options.logger.info({ signal }, "Received shutdown signal, stopping workspaces and server") - shutdownPromise = (async () => { - try { - await options.shutdown() - options.logger.info({}, "Shutdown complete") - setExitCode(0) - } catch (error) { - options.logger.error({ err: error }, "Server shutdown incomplete; forcing nonzero exit") - forceExit(1) - } - })() - return shutdownPromise - } -} - -export async function orchestrateServerShutdown( - operations: ServerShutdownOperations, - logger: ShutdownLogger, - workspaceAttempts = 2, -): Promise { - const failures: Array<{ resource: string; error: unknown }> = [] - const run = async (resource: string, operation: () => void | Promise) => { - try { - await operation() - } catch (error) { - failures.push({ resource, error }) - logger.error({ err: error, resource }, `${resource} shutdown failed`) - } - } - - await run("Instance event bridge", operations.stopInstanceEventBridge) - await run("SideCar manager", operations.stopSidecars) - await run("Client connection manager", operations.stopClientConnections) - await run("Remote proxy session manager", operations.stopRemoteProxySessions) - - const attempts = Math.max(1, Math.floor(workspaceAttempts)) - let workspaceFailure: unknown - for (let attempt = 1; attempt <= attempts; attempt += 1) { - try { - await operations.stopWorkspaces() - workspaceFailure = undefined - logger.info({ attempt }, "Workspace manager shutdown complete") - break - } catch (error) { - workspaceFailure = error - if (attempt < attempts) { - logger.warn({ err: error, attempt, attempts }, "Workspace manager shutdown failed; retrying cleanup") - } - } - } - if (workspaceFailure !== undefined) { - failures.push({ resource: "Workspace manager", error: workspaceFailure }) - logger.error({ err: workspaceFailure, attempts }, "Workspace manager shutdown failed") - } - - await run("HTTP servers", operations.stopHttpServers) - await run("Release monitor", operations.stopReleaseMonitor) - - if (failures.length > 0) { - throw new ServerShutdownError(failures) - } -} diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index d2e9822ef..a5fb2eda8 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -1,10 +1,7 @@ import assert from "node:assert/strict" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import path from "node:path" import { describe, it } from "node:test" -import { buildWindowsSpawnSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn" +import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn" describe("parseWslUncPath", () => { it("parses WSL UNC paths into distro and linux path", () => { @@ -50,70 +47,6 @@ describe("resolveWslWorkingDirectory", () => { }) describe("buildWindowsSpawnSpec", () => { - it("classifies native executables separately from script and shell wrappers", () => { - assert.equal(buildWindowsSpawnSpec("opencode.exe", []).processKind, "windows-direct") - assert.equal(buildWindowsSpawnSpec("opencode.cmd", []).processKind, "windows-wrapper") - assert.equal(buildWindowsSpawnSpec("powershell.exe", []).processKind, "windows-wrapper") - }) - - it("conservatively classifies bare commands as wrappers", () => { - assert.equal(buildWindowsSpawnSpec("opencode", []).processKind, "windows-wrapper") - }) - - it("resolves a bare cmd shim from a quoted PATH entry and wraps its absolute path", () => { - const root = mkdtempSync(path.join(tmpdir(), "codenomad-spawn-")) - const cwd = path.join(root, "workspace") - const bin = path.join(root, "bin with spaces") - mkdirSync(cwd) - mkdirSync(bin) - const shim = path.join(bin, "opencode.cmd") - writeFileSync(shim, "@echo off\r\n") - - try { - const spec = buildWindowsSpawnSpec("opencode", ["serve"], { - cwd, - env: { Path: `"${bin}"`, PathExt: ".CMD;.EXE", ComSpec: "test-cmd.exe" }, - }) - - assert.equal(spec.command, "test-cmd.exe") - assert.equal(spec.processKind, "windows-wrapper") - assert.equal(spec.options.windowsVerbatimArguments, true) - assert.match(spec.args[3] ?? "", new RegExp(escapeRegex(path.win32.resolve(shim)), "i")) - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) - - it("honors PATHEXT precedence when both native and shim files exist", () => { - const root = mkdtempSync(path.join(tmpdir(), "codenomad-spawn-")) - writeFileSync(path.join(root, "opencode.cmd"), "@echo off\r\n") - writeFileSync(path.join(root, "opencode.exe"), "") - - try { - const spec = buildWindowsSpawnSpec("opencode", [], { - cwd: root, - env: { PATH: "", PATHEXT: ".EXE;.CMD" }, - }) - - assert.equal(spec.command.toLowerCase(), path.win32.resolve(root, "opencode.exe").toLowerCase()) - assert.equal(spec.processKind, "windows-direct") - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) - - it("leaves an unresolved bare command unchanged without injecting a shell", () => { - const spec = buildWindowsSpawnSpec("missing-opencode", ["serve"], { - cwd: String.raw`C:\missing-workspace`, - env: { PATH: "", PATHEXT: ".CMD;.EXE", ComSpec: "must-not-run.exe" }, - }) - - assert.equal(spec.command, "missing-opencode") - assert.deepEqual(spec.args, ["serve"]) - assert.equal(spec.processKind, "windows-wrapper") - assert.equal(spec.options.windowsVerbatimArguments, undefined) - }) - it("wraps WSL binaries with wsl.exe and propagates required env vars", () => { const spec = buildWindowsSpawnSpec( String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, @@ -267,7 +200,7 @@ describe("buildWindowsSpawnSpec", () => { "--exec", "sh", "-lc", - `codenomad_pgid=$(ps -o pgid= -p "$$" 2>/dev/null | tr -d '[:space:]'); codenomad_start=$(awk '{print $22}' "/proc/$$/stat" 2>/dev/null); codenomad_boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null); test -n "$codenomad_pgid" && test -n "$codenomad_start" && test -n "$codenomad_boot" && printf '%s%s:%s:%s:%s\\n' '__CODENOMAD_WSL_PID__:' "$$" "$codenomad_pgid" "$codenomad_start" "$codenomad_boot" && cd "$1" && shift && exec "$@"`, + `printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`, "codenomad-wsl-launch", "/home/dev/workspace", "/home/dev/.opencode/bin/opencode", @@ -276,8 +209,10 @@ describe("buildWindowsSpawnSpec", () => { assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:") }) -}) + it("builds the WSL kill command for tracked Linux PIDs", () => { + const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM") -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} + assert.equal(spec.command, "wsl.exe") + assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"]) + }) +}) diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts index 5854adae0..b3686cbe4 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -41,7 +41,7 @@ function createManager(rootDir: string): WorkspaceManager { rootDir, settings: { getOwner: () => ({ environmentVariables: {} }) }, binaryResolver: { - resolve: () => ({ path: process.execPath, label: "Node.js", version: process.version }), + resolveDefault: () => ({ path: process.execPath, label: "Node.js", version: process.version }), }, eventBus: new EventBus(logger), logger, @@ -53,7 +53,6 @@ function createManager(rootDir: string): WorkspaceManager { pid: 123, port: 4321, exitPromise: new Promise(() => {}), - cancellationPromise: new Promise(() => {}), getLastOutput: () => "", }) internal.waitForWorkspaceReadiness = async () => undefined @@ -100,147 +99,38 @@ describe("workspace identity", () => { it("shares an in-flight startup between canonical aliases", async () => { const { root, target, link } = await createLinkedWorkspace() const manager = createManager(root) - const launchGate = deferred() let launches = 0 ;(manager as any).runtime.launch = async () => { launches += 1 - await launchGate.promise + await new Promise((resolve) => setTimeout(resolve, 20)) return { pid: 123, port: 4321, exitPromise: new Promise(() => {}), - cancellationPromise: new Promise(() => {}), getLastOutput: () => "", } } - const firstPromise = manager.create(target, undefined, { requestId: "restore-request" }) - const secondPromise = manager.create(link) - while (![...(manager as any).pendingWorkspaceCreations.values()][0]?.followerCount) { - await new Promise((resolve) => setImmediate(resolve)) - } - launchGate.resolve() - const [first, second] = await Promise.all([firstPromise, secondPromise]) + const [first, second] = await Promise.all([manager.create(target), manager.create(link)]) assert.equal(launches, 1) - assert.equal(first.created, false) - assert.equal(first.workspace.requestId, undefined) + assert.equal(Number(first.created) + Number(second.created), 1) assert.equal(first.workspace.id, second.workspace.id) assert.equal(first.workspace.status, "ready") assert.equal(second.workspace.status, "ready") }) - it("keeps both cleanup owners when two restore requests share a canonical launch", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const [first, second] = await Promise.all([ - manager.create(target, undefined, { requestId: "restore-first" }), - manager.create(link, undefined, { requestId: "restore-second" }), - ]) - - assert.equal(Number(first.created) + Number(second.created), 1) - assert.equal(first.workspace.id, second.workspace.id) - assert.equal(first.workspace.requestId, "restore-first") - assert.equal(second.workspace.requestId, "restore-second") - assert.equal(manager.releaseCreationRequest(first.workspace.id, "restore-first"), true) - assert.equal(manager.releaseCreationRequest(first.workspace.id, "restore-second"), true) - }) - - it("detaches a cancelled restore follower without stopping the leader launch", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const launchGate = deferred() - let launches = 0 - ;(manager as any).runtime.launch = async () => { - launches += 1 - await launchGate.promise - return { - pid: 123, - port: 4321, - exitPromise: new Promise(() => {}), - cancellationPromise: new Promise(() => {}), - getLastOutput: () => "", - } - } - - const leader = manager.create(target, undefined, { requestId: "restore-leader" }) - const follower = manager.create(link, undefined, { requestId: "restore-follower" }) - while (![...(manager as any).pendingWorkspaceCreations.values()][0]?.followerCount) { - await new Promise((resolve) => setImmediate(resolve)) - } - await manager.cancelCreationRequest("restore-follower") - launchGate.resolve() - - const leaderResult = await leader - await assert.rejects(follower, /creation request restore-follower was cancelled/) - assert.equal(launches, 1) - assert.equal(leaderResult.workspace.requestId, "restore-leader") - assert.equal(manager.list().length, 1) - }) - - it("detaches a cancelled restore leader without stopping a follower launch", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const launchGate = deferred() - ;(manager as any).runtime.launch = async () => { - await launchGate.promise - return { - pid: 123, - port: 4321, - exitPromise: new Promise(() => {}), - cancellationPromise: new Promise(() => {}), - getLastOutput: () => "", - } - } - - const leader = manager.create(target, undefined, { requestId: "restore-leader" }) - const follower = manager.create(link, undefined, { requestId: "restore-follower" }) - while (![...(manager as any).pendingWorkspaceCreations.values()][0]?.followerCount) { - await new Promise((resolve) => setImmediate(resolve)) - } - await manager.cancelCreationRequest("restore-leader") - launchGate.resolve() - - await assert.rejects(leader, /creation request restore-leader was cancelled/) - const followerResult = await follower - assert.equal(followerResult.workspace.requestId, "restore-follower") - assert.equal(manager.list().length, 1) - }) - - it("retains a shared workspace after one restore owner releases and another cancels", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const [first, second] = await Promise.all([ - manager.create(target, undefined, { requestId: "restore-first" }), - manager.create(link, undefined, { requestId: "restore-second" }), - ]) - - assert.equal(manager.releaseCreationRequest(first.workspace.id, "restore-first"), true) - await manager.cancelCreationRequest("restore-second") - - assert.equal(manager.list().length, 1) - assert.equal(manager.get(second.workspace.id)?.requestId, undefined) - assert.equal(manager.releaseCreationRequest(second.workspace.id, "restore-second"), true) - }) - it("releases a failed identity reservation so creation can be retried", async () => { const { root, target, link } = await createLinkedWorkspace() const manager = createManager(root) - const launchGate = deferred() let launches = 0 ;(manager as any).runtime.launch = async () => { launches += 1 - await launchGate.promise + await new Promise((resolve) => setTimeout(resolve, 20)) throw new Error("launch failed") } - const firstFailure = manager.create(target) - const secondFailure = manager.create(link) - while (![...(manager as any).pendingWorkspaceCreations.values()][0]?.followerCount) { - await new Promise((resolve) => setImmediate(resolve)) - } - launchGate.resolve() - const failed = await Promise.allSettled([firstFailure, secondFailure]) + const failed = await Promise.allSettled([manager.create(target), manager.create(link)]) assert.deepEqual(failed.map((result) => result.status), ["rejected", "rejected"]) assert.equal(launches, 1) @@ -248,7 +138,6 @@ describe("workspace identity", () => { pid: 456, port: 5432, exitPromise: new Promise(() => {}), - cancellationPromise: new Promise(() => {}), getLastOutput: () => "", }) const retry = await manager.create(target) @@ -271,27 +160,6 @@ describe("workspace identity", () => { assert.equal(manager.list().length, 2) }) - it("does not reuse restore-owned workspaces until hydration releases ownership", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const restore = await manager.create(target, undefined, { - forceNew: true, - requestId: "restore-request", - }) - - const concurrent = await manager.create(link) - assert.equal(concurrent.created, true) - assert.notEqual(concurrent.workspace.id, restore.workspace.id) - - assert.equal(manager.releaseCreationRequest(restore.workspace.id, "wrong-request"), false) - assert.equal(manager.releaseCreationRequest(restore.workspace.id, "restore-request"), true) - assert.equal(manager.releaseCreationRequest(restore.workspace.id, "restore-request"), true) - await manager.delete(concurrent.workspace.id) - const reused = await manager.create(link) - assert.equal(reused.created, false) - assert.equal(reused.workspace.id, restore.workspace.id) - }) - it("keeps the canonical reservation when a forced duplicate is deleted", async () => { const { root, target, link } = await createLinkedWorkspace() const manager = createManager(root) @@ -299,39 +167,35 @@ describe("workspace identity", () => { pid: number port: number exitPromise: Promise - cancellationPromise: Promise getLastOutput: () => string }>() const forcedLaunch = deferred<{ pid: number port: number exitPromise: Promise - cancellationPromise: Promise getLastOutput: () => string }>() let launches = 0 - const launchedWorkspaceIds: string[] = [] - ;(manager as any).runtime.launch = (options: { workspaceId: string }) => { + ;(manager as any).runtime.launch = () => { launches += 1 - launchedWorkspaceIds.push(options.workspaceId) return launches === 1 ? normalLaunch.promise : forcedLaunch.promise } const first = manager.create(target) - while (launchedWorkspaceIds.length < 1) { + while (manager.list().length < 1) { await new Promise((resolve) => setImmediate(resolve)) } const forced = manager.create(link, undefined, { forceNew: true }) - const forcedRejected = assert.rejects(forced, /launch was cancelled/) - while (launchedWorkspaceIds.length < 2) { + const forcedRejected = assert.rejects(forced, /Workspace creation cancelled/) + while (manager.list().length < 2) { await new Promise((resolve) => setImmediate(resolve)) } - const forcedDeletion = manager.delete(launchedWorkspaceIds[1]!) + const forcedWorkspace = manager.list().find((workspace) => workspace.id !== manager.list()[0].id)! + await manager.delete(forcedWorkspace.id) const reused = manager.create(link) - normalLaunch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), cancellationPromise: new Promise(() => {}), getLastOutput: () => "" }) - forcedLaunch.resolve({ pid: 456, port: 5432, exitPromise: new Promise(() => {}), cancellationPromise: new Promise(() => {}), getLastOutput: () => "" }) - await forcedDeletion + normalLaunch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), getLastOutput: () => "" }) + forcedLaunch.resolve({ pid: 456, port: 5432, exitPromise: new Promise(() => {}), getLastOutput: () => "" }) const [firstResult, reusedResult] = await Promise.all([first, reused]) await forcedRejected @@ -347,68 +211,26 @@ describe("workspace identity", () => { pid: number port: number exitPromise: Promise - cancellationPromise: Promise getLastOutput: () => string }>() - let workspaceId = "" - ;(manager as any).runtime.launch = (options: { workspaceId: string }) => { - workspaceId = options.workspaceId - return launch.promise - } + ;(manager as any).runtime.launch = () => launch.promise const events: any[] = [] ;(manager as any).options.eventBus.onEvent((event: any) => events.push(event)) const creation = manager.create(target) - const creationRejected = assert.rejects(creation, /launch was cancelled/) - while (!workspaceId) { + const creationRejected = assert.rejects(creation, /Workspace creation cancelled/) + while (manager.list().length === 0) { await new Promise((resolve) => setImmediate(resolve)) } - const deletion = manager.delete(workspaceId) - launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), cancellationPromise: new Promise(() => {}), getLastOutput: () => "" }) + const workspaceId = manager.list()[0].id + await manager.delete(workspaceId) + launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), getLastOutput: () => "" }) - await Promise.all([creationRejected, deletion]) + await creationRejected assert.equal(manager.get(workspaceId), undefined) assert.equal(events.filter((event) => event.type === "workspace.stopped" && event.workspaceId === workspaceId).length, 1) }) - it("cancels an in-flight restore startup by request id", async () => { - const { root, target } = await createLinkedWorkspace() - const manager = createManager(root) - const launch = deferred<{ - pid: number - port: number - exitPromise: Promise - cancellationPromise: Promise - getLastOutput: () => string - }>() - let workspaceId = "" - ;(manager as any).runtime.launch = (options: { workspaceId: string }) => { - workspaceId = options.workspaceId - return launch.promise - } - - const creation = manager.create(target, undefined, { requestId: "cancel-restore" }) - const creationRejected = assert.rejects(creation, /launch was cancelled/) - while (!workspaceId) await new Promise((resolve) => setImmediate(resolve)) - const cancellation = manager.cancelCreationRequest("cancel-restore") - launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), cancellationPromise: new Promise(() => {}), getLastOutput: () => "" }) - - await Promise.all([creationRejected, cancellation]) - assert.equal(manager.get(workspaceId), undefined) - }) - - it("remembers cancellation that arrives before restore reservation", async () => { - const { root, target } = await createLinkedWorkspace() - const manager = createManager(root) - - await manager.cancelCreationRequest("early-cancel") - await assert.rejects( - manager.create(target, undefined, { requestId: "early-cancel" }), - /creation request early-cancel was cancelled/, - ) - assert.equal(manager.list().length, 0) - }) - it("waits for and cancels forced creations during shutdown", async () => { const { root, target } = await createLinkedWorkspace() const manager = createManager(root) @@ -416,22 +238,17 @@ describe("workspace identity", () => { pid: number port: number exitPromise: Promise - cancellationPromise: Promise getLastOutput: () => string }>() - let workspaceId = "" - ;(manager as any).runtime.launch = (options: { workspaceId: string }) => { - workspaceId = options.workspaceId - return launch.promise - } + ;(manager as any).runtime.launch = () => launch.promise const creation = manager.create(target, undefined, { forceNew: true }) - const creationRejected = assert.rejects(creation, /launch was cancelled/) - while (!workspaceId) { + const creationRejected = assert.rejects(creation, /Workspace creation cancelled/) + while (manager.list().length === 0) { await new Promise((resolve) => setImmediate(resolve)) } const shutdown = manager.shutdown() - launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), cancellationPromise: new Promise(() => {}), getLastOutput: () => "" }) + launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), getLastOutput: () => "" }) await Promise.all([creationRejected, shutdown]) assert.equal(manager.list().length, 0) diff --git a/packages/server/src/workspaces/launch-cleanup.test.ts b/packages/server/src/workspaces/launch-cleanup.test.ts deleted file mode 100644 index 92f72b35f..000000000 --- a/packages/server/src/workspaces/launch-cleanup.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import assert from "node:assert/strict" -import type { SpawnSyncReturns } from "node:child_process" -import { describe, it } from "node:test" - -import { - LAUNCH_CLEANUP_TOKEN_ENV, - probeLaunchCleanupToken, - signalLaunchCleanupToken, -} from "./launch-cleanup" - -type SpawnCommand = typeof import("node:child_process").spawnSync - -function result(stdout = "", status = 0, stderr = ""): SpawnSyncReturns { - return { pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null } -} - -describe("launch cleanup token adapter", () => { - it("passes the exact token to a bounded Linux environ probe", () => { - const token = "a".repeat(64) - let invocation: { command: string; args: readonly string[]; timeout?: number } | undefined - const probe = probeLaunchCleanupToken(((command: string, args: readonly string[], options: { timeout?: number }) => { - invocation = { command, args, timeout: options.timeout } - return result("5000|1|4242|150|boot-a|150\n") - }) as unknown as SpawnCommand, token, 25) - - assert.equal(probe.ok && probe.processes.get(5000)?.startOrder, "150") - assert.equal(invocation?.command, "sh") - assert.equal(invocation?.timeout, 25) - assert.ok(invocation?.args.includes(LAUNCH_CLEANUP_TOKEN_ENV)) - assert.ok(invocation?.args.includes(token)) - assert.match(invocation?.args[1] ?? "", /\/proc\/\$1\/environ/) - }) - - it("signals every exact-token target returned by the bounded adapter", () => { - const cleanup = signalLaunchCleanupToken((() => result( - "CODENOMAD_TARGET|4242|1|4242|100|boot-a|100\n" + - "CODENOMAD_TARGET|5000|1|4242|150|boot-a|150\n" + - "CODENOMAD_RESULT|1\n", - )) as unknown as SpawnCommand, "b".repeat(64), "SIGKILL", 25) - - assert.equal(cleanup.ok, true) - assert.deepEqual(cleanup.targets.map((target) => target.pid), [4242, 5000]) - }) - - it("rejects malformed cleanup records conservatively", () => { - const probe = probeLaunchCleanupToken((() => result("5000|1|4242|150|boot-a|150|truncated\n")) as unknown as SpawnCommand, "c".repeat(64), 25) - - assert.equal(probe.ok, false) - }) -}) diff --git a/packages/server/src/workspaces/launch-cleanup.ts b/packages/server/src/workspaces/launch-cleanup.ts deleted file mode 100644 index b5ab35079..000000000 --- a/packages/server/src/workspaces/launch-cleanup.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { SpawnSyncReturns, spawnSync } from "node:child_process" - -import type { ProcessIdentity, ProcessSnapshot } from "./process-identity" - -export const LAUNCH_CLEANUP_TOKEN_ENV = "CODENOMAD_LAUNCH_CLEANUP_TOKEN" - -type SpawnCommand = typeof spawnSync - -export interface TokenSignalResult { - ok: boolean - signalSent: boolean - targets: ProcessIdentity[] - error?: string -} - -const LINUX_TOKEN_PROBE_SCRIPT = String.raw` -boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || exit 20 -key=$1; expected=$2 -matches_token() { - test -r "/proc/$1/environ" || return 1 - tr '\0' '\n' < "/proc/$1/environ" 2>/dev/null | grep -Fqx -- "$key=$expected" -} -read_stat() { - line=$(cat "/proc/$1/stat" 2>/dev/null) || return 1 - stat_pid=$(printf '%s\n' "$line" | cut -d' ' -f1); rest=$(printf '%s\n' "$line" | sed 's/^.*) //'); set -- $rest - stat_ppid=$2; stat_group=$3; stat_start=$20 -} -for environ in /proc/[0-9]*/environ; do - pid=$(basename "$(dirname "$environ")") - if matches_token "$pid" && read_stat "$pid"; then - printf '%s|%s|%s|%s|%s|%s\n' "$stat_pid" "$stat_ppid" "$stat_group" "$stat_start" "$boot" "$stat_start" - fi -done -` - -const LINUX_TOKEN_SIGNAL_SCRIPT = String.raw` -boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || exit 20 -key=$1; expected=$2; requested_signal=$3 -matches_token() { - test -r "/proc/$1/environ" || return 1 - tr '\0' '\n' < "/proc/$1/environ" 2>/dev/null | grep -Fqx -- "$key=$expected" -} -read_stat() { - line=$(cat "/proc/$1/stat" 2>/dev/null) || return 1 - stat_pid=$(printf '%s\n' "$line" | cut -d' ' -f1); rest=$(printf '%s\n' "$line" | sed 's/^.*) //'); set -- $rest - stat_ppid=$2; stat_group=$3; stat_start=$20 -} -signal_sent=0 -# Repeat inside the one bounded adapter so children forked by a signaled parent are selected too. -for pass in 1 2 3; do - for environ in /proc/[0-9]*/environ; do - pid=$(basename "$(dirname "$environ")") - if matches_token "$pid" && read_stat "$pid"; then - printf 'CODENOMAD_TARGET|%s|%s|%s|%s|%s|%s\n' "$stat_pid" "$stat_ppid" "$stat_group" "$stat_start" "$boot" "$stat_start" - if matches_token "$pid" && read_stat "$pid" && kill "-$requested_signal" "$pid" 2>/dev/null; then signal_sent=1; fi - fi - done -done -printf 'CODENOMAD_RESULT|%s\n' "$signal_sent" -` - -function failure(result: SpawnSyncReturns, token?: string): string { - const message = result.error?.message || `launch cleanup command failed with exit code ${result.status}` - return token ? redactToken(message, token) : message -} - -function redactToken(value: string, token: string): string { - return value.split(token).join("[REDACTED]") -} - -function parseIdentity(line: string): ProcessIdentity | null { - const fields = line.split("|") - if (fields.length !== 6) return null - const [pidText = "", parentPidText = "", groupIdText = "", startTime = "", bootId = "", startOrder = ""] = fields - if (!/^\d+$/.test(pidText) || !/^\d+$/.test(parentPidText) || !/^\d+$/.test(groupIdText) || - !startTime || !bootId || !/^\d+$/.test(startOrder)) return null - const pid = Number.parseInt(pidText, 10) - const parentPid = Number.parseInt(parentPidText, 10) - const groupId = Number.parseInt(groupIdText, 10) - if (pid <= 0 || parentPid < 0 || groupId <= 0) return null - return { pid, parentPid, groupId, startTime, bootId, startOrder } -} - -function runLinux( - spawnCommand: SpawnCommand, - script: string, - args: string[], - timeoutMs: number, - distro?: string, -): SpawnSyncReturns { - return distro - ? spawnCommand("wsl.exe", ["--distribution", distro, "--exec", "sh", "-c", script, "codenomad-token-cleanup", ...args], { - encoding: "utf8", - timeout: timeoutMs, - }) - : spawnCommand("sh", ["-c", script, "codenomad-token-cleanup", ...args], { encoding: "utf8", timeout: timeoutMs }) -} - -export function probeLaunchCleanupToken( - spawnCommand: SpawnCommand, - token: string, - timeoutMs: number, - distro?: string, -): ProcessSnapshot { - try { - const result = runLinux(spawnCommand, LINUX_TOKEN_PROBE_SCRIPT, [LAUNCH_CLEANUP_TOKEN_ENV, token], timeoutMs, distro) - if (result.status !== 0) return { ok: false, error: failure(result, token) } - const processes = new Map() - for (const line of String(result.stdout ?? "").split(/\r?\n/)) { - if (!line) continue - const identity = parseIdentity(line) - if (!identity) return { ok: false, error: "launch cleanup probe returned a malformed identity record" } - processes.set(identity.pid, identity) - } - return { ok: true, processes } - } catch (error) { - return { ok: false, error: redactToken(error instanceof Error ? error.message : String(error), token) } - } -} - -export function signalLaunchCleanupToken( - spawnCommand: SpawnCommand, - token: string, - signal: NodeJS.Signals, - timeoutMs: number, - distro?: string, -): TokenSignalResult { - try { - const signalName = signal === "SIGKILL" ? "KILL" : "TERM" - const result = runLinux( - spawnCommand, - LINUX_TOKEN_SIGNAL_SCRIPT, - [LAUNCH_CLEANUP_TOKEN_ENV, token, signalName], - timeoutMs, - distro, - ) - if (result.status !== 0) return { ok: false, signalSent: false, targets: [], error: failure(result, token) } - const targets = new Map() - let signalSent: boolean | undefined - for (const line of String(result.stdout ?? "").split(/\r?\n/)) { - if (!line) continue - if (line.startsWith("CODENOMAD_TARGET|")) { - const identity = parseIdentity(line.slice("CODENOMAD_TARGET|".length)) - if (!identity) return { ok: false, signalSent: false, targets: [], error: "launch cleanup signal returned a malformed target record" } - targets.set(identity.pid, identity) - continue - } - if (line === "CODENOMAD_RESULT|0" || line === "CODENOMAD_RESULT|1") { - signalSent = line.endsWith("1") - continue - } - return { ok: false, signalSent: false, targets: [], error: "launch cleanup signal returned unexpected output" } - } - return signalSent === undefined - ? { ok: false, signalSent: false, targets: [], error: "launch cleanup signal returned no structured result" } - : { ok: true, signalSent, targets: Array.from(targets.values()) } - } catch (error) { - return { - ok: false, - signalSent: false, - targets: [], - error: redactToken(error instanceof Error ? error.message : String(error), token), - } - } -} diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts deleted file mode 100644 index 1d7b6cad0..000000000 --- a/packages/server/src/workspaces/manager.test.ts +++ /dev/null @@ -1,386 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import pino from "pino" - -import { EventBus } from "../events/bus" -import { - WorkspaceRuntimeIdentityCaptureError, - WorkspaceRuntimeLaunchCancelledError, - WorkspaceWindowsTreeCleanupIncompleteError, - type ProcessExitInfo, - type WorkspaceRuntime, -} from "./runtime" -import { - WorkspaceLaunchCancelledError, - WorkspaceLaunchSettlementTimeoutError, - WorkspaceManager, - WorkspaceShutdownError, - WorkspaceShutdownTimeoutError, -} from "./manager" - -function deferred() { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { promise, resolve, reject } -} - -class ControlledRuntime { - readonly launchResult = deferred>>() - readonly launchCalled = deferred() - readonly cancellation = deferred() - readonly active = new Set() - readonly stopCalls: string[] = [] - launchCalls = 0 - failStops = 0 - - launch: WorkspaceRuntime["launch"] = (options) => { - this.launchCalls += 1 - this.active.add(options.workspaceId) - this.launchCalled.resolve(options.workspaceId) - return this.launchResult.promise - } - - stop: WorkspaceRuntime["stop"] = async (workspaceId) => { - this.stopCalls.push(workspaceId) - this.cancellation.resolve(new WorkspaceRuntimeLaunchCancelledError(workspaceId)) - if (this.failStops > 0) { - this.failStops -= 1 - throw new Error("controlled stop failure") - } - this.active.delete(workspaceId) - } - - resolveLaunch(workspaceId: string): void { - this.launchResult.resolve({ - pid: 1234, - port: 4321, - exitPromise: new Promise(() => undefined), - cancellationPromise: this.cancellation.promise, - getLastOutput: () => "", - }) - } -} - -function createHarness(managerOptions: { - shutdownTimeoutMs?: number - launchSettlementTimeoutMs?: number - setTimeout?: (callback: () => void, delayMs: number) => ReturnType - clearTimeout?: (timer: ReturnType) => void -} = {}) { - const eventBus = new EventBus() - const runtime = new ControlledRuntime() - const readiness = deferred() - const started: string[] = [] - let createdId = "" - eventBus.on("workspace.created", (event) => { - createdId = event.workspace.id - }) - eventBus.on("workspace.started", (event) => started.push(event.workspace.id)) - - const manager = new WorkspaceManager({ - rootDir: process.cwd(), - settings: { getOwner: () => ({}) } as never, - binaryResolver: { - resolve: () => ({ path: "test-opencode", label: "test-opencode" }), - } as never, - eventBus, - logger: pino({ level: "silent" }), - getServerBaseUrl: () => "http://127.0.0.1:4000", - runtime, - ...managerOptions, - }) - ;(manager as unknown as { - waitForWorkspaceReadiness: () => Promise - }).waitForWorkspaceReadiness = () => readiness.promise - - return { manager, runtime, readiness, started, eventBus, getCreatedId: () => createdId } -} - -describe("workspace manager launch cancellation", () => { - it("keeps a launched workspace unpublished until runtime identity launch succeeds", async () => { - const harness = createHarness() - let deletion: Promise | undefined - harness.eventBus.on("workspace.created", (event) => { - deletion = harness.manager.delete(event.workspace.id) - }) - - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - assert.equal(harness.getCreatedId(), "") - assert.deepEqual(harness.manager.list(), []) - assert.equal(harness.manager.getInstancePort(workspaceId), undefined) - assert.equal(harness.manager.getInstanceAuthorizationHeader(workspaceId), undefined) - - harness.runtime.resolveLaunch(workspaceId) - await assert.rejects(creation, WorkspaceLaunchCancelledError) - await deletion - - assert.equal(harness.runtime.launchCalls, 1) - assert.equal(harness.runtime.active.size, 0) - assert.deepEqual(harness.started, []) - assert.deepEqual(harness.manager.list(), []) - }) - - it("stops a late launch and shares cleanup across concurrent deletes", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - - const firstDelete = harness.manager.delete(workspaceId) - const secondDelete = harness.manager.delete(workspaceId) - assert.strictEqual(firstDelete, secondDelete) - assert.equal(harness.manager.get(workspaceId), undefined) - - harness.runtime.resolveLaunch(workspaceId) - - await assert.rejects(creation, WorkspaceLaunchCancelledError) - await Promise.all([firstDelete, secondDelete]) - - assert.equal(harness.runtime.active.has(workspaceId), false) - assert.deepEqual(harness.started, []) - assert.equal(harness.manager.get(workspaceId), undefined) - assert.deepEqual(harness.manager.list(), []) - }) - - it("does not publish or retain a workspace when mandatory identity launch fails", async () => { - const events: string[] = [] - const eventBus = new EventBus() - eventBus.on("workspace.created", () => events.push("created")) - eventBus.on("workspace.started", () => events.push("started")) - eventBus.on("workspace.error", () => events.push("error")) - const runtime = { - launch: ((options: { workspaceId: string }) => Promise.reject( - new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, "probe unavailable"), - )) as WorkspaceRuntime["launch"], - stop: (() => Promise.resolve()) as WorkspaceRuntime["stop"], - } - const manager = new WorkspaceManager({ - rootDir: process.cwd(), - settings: { getOwner: () => ({}) } as never, - binaryResolver: { resolve: () => ({ path: "test-opencode", label: "test-opencode" }) } as never, - eventBus, - logger: pino({ level: "silent" }), - getServerBaseUrl: () => "http://127.0.0.1:4000", - runtime, - }) - - await assert.rejects(manager.create(process.cwd()), WorkspaceRuntimeIdentityCaptureError) - assert.deepEqual(events, []) - assert.deepEqual(manager.list(), []) - }) - - it("retains an unpublished cleanup record when identity-failure cleanup cannot be proven", async () => { - let workspaceId = "" - let stopAttempts = 0 - const runtime = { - launch: ((options: { workspaceId: string }) => { - workspaceId = options.workspaceId - return Promise.reject(new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, "probe unavailable")) - }) as WorkspaceRuntime["launch"], - stop: (async () => { - stopAttempts += 1 - if (stopAttempts === 1) throw new Error("cleanup proof unavailable") - }) as WorkspaceRuntime["stop"], - } - const manager = new WorkspaceManager({ - rootDir: process.cwd(), - settings: { getOwner: () => ({}) } as never, - binaryResolver: { resolve: () => ({ path: "test-opencode", label: "test-opencode" }) } as never, - eventBus: new EventBus(), - logger: pino({ level: "silent" }), - getServerBaseUrl: () => "http://127.0.0.1:4000", - runtime, - }) - - await assert.rejects(manager.create(process.cwd()), /cleanup proof unavailable/) - assert.deepEqual(manager.list(), []) - assert.equal(manager.get(workspaceId), undefined) - - await manager.delete(workspaceId) - assert.equal(stopAttempts, 3) - }) - - it("actively cancels pending readiness without publishing workspace.started", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - await new Promise((resolve) => setImmediate(resolve)) - - const deletion = harness.manager.delete(workspaceId) - - await assert.rejects(creation, WorkspaceLaunchCancelledError) - await deletion - assert.equal(harness.runtime.active.has(workspaceId), false) - assert.deepEqual(harness.started, []) - assert.equal(harness.manager.get(workspaceId), undefined) - }) - - it("cancels and cleans a starting child during shutdown", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - - const shutdown = harness.manager.shutdown() - harness.runtime.resolveLaunch(workspaceId) - - await assert.rejects(creation, WorkspaceLaunchCancelledError) - await shutdown - assert.equal(harness.runtime.active.size, 0) - assert.deepEqual(harness.started, []) - assert.deepEqual(harness.manager.list(), []) - }) - - it("still starts and deletes an ordinary ready workspace", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd(), "ordinary") - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - harness.readiness.resolve("2.0.0") - - const { workspace } = await creation - assert.equal(workspace.id, harness.getCreatedId()) - assert.equal(workspace.status, "ready") - assert.equal(workspace.binaryVersion, "2.0.0") - assert.deepEqual(harness.started, [workspaceId]) - assert.equal(harness.runtime.active.has(workspaceId), true) - - await harness.manager.delete(workspaceId) - assert.equal(harness.runtime.active.has(workspaceId), false) - assert.equal(harness.manager.get(workspaceId), undefined) - }) - - it("publishes stopped and removes a workspace after readiness fails", async () => { - const harness = createHarness() - const stopped: string[] = [] - harness.eventBus.on("workspace.stopped", (event) => stopped.push(event.workspaceId)) - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - await new Promise((resolve) => setImmediate(resolve)) - - harness.readiness.reject(new Error("readiness failed")) - await assert.rejects(creation, /readiness failed/) - - assert.deepEqual(stopped, [workspaceId]) - assert.equal(harness.manager.get(workspaceId), undefined) - assert.deepEqual(harness.manager.list(), []) - }) - - it("retains a cancelled record after failed cleanup and allows delete retry", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - harness.readiness.resolve(undefined) - await creation - - harness.runtime.failStops = 1 - const firstDelete = harness.manager.delete(workspaceId) - const concurrentDelete = harness.manager.delete(workspaceId) - assert.strictEqual(firstDelete, concurrentDelete) - const failedDeletes = await Promise.allSettled([firstDelete, concurrentDelete]) - assert.equal(failedDeletes[0].status, "rejected") - assert.equal(failedDeletes[1].status, "rejected") - if (failedDeletes[0].status === "rejected" && failedDeletes[1].status === "rejected") { - assert.strictEqual(failedDeletes[0].reason, failedDeletes[1].reason) - assert.match(String(failedDeletes[0].reason), /controlled stop failure/) - } - assert.equal(harness.manager.get(workspaceId)?.status, "ready") - assert.equal(harness.runtime.active.has(workspaceId), true) - - const retryDelete = harness.manager.delete(workspaceId) - assert.notStrictEqual(retryDelete, firstDelete) - await retryDelete - assert.equal(harness.runtime.active.has(workspaceId), false) - assert.equal(harness.manager.get(workspaceId), undefined) - }) - - it("rejects shutdown at its deadline instead of waiting forever", async () => { - let fireDeadline: (() => void) | undefined - const timer = {} as ReturnType - const harness = createHarness({ - shutdownTimeoutMs: 25, - setTimeout: (callback: () => void) => { - fireDeadline = callback - return timer - }, - clearTimeout: () => undefined, - }) - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - harness.readiness.resolve(undefined) - await creation - harness.runtime.stop = () => new Promise(() => undefined) - - const shutdown = harness.manager.shutdown() - assert.ok(fireDeadline) - fireDeadline() - - await assert.rejects(shutdown, WorkspaceShutdownTimeoutError) - assert.equal(harness.manager.get(workspaceId)?.status, "ready") - }) - - it("rejects failed shutdown cleanup and allows a later delete retry", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - harness.readiness.resolve(undefined) - await creation - harness.runtime.failStops = 1 - - await assert.rejects(harness.manager.shutdown(), WorkspaceShutdownError) - assert.equal(harness.manager.get(workspaceId)?.status, "ready") - assert.equal(harness.runtime.active.has(workspaceId), true) - - await harness.manager.delete(workspaceId) - assert.equal(harness.manager.get(workspaceId), undefined) - assert.equal(harness.runtime.active.has(workspaceId), false) - }) - - it("reports incomplete Windows tree cleanup during shutdown", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - harness.readiness.resolve(undefined) - await creation - harness.runtime.stop = async () => { - throw new WorkspaceWindowsTreeCleanupIncompleteError(workspaceId, 4242, ["taskkill /T failed: unavailable"]) - } - - await assert.rejects(harness.manager.shutdown(), (error: unknown) => { - assert.ok(error instanceof WorkspaceShutdownError) - assert.ok(error.errors[0] instanceof WorkspaceWindowsTreeCleanupIncompleteError) - return true - }) - assert.equal(harness.manager.get(workspaceId)?.status, "ready") - assert.equal(harness.runtime.active.has(workspaceId), true) - }) - - it("bounds cleanup when a runtime does not settle its cancelled launch", async () => { - let fireDeadline: (() => void) | undefined - const timer = {} as ReturnType - const harness = createHarness({ - launchSettlementTimeoutMs: 25, - setTimeout: (callback: () => void) => { - fireDeadline = callback - return timer - }, - clearTimeout: () => undefined, - }) - const deletion = (harness.manager as unknown as { - withLaunchSettlementTimeout: (workspaceId: string, completion: Promise) => Promise - }).withLaunchSettlementTimeout("workspace-1", new Promise(() => undefined)) - assert.ok(fireDeadline) - fireDeadline() - - await assert.rejects(deletion, WorkspaceLaunchSettlementTimeoutError) - }) -}) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 9a10bad54..8c7fb2331 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -26,12 +26,6 @@ import { import { resolveWorkspaceIdentity } from "./workspace-identity" const STARTUP_STABILITY_DELAY_MS = 1500 -type ManagerTimeout = ReturnType - -interface WorkspaceRuntimeController { - launch: WorkspaceRuntime["launch"] - stop: WorkspaceRuntime["stop"] -} interface WorkspaceManagerOptions { rootDir: string @@ -42,152 +36,52 @@ interface WorkspaceManagerOptions { getServerBaseUrl: () => string /** Optional CA bundle path to trust CodeNomad HTTPS certs. */ nodeExtraCaCertsPath?: string - runtime?: WorkspaceRuntimeController - shutdownTimeoutMs?: number - launchSettlementTimeoutMs?: number - setTimeout?: (callback: () => void, delayMs: number) => ManagerTimeout - clearTimeout?: (timer: ManagerTimeout) => void -} - -interface WorkspaceLaunchLifecycle { - cancelled: boolean - settled: boolean - completion: Promise - complete: () => void - deletePromise?: Promise - stoppedEventPublished: boolean -} - -interface WorkspaceRecord { - descriptor: WorkspaceDescriptor - lifecycle: WorkspaceLaunchLifecycle - published: boolean - creationOwnership: WorkspaceCreationOwnership - releasedCreationRequestIds: Set -} - -export class WorkspaceLaunchCancelledError extends Error { - constructor(workspaceId: string) { - super(`Workspace ${workspaceId} launch was cancelled`) - this.name = "WorkspaceLaunchCancelledError" - } } -export class WorkspaceShutdownTimeoutError extends Error { - readonly code = "WORKSPACE_SHUTDOWN_TIMEOUT" - readonly retryable = true - - constructor(timeoutMs: number) { - super(`Workspace shutdown did not finish within ${timeoutMs}ms; remaining workspace cleanup can be retried`) - this.name = "WorkspaceShutdownTimeoutError" - } -} - -export class WorkspaceLaunchSettlementTimeoutError extends Error { - readonly code = "WORKSPACE_LAUNCH_SETTLEMENT_TIMEOUT" - readonly retryable = true - - constructor(workspaceId: string, timeoutMs: number) { - super(`Workspace ${workspaceId} launch cancellation did not settle within ${timeoutMs}ms; cleanup can be retried`) - this.name = "WorkspaceLaunchSettlementTimeoutError" - } -} - -export class WorkspaceShutdownError extends Error { - readonly code = "WORKSPACE_SHUTDOWN_FAILED" - readonly retryable = true - readonly errors: unknown[] - - constructor(errors: unknown[]) { - super(`Failed to stop ${errors.length} workspace${errors.length === 1 ? "" : "s"} during shutdown; cleanup can be retried`) - this.name = "WorkspaceShutdownError" - this.errors = errors - } -} +interface WorkspaceRecord extends WorkspaceDescriptor {} export interface WorkspaceCreateResult { workspace: WorkspaceDescriptor created: boolean } -export interface WorkspaceCreateOptions { - binaryPath?: string - requestId?: string - forceNew?: boolean -} - -interface PendingWorkspaceCreation { - promise: Promise - ownership: WorkspaceCreationOwnership - followerCount: number -} - -interface WorkspaceCreationOwnership { - requestIds: Set - cancelledRequestIds: Set - sharedByNonRestoreCaller: boolean -} - export class WorkspaceManager { private readonly workspaces = new Map() private readonly workspaceIdentities = new Map() - private readonly pendingWorkspaceCreations = new Map() + private readonly pendingWorkspaceCreations = new Map>() private readonly pendingWorkspaceOwners = new Map() - private readonly cancelledCreationRequests = new Set() + private readonly activeWorkspaceCreations = new Set>() + private readonly cancelledWorkspaceCreations = new Set() private shuttingDown = false - private readonly runtime: WorkspaceRuntimeController + private readonly runtime: WorkspaceRuntime private readonly codeNomadPluginUrl: string private readonly opencodeAuth = new Map() - private readonly shutdownTimeoutMs: number - private readonly launchSettlementTimeoutMs: number - private readonly scheduleTimeout: (callback: () => void, delayMs: number) => ManagerTimeout - private readonly cancelTimeout: (timer: ManagerTimeout) => void constructor(private readonly options: WorkspaceManagerOptions) { - this.runtime = options.runtime ?? new WorkspaceRuntime(this.options.eventBus, this.options.logger) + this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger) this.codeNomadPluginUrl = getCodeNomadPluginUrl() - this.shutdownTimeoutMs = Math.max(1, options.shutdownTimeoutMs ?? 10000) - this.launchSettlementTimeoutMs = Math.max(1, options.launchSettlementTimeoutMs ?? 5000) - this.scheduleTimeout = options.setTimeout ?? setTimeout - this.cancelTimeout = options.clearTimeout ?? clearTimeout } list(): WorkspaceDescriptor[] { return Array.from(this.workspaces.values()) - .filter((record) => record.published) - .map((record) => record.descriptor) } get(id: string): WorkspaceDescriptor | undefined { - const record = this.workspaces.get(id) - return record?.published ? record.descriptor : undefined + return this.workspaces.get(id) } getInstancePort(id: string): number | undefined { - const record = this.workspaces.get(id) - return record?.published ? record.descriptor.port : undefined + return this.workspaces.get(id)?.port } getInstanceAuthorizationHeader(id: string): string | undefined { - return this.workspaces.get(id)?.published ? this.opencodeAuth.get(id)?.authorization : undefined + return this.opencodeAuth.get(id)?.authorization } - private findReadyWorkspaceByIdentity( - identityKey: string, - includeRestoreOwned: boolean, - ): WorkspaceDescriptor | undefined { - for (const [workspaceId, record] of this.workspaces) { - if ( - record.published - && !record.lifecycle.cancelled - && (includeRestoreOwned || !record.descriptor.requestId) - && record.descriptor.status === "ready" - && this.workspaceIdentities.get(workspaceId) === identityKey - ) { - return record.descriptor - } - } - return undefined + private findReadyWorkspaceByIdentity(identityKey: string): WorkspaceDescriptor | undefined { + return Array.from(this.workspaces.values()).find((workspace) => { + return workspace.status === "ready" && this.workspaceIdentities.get(workspace.id) === identityKey + }) } listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] { @@ -239,93 +133,64 @@ export class WorkspaceManager { browser.writeFile(relativePath, contents) } - async create( - folder: string, - name?: string, - options: WorkspaceCreateOptions = {}, - ): Promise { + async create(folder: string, name?: string, options?: { forceNew?: boolean }): Promise { const { workspacePath, identityKey } = await resolveWorkspaceIdentity(folder, this.options.rootDir) - if (options.requestId && this.cancelledCreationRequests.delete(options.requestId)) { - throw new Error(`Workspace creation request ${options.requestId} was cancelled`) - } if (this.shuttingDown) { throw new Error("Workspace manager is shutting down") } - if (options.forceNew) { - const ownership = this.createOwnership(options.requestId) - const result = await this.createResolvedWorkspace(workspacePath, identityKey, name, options, ownership) - return this.resultForCreationOwner(result, options.requestId, ownership) + if (options?.forceNew) { + return this.trackWorkspaceCreation(this.createResolvedWorkspace(workspacePath, identityKey, name)) } - const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId)) + const existing = this.findReadyWorkspaceByIdentity(identityKey) if (existing) { this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace") - const record = this.workspaces.get(existing.id) - if (options.requestId && record && record.creationOwnership.requestIds.size > 0) { - record.creationOwnership.requestIds.add(options.requestId) - this.syncCreationOwnership(record) - return this.resultForCreationOwner({ workspace: existing, created: false }, options.requestId, record.creationOwnership) - } return { workspace: existing, created: false } } const pending = this.pendingWorkspaceCreations.get(identityKey) if (pending) { - pending.followerCount += 1 - if (options.requestId) { - pending.ownership.requestIds.add(options.requestId) - } else { - const workspaceId = this.pendingWorkspaceOwners.get(identityKey) - if (workspaceId) { - this.retainCreationOwnership(this.workspaces.get(workspaceId)) - } else { - pending.ownership.sharedByNonRestoreCaller = true - } - } - const result = await pending.promise - return this.resultForCreationOwner({ workspace: result.workspace, created: false }, options.requestId, pending.ownership) + const result = await pending + return { workspace: result.workspace, created: false } } - const ownership = this.createOwnership(options.requestId) - const creation = this.createResolvedWorkspace( - workspacePath, - identityKey, - name, - options, - ownership, - (workspaceId) => { + const creation = this.trackWorkspaceCreation( + this.createResolvedWorkspace(workspacePath, identityKey, name, (workspaceId) => { this.pendingWorkspaceOwners.set(identityKey, workspaceId) - }, + }), ) - const pendingCreation = { promise: creation, ownership, followerCount: 0 } - this.pendingWorkspaceCreations.set(identityKey, pendingCreation) + this.pendingWorkspaceCreations.set(identityKey, creation) try { - const result = await creation - return this.resultForCreationOwner( - options.requestId && pendingCreation.ownership.sharedByNonRestoreCaller - ? { workspace: result.workspace, created: false } - : result, - options.requestId, - pendingCreation.ownership, - ) + return await creation } finally { - if (this.pendingWorkspaceCreations.get(identityKey) === pendingCreation) { + if (this.pendingWorkspaceCreations.get(identityKey) === creation) { this.pendingWorkspaceCreations.delete(identityKey) this.pendingWorkspaceOwners.delete(identityKey) } } } + private trackWorkspaceCreation(creation: Promise): Promise { + let tracked!: Promise + tracked = (async () => { + try { + return await creation + } finally { + this.activeWorkspaceCreations.delete(tracked) + } + })() + this.activeWorkspaceCreations.add(tracked) + return tracked + } + private async createResolvedWorkspace( workspacePath: string, identityKey: string, - name: string | undefined, - options: WorkspaceCreateOptions, - creationOwnership: WorkspaceCreationOwnership, + name?: string, onReserved?: (workspaceId: string) => void, ): Promise { const id = randomUUID() - const binary = this.options.binaryResolver.resolve(options.binaryPath) + const binary = this.options.binaryResolver.resolveDefault() const resolvedBinaryPath = this.resolveBinaryPath(binary.path) clearWorkspaceSearchCache(workspacePath) @@ -334,9 +199,8 @@ export class WorkspaceManager { const proxyPath = `/workspaces/${id}/instance` - const descriptor: WorkspaceDescriptor = { + const descriptor: WorkspaceRecord = { id, - requestId: options.requestId, path: workspacePath, name, status: "starting", @@ -348,33 +212,13 @@ export class WorkspaceManager { updatedAt: new Date().toISOString(), } - let completeLaunch!: () => void - const completion = new Promise((resolve) => { - completeLaunch = resolve - }) - const record: WorkspaceRecord = { - descriptor, - lifecycle: { - cancelled: false, - settled: false, - completion, - complete: completeLaunch, - stoppedEventPublished: false, - }, - published: false, - creationOwnership, - releasedCreationRequestIds: new Set(), - } - - this.workspaces.set(id, record) + this.workspaces.set(id, descriptor) this.workspaceIdentities.set(id, identityKey) onReserved?.(id) - if (options.requestId && this.cancelledCreationRequests.delete(options.requestId)) { - record.lifecycle.cancelled = true - } + try { - this.throwIfCancelled(record) + this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) const serverConfig = this.options.settings.getOwner("config", "server") const envVars = (serverConfig as any)?.environmentVariables @@ -409,7 +253,8 @@ export class WorkspaceManager { } const logLevel = (serverConfig as any)?.logLevel - const { pid, port, exitPromise, cancellationPromise, getLastOutput } = await this.runtime.launch({ + this.throwIfWorkspaceCreationCancelled(id) + const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, binaryPath: resolvedBinaryPath, @@ -419,23 +264,13 @@ export class WorkspaceManager { }) descriptor.pid = pid descriptor.port = port + this.throwIfWorkspaceCreationCancelled(id) - this.throwIfCancelled(record) - record.published = true - this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) - this.throwIfCancelled(record) - const readinessAbort = new AbortController() - const runtimeVersion = await Promise.race([ - this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput, signal: readinessAbort.signal }), - cancellationPromise.then((error) => { - readinessAbort.abort(error) - throw error - }), - ]) - this.throwIfCancelled(record) + const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) if (runtimeVersion) { descriptor.binaryVersion = runtimeVersion } + this.throwIfWorkspaceCreationCancelled(id) descriptor.status = "ready" descriptor.updatedAt = new Date().toISOString() @@ -443,273 +278,132 @@ export class WorkspaceManager { this.options.logger.info({ workspaceId: id, port }, "Workspace ready") return { workspace: descriptor, created: true } } catch (error) { - if (record.lifecycle.cancelled) { - await this.stopRuntime(id) - throw error instanceof WorkspaceLaunchCancelledError ? error : new WorkspaceLaunchCancelledError(id) - } - if (!record.published) { - try { - await this.stopRuntime(id) - } catch (cleanupError) { - this.options.logger.error({ workspaceId: id, err: cleanupError }, "Unpublished workspace cleanup remains pending") - throw cleanupError - } - if (this.workspaces.get(id) === record) { - this.workspaces.delete(id) - this.workspaceIdentities.delete(id) - } - this.opencodeAuth.delete(id) - this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed before identity publication") - throw error - } - let stopFailure: unknown - await this.stopRuntime(id).catch((stopError) => { + await this.runtime.stop(id).catch((stopError) => { stopFailure = stopError this.options.logger.warn({ workspaceId: id, err: stopError }, "Failed to stop workspace after startup error") }) - if (!stopFailure) { - this.publishStopped(record) - if (this.workspaces.get(id) === record) { - this.workspaces.delete(id) - this.workspaceIdentities.delete(id) - } - this.opencodeAuth.delete(id) - throw error + const cancelled = this.cancelledWorkspaceCreations.delete(id) || !this.workspaces.has(id) + if (cancelled && !stopFailure) { + throw new Error("Workspace creation cancelled") } descriptor.status = "error" descriptor.error = stopFailure instanceof Error ? `Workspace startup failed and its process could not be stopped: ${stopFailure.message}` : error instanceof Error ? error.message : String(error) descriptor.updatedAt = new Date().toISOString() - if (this.workspaces.get(id) === record && record.published) { + if (this.workspaces.has(id)) { this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor }) } this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start") + if (cancelled) { + throw new Error(descriptor.error) + } throw error - } finally { - record.lifecycle.settled = true - record.lifecycle.complete() } } - delete(id: string): Promise { - const record = this.workspaces.get(id) - if (!record) return Promise.resolve(undefined) + async delete(id: string): Promise { + const workspace = this.workspaces.get(id) + if (!workspace) return undefined - record.lifecycle.cancelled = true - const identityKey = this.workspaceIdentities.get(id) - if (identityKey && this.pendingWorkspaceOwners.get(identityKey) === id) { - this.pendingWorkspaceCreations.delete(identityKey) - this.pendingWorkspaceOwners.delete(identityKey) - } - if (!record.lifecycle.deletePromise) { - let deletePromise!: Promise - deletePromise = this.cleanupDeletedWorkspace(id, record).catch((error) => { - if (record.lifecycle.deletePromise === deletePromise) { - record.lifecycle.deletePromise = undefined - } + this.options.logger.info({ workspaceId: id }, "Stopping workspace") + const wasStarting = workspace.status === "starting" + const wasRunning = Boolean(workspace.pid) + if (wasStarting) { + this.cancelledWorkspaceCreations.add(id) + } + if (wasStarting || wasRunning) { + try { + await this.runtime.stop(id) + } catch (error) { + this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly") + workspace.status = "error" + workspace.error = error instanceof Error ? error.message : String(error) + workspace.updatedAt = new Date().toISOString() + this.options.eventBus.publish({ type: "workspace.error", workspace }) throw error - }) - record.lifecycle.deletePromise = deletePromise - } - return record.lifecycle.deletePromise - } - - releaseCreationRequest(id: string, requestId: string): boolean { - const record = this.workspaces.get(id) - if (!record?.published) return false - if (record.releasedCreationRequestIds.has(requestId)) return true - if (!record.creationOwnership.requestIds.has(requestId)) return false - this.retainCreationOwnership(record) - return true - } - - async cancelCreationRequest(requestId: string): Promise { - for (const [workspaceId, record] of this.workspaces) { - if (record.releasedCreationRequestIds.has(requestId)) return - if (!record.creationOwnership.requestIds.delete(requestId)) continue - record.creationOwnership.cancelledRequestIds.add(requestId) - record.releasedCreationRequestIds.add(requestId) - this.syncCreationOwnership(record) - if (record.creationOwnership.requestIds.size > 0 || record.creationOwnership.sharedByNonRestoreCaller) { - return } - await this.delete(workspaceId) - return } - if (this.cancelledCreationRequests.size >= 1_024) { - const oldestRequestId = this.cancelledCreationRequests.values().next().value - if (oldestRequestId) this.cancelledCreationRequests.delete(oldestRequestId) - } - this.cancelledCreationRequests.add(requestId) - } - private createOwnership(requestId?: string): WorkspaceCreationOwnership { - return { - requestIds: new Set(requestId ? [requestId] : []), - cancelledRequestIds: new Set(), - sharedByNonRestoreCaller: !requestId, + const identityKey = this.workspaceIdentities.get(id) + if (identityKey && this.pendingWorkspaceOwners.get(identityKey) === id) { + this.pendingWorkspaceCreations.delete(identityKey) + this.pendingWorkspaceOwners.delete(identityKey) } - } - - private resultForCreationOwner( - result: WorkspaceCreateResult, - requestId: string | undefined, - ownership: WorkspaceCreationOwnership, - ): WorkspaceCreateResult { - if (requestId && ownership.cancelledRequestIds.has(requestId)) { - throw new Error(`Workspace creation request ${requestId} was cancelled`) + const stoppedEventPublished = workspace.status === "stopped" + this.workspaces.delete(id) + this.workspaceIdentities.delete(id) + this.opencodeAuth.delete(id) + if (!wasStarting) { + this.cancelledWorkspaceCreations.delete(id) } - return { - workspace: requestId && !ownership.sharedByNonRestoreCaller - ? { ...result.workspace, requestId } - : result.workspace, - created: result.created, + clearWorkspaceSearchCache(workspace.path) + if (!stoppedEventPublished) { + this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id }) } - } - - private syncCreationOwnership(record: WorkspaceRecord | undefined): void { - if (!record) return - record.descriptor.requestId = record.creationOwnership.sharedByNonRestoreCaller - ? undefined - : record.creationOwnership.requestIds.values().next().value - } - - private retainCreationOwnership(record: WorkspaceRecord | undefined): void { - if (!record) return - for (const requestId of record.creationOwnership.requestIds) { - record.releasedCreationRequestIds.add(requestId) - } - record.creationOwnership.requestIds.clear() - record.creationOwnership.sharedByNonRestoreCaller = true - this.syncCreationOwnership(record) + return workspace } async shutdown() { this.shuttingDown = true this.options.logger.info("Shutting down all workspaces") - const stopTasks = Array.from(this.workspaces.keys(), (id) => this.delete(id)) - let stopFailures: unknown[] = [] - - if (stopTasks.length > 0) { - const results = await this.withShutdownTimeout(Promise.allSettled(stopTasks)) - stopFailures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) - } + const stopTasks: Array> = [] - if (this.workspaces.size === 0) { - this.workspaceIdentities.clear() - this.pendingWorkspaceCreations.clear() - this.pendingWorkspaceOwners.clear() - this.cancelledCreationRequests.clear() - this.options.logger.info("All workspaces cleared") - } else { - this.options.logger.warn( - { workspaceIds: Array.from(this.workspaces.keys()) }, - "Some workspace records remain after failed shutdown cleanup", - ) - if (stopFailures.length === 0) { - stopFailures.push(new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`)) + for (const [id, workspace] of this.workspaces) { + if (workspace.status === "starting") { + this.cancelledWorkspaceCreations.add(id) + } + if (!workspace.pid && workspace.status !== "starting") { + this.options.logger.debug({ workspaceId: id }, "Workspace already stopped") + continue } - } - if (stopFailures.length > 0) { - throw new WorkspaceShutdownError(stopFailures) + this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown") + stopTasks.push( + this.runtime.stop(id).catch((error) => { + this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown") + throw error + }), + ) } - } - private async withShutdownTimeout(operation: Promise): Promise { - let timeout: ManagerTimeout | null = null - const deadline = new Promise((_resolve, reject) => { - timeout = this.scheduleTimeout(() => { - timeout = null - reject(new WorkspaceShutdownTimeoutError(this.shutdownTimeoutMs)) - }, this.shutdownTimeoutMs) - }) - - try { - return await Promise.race([operation, deadline]) - } finally { - if (timeout) { - this.cancelTimeout(timeout) + if (stopTasks.length > 0) { + const stopResults = await Promise.allSettled(stopTasks) + const failedStop = stopResults.find((result): result is PromiseRejectedResult => result.status === "rejected") + if (failedStop) { + throw failedStop.reason } } - } - - private requireWorkspace(id: string): WorkspaceDescriptor { - const record = this.workspaces.get(id) - if (!record?.published) { - throw new Error("Workspace not found") + if (this.activeWorkspaceCreations.size > 0) { + await Promise.allSettled(this.activeWorkspaceCreations) } - return record.descriptor - } - private throwIfCancelled(record: WorkspaceRecord): void { - if (record.lifecycle.cancelled) { - throw new WorkspaceLaunchCancelledError(record.descriptor.id) - } + this.workspaces.clear() + this.workspaceIdentities.clear() + this.pendingWorkspaceCreations.clear() + this.pendingWorkspaceOwners.clear() + this.activeWorkspaceCreations.clear() + this.cancelledWorkspaceCreations.clear() + this.opencodeAuth.clear() + this.options.logger.info("All workspaces cleared") } - private async stopRuntime(workspaceId: string): Promise { - try { - await this.runtime.stop(workspaceId) - } catch (error) { - this.options.logger.warn({ workspaceId, err: error }, "Failed to stop workspace process cleanly") - throw error - } - } - - private async cleanupDeletedWorkspace(id: string, record: WorkspaceRecord): Promise { - this.options.logger.info({ workspaceId: id }, "Stopping workspace") - - // Stop once immediately, then again after launch settlement to cover a child - // that became available while cancellation was propagating. - await this.stopRuntime(id) - if (!record.lifecycle.settled) { - await this.withLaunchSettlementTimeout(id, record.lifecycle.completion) - } - try { - await this.runtime.stop(id) - } catch (error) { - this.options.logger.warn({ workspaceId: id, err: error }, "Failed final workspace process cleanup") - throw error - } - - if (this.workspaces.get(id) === record) { - this.workspaces.delete(id) - this.workspaceIdentities.delete(id) - this.opencodeAuth.delete(id) - clearWorkspaceSearchCache(record.descriptor.path) - this.publishStopped(record) + private requireWorkspace(id: string): WorkspaceRecord { + const workspace = this.workspaces.get(id) + if (!workspace) { + throw new Error("Workspace not found") } - return record.descriptor + return workspace } - private async withLaunchSettlementTimeout(workspaceId: string, completion: Promise): Promise { - let timeout: ManagerTimeout | null = null - const deadline = new Promise((_resolve, reject) => { - timeout = this.scheduleTimeout(() => { - timeout = null - reject(new WorkspaceLaunchSettlementTimeoutError(workspaceId, this.launchSettlementTimeoutMs)) - }, this.launchSettlementTimeoutMs) - }) - - try { - await Promise.race([completion, deadline]) - } finally { - if (timeout) { - this.cancelTimeout(timeout) - } + private throwIfWorkspaceCreationCancelled(id: string): void { + if (this.shuttingDown || this.cancelledWorkspaceCreations.has(id) || !this.workspaces.has(id)) { + throw new Error("Workspace creation cancelled") } } - private publishStopped(record: WorkspaceRecord): void { - if (record.lifecycle.stoppedEventPublished) return - record.lifecycle.stoppedEventPublished = true - this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: record.descriptor.id }) - } - private resolveBinaryPath(identifier: string): string { if (!identifier) { return identifier @@ -768,11 +462,10 @@ export class WorkspaceManager { port: number exitPromise: Promise getLastOutput: () => string - signal?: AbortSignal }): Promise { await Promise.race([ - this.waitForPortAvailability(params.port, 5000, params.signal), + this.waitForPortAvailability(params.port), params.exitPromise.then((info) => { throw this.buildStartupError( params.workspaceId, @@ -786,7 +479,7 @@ export class WorkspaceManager { const version = await this.waitForInstanceHealth(params) await Promise.race([ - this.delay(STARTUP_STABILITY_DELAY_MS, params.signal), + this.delay(STARTUP_STABILITY_DELAY_MS), params.exitPromise.then((info) => { throw this.buildStartupError( params.workspaceId, @@ -805,10 +498,9 @@ export class WorkspaceManager { port: number exitPromise: Promise getLastOutput: () => string - signal?: AbortSignal }): Promise { const probeResult = await Promise.race([ - this.probeInstance(params.workspaceId, params.port, params.signal), + this.probeInstance(params.workspaceId, params.port), params.exitPromise.then((info) => { throw this.buildStartupError( params.workspaceId, @@ -834,7 +526,6 @@ export class WorkspaceManager { private async probeInstance( workspaceId: string, port: number, - signal?: AbortSignal, ): Promise<{ ok: boolean; reason?: string; version?: string }> { const url = `http://127.0.0.1:${port}/global/health` @@ -845,7 +536,7 @@ export class WorkspaceManager { headers["Authorization"] = authHeader } - const response = await fetch(url, { headers, signal }) + const response = await fetch(url, { headers }) if (!response.ok) { const reason = `/global/health returned HTTP ${response.status}` this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") @@ -882,12 +573,11 @@ export class WorkspaceManager { return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`) } - private waitForPortAvailability(port: number, timeoutMs = 5000, signal?: AbortSignal): Promise { + private waitForPortAvailability(port: number, timeoutMs = 5000): Promise { return new Promise((resolve, reject) => { const deadline = Date.now() + timeoutMs let settled = false let retryTimer: NodeJS.Timeout | null = null - let socket: ReturnType | null = null const cleanup = () => { settled = true @@ -895,26 +585,19 @@ export class WorkspaceManager { clearTimeout(retryTimer) retryTimer = null } - signal?.removeEventListener("abort", onAbort) - } - const onAbort = () => { - if (settled) return - cleanup() - socket?.destroy() - reject(signal?.reason instanceof Error ? signal.reason : new Error("Workspace readiness was cancelled")) } const tryConnect = () => { if (settled) { return } - socket = connect({ port, host: "127.0.0.1" }, () => { + const socket = connect({ port, host: "127.0.0.1" }, () => { cleanup() - socket?.end() + socket.end() resolve() }) socket.once("error", () => { - socket?.destroy() + socket.destroy() if (settled) { return } @@ -930,35 +613,15 @@ export class WorkspaceManager { }) } - if (signal?.aborted) { - onAbort() - return - } - signal?.addEventListener("abort", onAbort, { once: true }) tryConnect() }) } - private delay(durationMs: number, signal?: AbortSignal): Promise { + private delay(durationMs: number): Promise { if (durationMs <= 0) { return Promise.resolve() } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort) - resolve() - }, durationMs) - const onAbort = () => { - clearTimeout(timer) - signal?.removeEventListener("abort", onAbort) - reject(signal?.reason instanceof Error ? signal.reason : new Error("Workspace readiness was cancelled")) - } - if (signal?.aborted) { - onAbort() - return - } - signal?.addEventListener("abort", onAbort, { once: true }) - }) + return new Promise((resolve) => setTimeout(resolve, durationMs)) } private describeExit(info: ProcessExitInfo): string { @@ -972,9 +635,8 @@ export class WorkspaceManager { } private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { - const record = this.workspaces.get(workspaceId) - if (!record) return - const workspace = record.descriptor + const workspace = this.workspaces.get(workspaceId) + if (!workspace) return this.opencodeAuth.delete(workspaceId) @@ -984,10 +646,10 @@ export class WorkspaceManager { workspace.port = undefined workspace.updatedAt = new Date().toISOString() - if (record.lifecycle.cancelled || info.requested || info.code === 0) { + if (info.requested || info.code === 0) { workspace.status = "stopped" workspace.error = undefined - this.publishStopped(record) + this.options.eventBus.publish({ type: "workspace.stopped", workspaceId }) } else { workspace.status = "error" workspace.error = `Process exited with code ${info.code}` diff --git a/packages/server/src/workspaces/process-identity.test.ts b/packages/server/src/workspaces/process-identity.test.ts deleted file mode 100644 index 753e1df76..000000000 --- a/packages/server/src/workspaces/process-identity.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import assert from "node:assert/strict" -import type { SpawnSyncReturns } from "node:child_process" -import { describe, it } from "node:test" - -import { - probePosixProcesses, - probeWindowsProcesses, - probeWslProcesses, - sameProcess, - signalOwnedPosixProcessGroup, - signalPosixProcesses, - signalWindowsProcesses, - startedNoLaterThan, - type ProcessIdentity, -} from "./process-identity" - -type SpawnCommand = typeof import("node:child_process").spawnSync - -function result(stdout = "", status = 0, stderr = ""): SpawnSyncReturns { - return { pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null } -} - -function b64(value: string): string { - return Buffer.from(value).toString("base64") -} - -describe("process identity probes", () => { - it("parses Linux PID, PGID, and kernel process start ticks", () => { - let invocation: { command: string; args: readonly string[] } | undefined - const probe = probePosixProcesses(((command: string, args: readonly string[]) => { - invocation = { command, args } - return result("42|1|42|123456|boot-a|123456\n") - }) as unknown as SpawnCommand, 25, "linux") - - assert.equal(invocation?.command, "sh") - assert.ok(invocation?.args.includes("codenomad-posix-identity")) - assert.equal(probe.ok, true) - if (probe.ok) { - assert.deepEqual(probe.processes.get(42), { - pid: 42, - parentPid: 1, - groupId: 42, - startTime: "123456", - bootId: "boot-a", - startOrder: "123456", - }) - } - }) - - it("uses one process-table query on non-Linux POSIX platforms", () => { - let invocation: { command: string; args: readonly string[] } | undefined - const probe = probePosixProcesses(((command: string, args: readonly string[]) => { - invocation = { command, args } - return result(" 42 1 42 Fri Jul 10 12:34:56 2026 /usr/bin/opencode serve\n") - }) as unknown as SpawnCommand, 25, "darwin") - - assert.equal(invocation?.command, "ps") - assert.deepEqual(invocation?.args, ["-axo", "pid=,ppid=,pgid=,lstart=,command="]) - assert.equal(probe.ok && probe.processes.get(42)?.startTime, "Fri Jul 10 12:34:56 2026\t/usr/bin/opencode serve") - }) - - it("round trips delimiter-heavy non-Linux POSIX command identities", () => { - const command = "/opt/opencode 'pipe|value'\t\"quoted\" café" - const start = "Fri Jul 10 12:34:56 2026" - const probe = probePosixProcesses((() => result( - `42 1 42 ${start} ${command}\n`, - )) as unknown as SpawnCommand, 25, "darwin") - - assert.equal(probe.ok && probe.processes.get(42)?.startTime, `${start}\t${command}`) - }) - - it("uses delimiter-safe identities during non-Linux POSIX escalation", () => { - const command = "/opt/opencode pipe|value\nnext\t'quoted'" - const startTime = `Fri Jul 10 12:34:56 2026\t${command}` - const identity: ProcessIdentity = { pid: 42, parentPid: 1, groupId: 42, startTime } - const guarded = signalPosixProcesses((() => result( - `CODENOMAD_TARGET_B64|42|1|42|${b64("Fri Jul 10 12:34:56 2026")}|${b64(command)}\nCODENOMAD_RESULT|1||1\n`, - )) as unknown as SpawnCommand, { leader: identity, groupId: 42, members: [identity], signal: "SIGKILL" }, 25, "darwin") - - assert.equal(guarded.ok, true) - assert.equal(guarded.ok && guarded.signaled[0]?.startTime, startTime) - }) - - it("signals and rescans a still-owned portable POSIX process group", () => { - const command = "/opt/opencode pipe|value\nchild" - let script = "" - const cleanup = signalOwnedPosixProcessGroup(((_command: string, args: readonly string[]) => { - script = args[1] ?? "" - return result( - `CODENOMAD_TARGET_B64|42|1|42|${b64("Fri Jul 10 12:34:56 2026")}|${b64(command)}\n` + - "CODENOMAD_RESULT|1||1\n", - ) - }) as unknown as SpawnCommand, 42, "SIGTERM", 25) - - assert.equal(cleanup.ok && cleanup.matched, true) - assert.equal(cleanup.ok && cleanup.signaled[0]?.startTime, `Fri Jul 10 12:34:56 2026\t${command}`) - assert.ok(script.indexOf('kill "-$requested_signal"') < script.lastIndexOf("for current_pid")) - }) - - it("rejects malformed portable process rows", () => { - const probe = probePosixProcesses((() => result("42 malformed process row\n")) as unknown as SpawnCommand, 25, "darwin") - - assert.deepEqual(probe, { ok: false, error: "process identity query returned no parseable processes" }) - }) - - it("queries WSL identities inside the selected distro", () => { - let invocation: { command: string; args: readonly string[] } | undefined - const probe = probeWslProcesses(((command: string, args: readonly string[]) => { - invocation = { command, args } - return result("99|1|99|123456|boot-a|123456\n101|99|99|123460|boot-a|123460\n") - }) as unknown as SpawnCommand, "Ubuntu Test", 25) - - assert.equal(invocation?.command, "wsl.exe") - assert.deepEqual(invocation?.args.slice(0, 4), ["--distribution", "Ubuntu Test", "--exec", "sh"]) - assert.ok(invocation?.args.includes("codenomad-wsl-identity")) - assert.equal(probe.ok && probe.processes.get(101)?.startTime, "123460") - }) - - it("parses Windows CIM CreationDate as the immutable identity", () => { - let script = "" - const probe = probeWindowsProcesses(((_command: string, args: readonly string[]) => { - script = args.at(-1) ?? "" - return result("4242|100|0|20260710123456.123456+000||20260710123456\n") - }) as unknown as SpawnCommand, 25) - - assert.match(script, /Get-CimInstance Win32_Process/) - assert.match(script, /ProcessId -gt 0/) - assert.equal(probe.ok && probe.processes.get(4242)?.startTime, "20260710123456.123456+000") - }) - - it("matches only the same numeric PID and start identity", () => { - const original: ProcessIdentity = { pid: 42, parentPid: 1, groupId: 42, startTime: "start-a" } - assert.equal(sameProcess(original, { ...original }), true) - assert.equal(sameProcess(original, { ...original, startTime: "start-b" }), false) - assert.equal(sameProcess(original, { ...original, pid: 43 }), false) - }) - - it("compares Linux start ticks numerically and rejects non-numeric fallbacks", () => { - const identity: ProcessIdentity = { pid: 42, parentPid: 1, groupId: 42, startTime: "9", startOrder: "9" } - assert.equal(startedNoLaterThan(identity, "10"), true) - assert.equal(startedNoLaterThan({ ...identity, startOrder: "11" }, "10"), false) - assert.equal(startedNoLaterThan({ ...identity, startOrder: "Fri Jul 10" }, "10"), false) - }) - - it("returns mismatch without scheduling a second POSIX signal command", () => { - const invocations: Array<{ command: string; args: readonly string[] }> = [] - const identity: ProcessIdentity = { - pid: 42, - parentPid: 1, - groupId: 42, - startTime: "123456", - bootId: "boot-a", - startOrder: "123456", - } - const guarded = signalPosixProcesses(((command: string, args: readonly string[]) => { - invocations.push({ command, args }) - return result("CODENOMAD_RESULT|0||0\n") - }) as unknown as SpawnCommand, { leader: identity, groupId: 42, members: [identity], signal: "SIGTERM" }, 25, "linux") - - assert.deepEqual(guarded, { ok: true, matched: false, signalSent: false, signaled: [] }) - assert.equal(invocations.length, 1) - assert.equal(invocations[0]?.command, "sh") - assert.match(invocations[0]?.args[2] ?? "", /codenomad-guarded-signal/) - assert.ok(invocations[0]?.args.includes("123456")) - const script = invocations[0]?.args[1] ?? "" - assert.ok(script.indexOf('kill "-$requested_signal"') < script.indexOf("uptime=$(cut")) - }) - - it("uses one guarded Windows CIM selection and termination invocation", () => { - const invocations: Array<{ command: string; args: readonly string[] }> = [] - const identity: ProcessIdentity = { pid: 4242, parentPid: 1, groupId: 4242, startTime: "created" } - const guarded = signalWindowsProcesses(((command: string, args: readonly string[]) => { - invocations.push({ command, args }) - return result("CODENOMAD_TARGET|4242|1|0|created||99\nCODENOMAD_RESULT|1||1\n") - }) as unknown as SpawnCommand, { leader: identity, groupId: 4242, members: [identity], signal: "SIGKILL" }, 25) - - assert.equal(guarded.ok && guarded.matched, true) - assert.equal(invocations.length, 1) - assert.equal(invocations[0]?.command, "powershell.exe") - const script = invocations[0]?.args.at(-1) ?? "" - assert.match(script, /CreationDate/) - assert.match(script, /Invoke-CimMethod -InputObject/) - assert.equal(script.match(/foreach \(\$process in \$selected\)/g)?.length, 2) - assert.ok(script.indexOf("CODENOMAD_TARGET|") < script.indexOf("Invoke-CimMethod")) - assert.doesNotMatch(script, /taskkill/i) - }) - - it("preserves observed Windows targets when guarded termination fails partway", () => { - const identity: ProcessIdentity = { pid: 4242, parentPid: 1, groupId: 4242, startTime: "created" } - const guarded = signalWindowsProcesses((() => result( - [ - "CODENOMAD_TARGET|4242|1|0|created||99", - "CODENOMAD_TARGET|4243|4242|0|descendant||100", - ].join("\n"), - 1, - "termination failed", - )) as unknown as SpawnCommand, { - leader: identity, - groupId: 4242, - members: [identity], - signal: "SIGTERM", - }, 25) - - assert.equal(guarded.ok, false) - assert.deepEqual(!guarded.ok && guarded.observed?.map((target) => target.pid), [4242, 4243]) - }) - - it("reports command failures without fabricating identities", () => { - const probe = probeWindowsProcesses((() => result("", 1, "CIM unavailable")) as unknown as SpawnCommand, 25) - assert.deepEqual(probe, { ok: false, error: "CIM unavailable" }) - }) - - it("rejects successful commands with unparseable or empty output", () => { - const probe = probeWslProcesses((() => result("not an identity")) as unknown as SpawnCommand, "Ubuntu", 25) - assert.deepEqual(probe, { ok: false, error: "process identity query returned no parseable processes" }) - }) -}) diff --git a/packages/server/src/workspaces/process-identity.ts b/packages/server/src/workspaces/process-identity.ts deleted file mode 100644 index e7c86ae1d..000000000 --- a/packages/server/src/workspaces/process-identity.ts +++ /dev/null @@ -1,485 +0,0 @@ -import type { SpawnSyncReturns, spawnSync } from "node:child_process" - -export interface ProcessIdentity { - pid: number - parentPid: number - groupId: number - startTime: string - bootId?: string - startOrder?: string -} - -export type ProcessSnapshot = - | { ok: true; processes: Map } - | { ok: false; error: string } - -export interface GuardedSignalRequest { - leader?: ProcessIdentity - groupId?: number - members: ProcessIdentity[] - signal: NodeJS.Signals -} - -export type GuardedSignalResult = - | { ok: true; matched: boolean; signalSent: boolean; signaled: ProcessIdentity[]; cutoff?: string } - | { ok: false; error: string; observed?: ProcessIdentity[] } - -type SpawnCommand = typeof spawnSync - -const LINUX_SNAPSHOT_SCRIPT = String.raw` -boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || exit 1 -test -n "$boot" || exit 1 -for stat in /proc/[0-9]*/stat; do - line=$(cat "$stat" 2>/dev/null) || continue - pid=$(printf '%s\n' "$line" | cut -d' ' -f1) - rest=$(printf '%s\n' "$line" | sed 's/^.*) //') - set -- $rest - printf '%s|%s|%s|%s|%s|%s\n' "$pid" "$2" "$3" "$20" "$boot" "$20" -done -` - -const LINUX_GUARDED_SIGNAL_SCRIPT = String.raw` -boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || exit 20 -read_stat() { - line=$(cat "/proc/$1/stat" 2>/dev/null) || return 1 - stat_pid=$(printf '%s\n' "$line" | cut -d' ' -f1); rest=$(printf '%s\n' "$line" | sed 's/^.*) //'); set -- $rest - stat_ppid=$2; stat_group=$3; stat_start=$20 -} -emit_target() { - printf 'CODENOMAD_TARGET|%s|%s|%s|%s|%s|%s\n' "$stat_pid" "$stat_ppid" "$stat_group" "$stat_start" "$boot" "$stat_start" -} -leader_pid=$1; leader_start=$2; leader_boot=$3; expected_group=$4; requested_signal=$5 -shift 5 -matched=0; cutoff=; signal_sent=0 -if read_stat "$leader_pid" && test "$boot" = "$leader_boot" && test "$stat_start" = "$leader_start" && test "$stat_group" = "$expected_group"; then - matched=1 - for stat in /proc/[0-9]*/stat; do - candidate=$(basename "$(dirname "$stat")") - if read_stat "$candidate" && test "$stat_group" = "$expected_group"; then emit_target; fi - done - if kill "-$requested_signal" -- "-$expected_group" 2>/dev/null; then - signal_sent=1 - hz=$(getconf CLK_TCK 2>/dev/null) || exit 21 - uptime=$(cut -d' ' -f1 /proc/uptime 2>/dev/null) || exit 21 - cutoff=$(awk -v uptime="$uptime" -v hz="$hz" 'BEGIN { printf "%.0f", uptime * hz }') - fi -else - while test "$#" -ge 3; do - expected_pid=$1; expected_start=$2; expected_boot=$3; shift 3 - if read_stat "$expected_pid" && test "$boot" = "$expected_boot" && test "$stat_start" = "$expected_start"; then - emit_target - if kill "-$requested_signal" "$expected_pid" 2>/dev/null; then signal_sent=1; fi - fi - done -fi -printf 'CODENOMAD_RESULT|%s|%s|%s\n' "$matched" "$cutoff" "$signal_sent" -` - -const POSIX_GUARDED_SIGNAL_SCRIPT = String.raw` -encode() { printf '%s' "$1" | base64 | tr -d '\r\n'; } -read_identity() { - current_meta=$(ps -p "$1" -o ppid= -o pgid= -o lstart= 2>/dev/null) || return 1 - current_command=$(ps -p "$1" -o command= 2>/dev/null) || return 1 - current_verify=$(ps -p "$1" -o ppid= -o pgid= -o lstart= 2>/dev/null) || return 1 - test "$current_meta" = "$current_verify" || return 1 - set -- $current_meta - test "$#" -ge 7 || return 1 - current_ppid=$1; current_group=$2; shift 2 - current_start="$1 $2 $3 $4 $5" - current_identity=$(printf '%s\t%s' "$current_start" "$current_command") -} -emit_target() { - printf 'CODENOMAD_TARGET_B64|%s|%s|%s|' "$current_pid" "$current_ppid" "$current_group" - encode "$current_start"; printf '|'; encode "$current_command"; printf '\n' -} -leader_pid=$1; leader_start=$2; expected_group=$3; requested_signal=$4; shift 4 -matched=0; signal_sent=0 -if read_identity "$leader_pid" && test "$current_group" = "$expected_group" && test "$current_identity" = "$leader_start"; then - matched=1 - for current_pid in $(ps -eo pid= 2>/dev/null); do - if read_identity "$current_pid" && test "$current_group" = "$expected_group"; then emit_target; fi - done - if kill "-$requested_signal" -- "-$expected_group" 2>/dev/null; then signal_sent=1; fi -else - while test "$#" -ge 2; do - expected_pid=$1; expected_start=$2; shift 2 - current_pid=$expected_pid - if read_identity "$expected_pid" && test "$current_identity" = "$expected_start"; then - emit_target - if kill "-$requested_signal" "$expected_pid" 2>/dev/null; then signal_sent=1; fi - fi - done -fi -printf 'CODENOMAD_RESULT|%s||%s\n' "$matched" "$signal_sent" -` - -const POSIX_OWNED_GROUP_SIGNAL_SCRIPT = String.raw` -encode() { printf '%s' "$1" | base64 | tr -d '\r\n'; } -read_identity() { - current_meta=$(ps -p "$1" -o ppid= -o pgid= -o lstart= 2>/dev/null) || return 1 - current_command=$(ps -p "$1" -o command= 2>/dev/null) || return 1 - current_verify=$(ps -p "$1" -o ppid= -o pgid= -o lstart= 2>/dev/null) || return 1 - test "$current_meta" = "$current_verify" || return 1 - set -- $current_meta - test "$#" -ge 7 || return 1 - current_ppid=$1; current_group=$2; shift 2 - current_start="$1 $2 $3 $4 $5" - current_identity=$(printf '%s\t%s' "$current_start" "$current_command") -} -emit_target() { - printf 'CODENOMAD_TARGET_B64|%s|%s|%s|' "$current_pid" "$current_ppid" "$current_group" - encode "$current_start"; printf '|'; encode "$current_command"; printf '\n' -} -root_pid=$1; requested_signal=$2; matched=0; signal_sent=0 -if read_identity "$root_pid" && test "$current_group" = "$root_pid"; then - matched=1 - for current_pid in $(ps -eo pid= 2>/dev/null); do - if read_identity "$current_pid" && test "$current_group" = "$root_pid"; then emit_target; fi - done - if kill "-$requested_signal" -- "-$root_pid" 2>/dev/null; then signal_sent=1; fi - for current_pid in $(ps -eo pid= 2>/dev/null); do - if read_identity "$current_pid" && test "$current_group" = "$root_pid"; then emit_target; fi - done -fi -printf 'CODENOMAD_RESULT|%s||%s\n' "$matched" "$signal_sent" -` - -function commandError(result: SpawnSyncReturns): string { - return result.error?.message || String(result.stderr ?? result.stdout ?? "").trim() || `exit code ${result.status}` -} - -function parseDelimitedSnapshot(output: string, requireBootId = false): Map | null { - const processes = new Map() - for (const line of output.split(/\r?\n/)) { - if (!line) continue - const fields = line.split("|") - if (fields.length !== 6) return null - const [pidText, parentPidText, groupIdText, startTime = "", bootId = "", startOrder = ""] = fields - const pid = Number.parseInt(pidText ?? "", 10) - const parentPid = Number.parseInt(parentPidText ?? "", 10) - const groupId = Number.parseInt(groupIdText ?? "", 10) - if (!/^\d+$/.test(pidText ?? "") || !/^\d+$/.test(parentPidText ?? "") || !/^\d+$/.test(groupIdText ?? "") || - !Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !startTime || (requireBootId && !bootId)) return null - processes.set(pid, { - pid, - parentPid, - groupId: Number.isInteger(groupId) && groupId > 0 ? groupId : pid, - startTime, - ...(bootId ? { bootId } : {}), - ...(startOrder ? { startOrder } : {}), - }) - } - return processes -} - -function decodeBase64Field(value: string): string | null { - if (value.length === 0 || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { - return null - } - try { - const bytes = Buffer.from(value, "base64") - if (bytes.toString("base64") !== value) return null - return new TextDecoder("utf-8", { fatal: true }).decode(bytes) - } catch { - return null - } -} - -function parseBase64Snapshot(output: string, prefix = "CODENOMAD_B64|"): Map | null { - const processes = new Map() - for (const line of output.split(/\r?\n/)) { - if (!line) continue - if (!line.startsWith(prefix)) return null - const fields = line.slice(prefix.length).split("|") - if (fields.length !== 5) return null - const [pidText = "", parentPidText = "", groupIdText = "", startEncoded = "", commandEncoded = ""] = fields - if (!/^\d+$/.test(pidText) || !/^\d+$/.test(parentPidText) || !/^\d+$/.test(groupIdText)) return null - const pid = Number.parseInt(pidText, 10) - const parentPid = Number.parseInt(parentPidText, 10) - const groupId = Number.parseInt(groupIdText, 10) - const start = decodeBase64Field(startEncoded) - const command = decodeBase64Field(commandEncoded) - if (pid <= 0 || parentPid < 0 || groupId <= 0 || start === null || command === null) return null - processes.set(pid, { pid, parentPid, groupId, startTime: `${start}\t${command}` }) - } - return processes -} - -function parsePortablePosixSnapshot(output: string): Map | null { - const processes = new Map() - for (const line of output.split(/\r?\n/)) { - if (!line.trim()) continue - const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/) - if (!match) return null - const [, pidText = "", parentPidText = "", groupIdText = "", start = "", command = ""] = match - const pid = Number.parseInt(pidText, 10) - const parentPid = Number.parseInt(parentPidText, 10) - const groupId = Number.parseInt(groupIdText, 10) - if (pid <= 0 || parentPid < 0 || groupId <= 0) return null - processes.set(pid, { pid, parentPid, groupId, startTime: `${start}\t${command}` }) - } - return processes -} - -function snapshotOrFailure(processes: Map | null): ProcessSnapshot { - return processes && processes.size > 0 - ? { ok: true, processes } - : { ok: false, error: "process identity query returned no parseable processes" } -} - -function parseGuardedResult(result: SpawnSyncReturns): GuardedSignalResult { - const signaled = new Map() - const failure = (error: string): GuardedSignalResult => ({ - ok: false, - error, - ...(signaled.size > 0 ? { observed: Array.from(signaled.values()) } : {}), - }) - let matched: boolean | undefined - let signalSent = false - let cutoff: string | undefined - for (const line of String(result.stdout ?? "").split(/\r?\n/)) { - if (line.startsWith("CODENOMAD_TARGET|")) { - const parsed = parseDelimitedSnapshot(line.slice("CODENOMAD_TARGET|".length)) - if (!parsed) return failure("guarded signal command returned a malformed target record") - for (const identity of parsed.values()) signaled.set(identity.pid, identity) - continue - } - if (line.startsWith("CODENOMAD_TARGET_B64|")) { - const parsed = parseBase64Snapshot(line, "CODENOMAD_TARGET_B64|") - if (!parsed) return failure("guarded signal command returned a malformed target record") - for (const identity of parsed.values()) signaled.set(identity.pid, identity) - continue - } - if (line.startsWith("CODENOMAD_RESULT|")) { - const fields = line.split("|") - if (fields.length !== 4 || !/^[01]$/.test(fields[1] ?? "") || !/^[01]$/.test(fields[3] ?? "")) { - return failure("guarded signal command returned a malformed result record") - } - const [, matchedText, cutoffText, signalSentText] = fields - matched = matchedText === "1" - cutoff = cutoffText || undefined - signalSent = signalSentText === "1" - continue - } - if (line) return failure("guarded signal command returned unexpected output") - } - if (result.status !== 0) return failure(commandError(result)) - return matched === undefined - ? failure("guarded signal command returned no structured result") - : { ok: true, matched, signalSent, signaled: Array.from(signaled.values()), ...(cutoff ? { cutoff } : {}) } -} - -function runGuardedCommand( - spawnCommand: SpawnCommand, - command: string, - args: string[], - timeoutMs: number, -): GuardedSignalResult { - try { - return parseGuardedResult(spawnCommand(command, args, { encoding: "utf8", timeout: timeoutMs })) - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } -} - -function signalName(signal: NodeJS.Signals): "TERM" | "KILL" { - return signal === "SIGKILL" ? "KILL" : "TERM" -} - -function shellGuardArgs(request: GuardedSignalRequest, linux: boolean): string[] { - const leader = request.leader - const args = linux - ? [String(leader?.pid ?? 0), leader?.startTime ?? "", leader?.bootId ?? "", String(request.groupId ?? 0), signalName(request.signal)] - : [String(leader?.pid ?? 0), leader?.startTime ?? "", String(request.groupId ?? 0), signalName(request.signal)] - for (const member of request.members) { - args.push(String(member.pid), member.startTime) - if (linux) args.push(member.bootId ?? "") - } - return args -} - -function quotePowerShell(value: string): string { - return `'${value.replace(/'/g, "''")}'` -} - -function buildWindowsGuardedScript(request: GuardedSignalRequest): string { - const leaderPid = request.leader?.pid ?? 0 - const leaderStart = quotePowerShell(request.leader?.startTime ?? "") - const expected = request.members - .map((identity) => `@{ Pid = ${identity.pid}; Start = ${quotePowerShell(identity.startTime)} }`) - .join(", ") - return [ - "$ErrorActionPreference = 'Stop'", - `$leaderPid = ${leaderPid}`, - `$leaderStart = ${leaderStart}`, - `$expected = @(${expected})`, - "function Get-CodeNomadStart($process) { return ([datetime]$process.CreationDate).ToUniversalTime().Ticks.ToString() }", - "$all = @(Get-CimInstance Win32_Process -ErrorAction Stop)", - "$byPid = @{}; $all | ForEach-Object { $byPid[[int]$_.ProcessId] = $_ }", - "$leader = $byPid[$leaderPid]", - "$matched = $null -ne $leader -and (Get-CodeNomadStart $leader) -eq $leaderStart", - "$selected = @()", - "if ($matched) {", - " $ids = @($leaderPid); $changed = $true", - " while ($changed) { $changed = $false; foreach ($process in $all) { if ($ids -contains [int]$process.ParentProcessId -and $ids -notcontains [int]$process.ProcessId) { $ids += [int]$process.ProcessId; $changed = $true } } }", - " $selected = @($all | Where-Object { $ids -contains [int]$_.ProcessId } | Sort-Object ProcessId -Descending)", - "} else {", - " foreach ($item in $expected) { $process = $byPid[[int]$item.Pid]; if ($null -ne $process -and (Get-CodeNomadStart $process) -eq [string]$item.Start) { $selected += $process } }", - "}", - "foreach ($process in $selected) {", - " $start = Get-CodeNomadStart $process", - " '{0}|{1}|0|{2}||{2}' -f [int]$process.ProcessId, [int]$process.ParentProcessId, $start | ForEach-Object { 'CODENOMAD_TARGET|' + $_ }", - "}", - "foreach ($process in $selected) {", - " Invoke-CimMethod -InputObject $process -MethodName Terminate -Arguments @{ Reason = 1 } -ErrorAction Stop | Out-Null", - "}", - "'CODENOMAD_RESULT|' + ($(if ($matched) { '1' } else { '0' })) + '||' + ($(if ($selected.Count -gt 0) { '1' } else { '0' }))", - ].join("; ") -} - -export function sameProcess(left: ProcessIdentity | undefined, right: ProcessIdentity | undefined): boolean { - return Boolean( - left && right && left.pid === right.pid && left.startTime === right.startTime && - (!left.bootId || !right.bootId || left.bootId === right.bootId), - ) -} - -export function startedNoLaterThan(identity: ProcessIdentity, cutoff: string): boolean { - const startOrder = identity.startOrder ?? identity.startTime - if (!/^\d+$/.test(startOrder) || !/^\d+$/.test(cutoff)) return false - try { - return BigInt(startOrder) <= BigInt(cutoff) - } catch { - return false - } -} - -export function descendantsOf(processes: Map, rootPid: number): ProcessIdentity[] { - const descendants: ProcessIdentity[] = [] - const pending = [rootPid] - const seen = new Set(pending) - while (pending.length > 0) { - const parentPid = pending.shift()! - for (const process of processes.values()) { - if (process.parentPid !== parentPid || seen.has(process.pid)) continue - seen.add(process.pid) - pending.push(process.pid) - descendants.push(process) - } - } - return descendants -} - -export function probePosixProcesses( - spawnCommand: SpawnCommand, - timeoutMs: number, - platform: NodeJS.Platform = process.platform, -): ProcessSnapshot { - try { - if (platform === "linux") { - const result = spawnCommand("sh", ["-c", LINUX_SNAPSHOT_SCRIPT, "codenomad-posix-identity"], { - encoding: "utf8", - timeout: timeoutMs, - }) - if (result.status !== 0) return { ok: false, error: commandError(result) } - return snapshotOrFailure(parseDelimitedSnapshot(String(result.stdout ?? ""), true)) - } - - // POSIX has no portable pidfd/start ticks; collect one coherent table instead of probing every PID. - const result = spawnCommand("ps", ["-axo", "pid=,ppid=,pgid=,lstart=,command="], { - encoding: "utf8", - timeout: timeoutMs, - }) - if (result.status !== 0) return { ok: false, error: commandError(result) } - return snapshotOrFailure(parsePortablePosixSnapshot(String(result.stdout ?? ""))) - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } -} - -export function probeWindowsProcesses(spawnCommand: SpawnCommand, timeoutMs: number): ProcessSnapshot { - const script = [ - "$all = @(Get-CimInstance Win32_Process -ErrorAction Stop)", - "$all | Where-Object { [int]$_.ProcessId -gt 0 } | ForEach-Object { $start = ([datetime]$_.CreationDate).ToUniversalTime().Ticks.ToString(); '{0}|{1}|0|{2}||{2}' -f [int]$_.ProcessId, [int]$_.ParentProcessId, $start }", - ].join("; ") - try { - const result = spawnCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { - encoding: "utf8", - timeout: timeoutMs, - }) - if (result.status !== 0) return { ok: false, error: commandError(result) } - return snapshotOrFailure(parseDelimitedSnapshot(String(result.stdout ?? ""))) - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } -} - -export function probeWslProcesses(spawnCommand: SpawnCommand, distro: string, timeoutMs: number): ProcessSnapshot { - try { - const result = spawnCommand( - "wsl.exe", - ["--distribution", distro, "--exec", "sh", "-c", LINUX_SNAPSHOT_SCRIPT, "codenomad-wsl-identity"], - { encoding: "utf8", timeout: timeoutMs }, - ) - if (result.status !== 0) return { ok: false, error: commandError(result) } - return snapshotOrFailure(parseDelimitedSnapshot(String(result.stdout ?? ""), true)) - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } -} - -export function signalPosixProcesses( - spawnCommand: SpawnCommand, - request: GuardedSignalRequest, - timeoutMs: number, - platform: NodeJS.Platform, -): GuardedSignalResult { - const linux = platform === "linux" - return runGuardedCommand( - spawnCommand, - "sh", - ["-c", linux ? LINUX_GUARDED_SIGNAL_SCRIPT : POSIX_GUARDED_SIGNAL_SCRIPT, "codenomad-guarded-signal", ...shellGuardArgs(request, linux)], - timeoutMs, - ) -} - -export function signalOwnedPosixProcessGroup( - spawnCommand: SpawnCommand, - rootPid: number, - signal: NodeJS.Signals, - timeoutMs: number, -): GuardedSignalResult { - return runGuardedCommand( - spawnCommand, - "sh", - ["-c", POSIX_OWNED_GROUP_SIGNAL_SCRIPT, "codenomad-owned-group-cleanup", String(rootPid), signalName(signal)], - timeoutMs, - ) -} - -export function signalWslProcesses( - spawnCommand: SpawnCommand, - distro: string, - request: GuardedSignalRequest, - timeoutMs: number, -): GuardedSignalResult { - return runGuardedCommand( - spawnCommand, - "wsl.exe", - ["--distribution", distro, "--exec", "sh", "-c", LINUX_GUARDED_SIGNAL_SCRIPT, "codenomad-wsl-guarded-signal", ...shellGuardArgs(request, true)], - timeoutMs, - ) -} - -export function signalWindowsProcesses( - spawnCommand: SpawnCommand, - request: GuardedSignalRequest, - timeoutMs: number, -): GuardedSignalResult { - return runGuardedCommand( - spawnCommand, - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", buildWindowsGuardedScript(request)], - timeoutMs, - ) -} diff --git a/packages/server/src/workspaces/runtime.test.ts b/packages/server/src/workspaces/runtime.test.ts deleted file mode 100644 index cbc5a7c4e..000000000 --- a/packages/server/src/workspaces/runtime.test.ts +++ /dev/null @@ -1,679 +0,0 @@ -import assert from "node:assert/strict" -import type { ChildProcess, SpawnSyncReturns } from "node:child_process" -import { EventEmitter } from "node:events" -import { PassThrough } from "node:stream" -import { describe, it } from "node:test" -import pino from "pino" - -import { EventBus } from "../events/bus" -import { - WorkspaceRuntime, - WorkspaceRuntimeIdentityCaptureError, - WorkspaceRuntimeLaunchCancelledError, - WorkspaceStopTimeoutError, - WorkspaceWindowsTreeCleanupIncompleteError, - type WorkspaceRuntimeOptions, -} from "./runtime" - -type TimerHandle = ReturnType -type SpawnCommand = typeof import("node:child_process").spawnSync - -class ManualTimers { - private nextId = 1 - private readonly pending = new Map void; delayMs: number }>() - - readonly setTimeout = (callback: () => void, delayMs: number): TimerHandle => { - const id = this.nextId++ - this.pending.set(id, { callback, delayMs }) - return id as unknown as TimerHandle - } - - readonly clearTimeout = (timer: TimerHandle): void => { - this.pending.delete(timer as unknown as number) - } - - runNext(): void { - const next = Array.from(this.pending.entries()).sort((left, right) => left[1].delayMs - right[1].delayMs || left[0] - right[0])[0] - assert.ok(next, "expected a pending timer") - this.pending.delete(next[0]) - next[1].callback() - } - - get size(): number { - return this.pending.size - } -} - -class FakeChild extends EventEmitter { - readonly stdout = new PassThrough() - readonly stderr = new PassThrough() - readonly liveSignals: NodeJS.Signals[] = [] - exitCode: number | null = null - signalCode: NodeJS.Signals | null = null - - constructor(readonly pid: number | undefined = 4242) { - super() - } - - kill(signal: NodeJS.Signals = "SIGTERM"): boolean { - this.liveSignals.push(signal) - return true - } - - exit(code: number | null, signal: NodeJS.Signals | null): void { - this.exitCode = code - this.signalCode = signal - this.emit("exit", code, signal) - } -} - -function result(stdout = "", status = 0, stderr = ""): SpawnSyncReturns { - return { pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null } -} - -function linuxRows(rows: Array<[number, number, number, string]>, bootId = "boot-a"): string { - return rows.map(([pid, parentPid, groupId, start]) => `${pid}|${parentPid}|${groupId}|${start}|${bootId}|${start}`).join("\n") -} - -function windowsRows(rows: Array<[number, number, string]>): string { - return rows.map(([pid, parentPid, start], index) => `${pid}|${parentPid}|0|${start}||${100 + index}`).join("\n") -} - -function guardedRows(matched: boolean, rows: Array<[number, number, number, string]>, cutoff?: string, bootId = "boot-a"): string { - const targets = rows.map(([pid, parentPid, groupId, start]) => - `CODENOMAD_TARGET|${pid}|${parentPid}|${groupId}|${start}|${bootId}|${start}`, - ) - return [...targets, `CODENOMAD_RESULT|${matched ? "1" : "0"}|${cutoff ?? ""}|${targets.length > 0 ? "1" : "0"}`].join("\n") -} - -function tokenSignalRows(rows: Array<[number, number, number, string]>, bootId = "boot-a"): string { - return [ - ...rows.map(([pid, parentPid, groupId, start]) => - `CODENOMAD_TARGET|${pid}|${parentPid}|${groupId}|${start}|${bootId}|${start}`, - ), - `CODENOMAD_RESULT|${rows.length > 0 ? "1" : "0"}`, - ].join("\n") -} - -function isTokenCleanup(args: readonly string[]): boolean { - return args.includes("codenomad-token-cleanup") -} - -function isTokenSignal(args: readonly string[]): boolean { - return isTokenCleanup(args) && args.some((arg) => arg.includes("for pass in 1 2 3")) -} - -function isGuarded(args: readonly string[]): boolean { - return !isTokenCleanup(args) && args.some((arg) => arg.includes("guarded-signal") || arg.includes("CODENOMAD_RESULT")) -} - -async function createRuntime( - options: Omit = {}, - reportPort = true, - binaryPath = "opencode", -) { - const child = new FakeChild() - const timers = new ManualTimers() - const platform = options.platform ?? "linux" - const defaultCommand = ((command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - return isTokenSignal(args) - ? result(tokenSignalRows([[4242, 1, 4242, "100"]])) - : result(linuxRows([[4242, 1, 4242, "100"]])) - } - if (isGuarded(args)) { - return platform === "win32" - ? result("CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1") - : result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) - } - return platform === "win32" - ? result(windowsRows([[4242, 1, "win-start"]])) - : result(linuxRows([[4242, 1, 4242, "100"]])) - }) as unknown as SpawnCommand - const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "silent" }), { - gracefulStopTimeoutMs: 10, - forcedStopTimeoutMs: 10, - spawnSync: defaultCommand, - ...options, - setTimeout: timers.setTimeout, - clearTimeout: timers.clearTimeout, - spawn: (() => { - if (reportPort) queueMicrotask(() => child.stdout.write("opencode server listening on http://127.0.0.1:4321\n")) - return child as unknown as ChildProcess - }) as typeof import("node:child_process").spawn, - }) - const launch = runtime.launch({ workspaceId: "workspace-1", folder: process.cwd(), binaryPath }) - if (reportPort) await launch - return { runtime, child, timers, launch } -} - -function setWslIdentity(runtime: WorkspaceRuntime): void { - const managed = (runtime as unknown as { - processes: Map }> - }).processes.get("workspace-1") - assert.ok(managed) - managed.processKind = "wsl" - managed.wsl = { - distro: "Ubuntu", - linuxPid: 99, - linuxPgid: 99, - leaderStartTime: "50", - bootId: "wsl-boot", - members: new Map([[99, { pid: 99, parentPid: 1, groupId: 99, startTime: "50", bootId: "wsl-boot", startOrder: "50" }]]), - } -} - -describe("workspace runtime verified stop", () => { - it("keeps an error listener on children rejected before launch handlers are installed", async () => { - const child = new FakeChild(undefined) - const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "silent" }), { - platform: "linux", - spawn: (() => child as unknown as ChildProcess) as typeof import("node:child_process").spawn, - spawnSync: (() => result()) as unknown as SpawnCommand, - }) - - await assert.rejects( - runtime.launch({ workspaceId: "missing-pid", folder: process.cwd(), binaryPath: "missing-opencode" }), - WorkspaceRuntimeIdentityCaptureError, - ) - assert.equal(child.listenerCount("error"), 1) - assert.doesNotThrow(() => child.emit("error", new Error("ENOENT"))) - }) - - it("rejects a direct Windows launch without immutable process identity", async () => { - let commandCalls = 0 - const harness = await createRuntime({ - platform: "win32", - spawnSync: (() => { - commandCalls += 1 - return result("", 1, "CIM unavailable") - }) as unknown as SpawnCommand, - }, false, "opencode.exe") - - await assert.rejects(harness.launch, WorkspaceRuntimeIdentityCaptureError) - assert.ok(commandCalls >= 1) - assert.deepEqual(harness.child.liveSignals, ["SIGTERM"]) - harness.child.exit(null, "SIGTERM") - }) - - it("stops an identity-matched direct Windows process tree", async () => { - let alive = true - const commands: Array<{ command: string; args: readonly string[] }> = [] - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((command: string, args: readonly string[]) => { - commands.push({ command, args: [...args] }) - if (isGuarded(args)) { - alive = false - return result([ - "CODENOMAD_TARGET|4243|4242|0|descendant-start||101", - "CODENOMAD_TARGET|4242|1|0|win-start||100", - "CODENOMAD_RESULT|1||1", - ].join("\n")) - } - return result(alive - ? windowsRows([[4242, 1, "win-start"], [4243, 4242, "descendant-start"]]) - : windowsRows([[7, 1, "other-start"]])) - }) as unknown as SpawnCommand, - }, true, "opencode.exe") - - await harness.runtime.stop("workspace-1") - assert.equal(commands.some(({ command }) => command === "taskkill.exe"), false) - assert.equal(commands.filter(({ args }) => isGuarded(args)).length, 1) - assert.deepEqual(harness.child.liveSignals, []) - }) - - it("keeps direct Windows stop bounded when the child ignores termination", async () => { - const commands: Array<{ command: string; args: readonly string[] }> = [] - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((command: string, args: readonly string[]) => { - commands.push({ command, args: [...args] }) - return isGuarded(args) - ? result("CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1") - : result(windowsRows([[4242, 1, "win-start"]])) - }) as unknown as SpawnCommand, - }, true, "opencode.exe") - - const stop = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(stop, WorkspaceStopTimeoutError) - assert.equal(commands.filter(({ args }) => isGuarded(args)).length, 2) - assert.equal(commands.some(({ command }) => command === "taskkill.exe"), false) - assert.deepEqual(harness.child.liveSignals, []) - }) - - it("keeps tracking a descendant after partial guarded Windows termination", async () => { - let stage: "tree" | "descendant" | "gone" = "tree" - let guardedCalls = 0 - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((_command: string, args: readonly string[]) => { - if (isGuarded(args)) { - guardedCalls += 1 - if (guardedCalls === 1) { - stage = "descendant" - return result([ - "CODENOMAD_TARGET|4242|1|0|win-start||100", - "CODENOMAD_TARGET|4243|4242|0|descendant-start||101", - ].join("\n"), 1, "descendant termination failed") - } - stage = "gone" - return result("CODENOMAD_TARGET|4243|1|0|descendant-start||101\nCODENOMAD_RESULT|0||1") - } - if (stage === "tree") { - return result(windowsRows([[4242, 1, "win-start"], [4243, 4242, "descendant-start"]])) - } - if (stage === "descendant") { - return result(windowsRows([[4243, 1, "descendant-start"]])) - } - return result(windowsRows([[7, 1, "other-start"]])) - }) as unknown as SpawnCommand, - }, true, "opencode.exe") - - let settled = false - const stop = harness.runtime.stop("workspace-1").finally(() => { settled = true }) - await Promise.resolve() - assert.equal(settled, false) - harness.timers.runNext() - harness.timers.runNext() - await stop - assert.equal(guardedCalls, 2) - }) - - it("cleans a token-matching descendant that survives its unidentified wrapper", async () => { - const child = new FakeChild() - const timers = new ManualTimers() - let tokenAlive = true - let spawnedToken = "" - let logs = "" - const tokenSignals: number[][] = [] - const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "trace" }, { - write: (chunk: string) => { logs += chunk }, - }), { - platform: "linux", - spawnSync: ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - if (isTokenSignal(args)) { - const rows: Array<[number, number, number, string]> = tokenAlive - ? [[5000, 1, 4242, "150"]] - : [] - tokenSignals.push(rows.map(([pid]) => pid)) - tokenAlive = false - return result(tokenSignalRows(rows)) - } - return result(tokenAlive ? linuxRows([[5000, 1, 4242, "150"]]) : "") - } - return result("", 1, "proc unavailable") - }) as unknown as SpawnCommand, - spawn: ((_command, _args, options) => { - spawnedToken = String(options?.env?.CODENOMAD_LAUNCH_CLEANUP_TOKEN ?? "") - return child as unknown as ChildProcess - }) as typeof import("node:child_process").spawn, - setTimeout: timers.setTimeout, - clearTimeout: timers.clearTimeout, - }) - - await assert.rejects( - runtime.launch({ workspaceId: "workspace-1", folder: process.cwd(), binaryPath: "opencode" }), - WorkspaceRuntimeIdentityCaptureError, - ) - assert.equal((runtime as unknown as { processes: Map }).processes.size, 0) - assert.deepEqual(child.liveSignals, ["SIGTERM"]) - assert.match(spawnedToken, /^[a-f0-9]{64}$/) - assert.equal(logs.includes(spawnedToken), false) - assert.match(logs, /\[REDACTED\]/) - assert.deepEqual(tokenSignals, [[5000]]) - }) - - it("uses one guarded command per signal", async () => { - const guardedInvocations: readonly string[][] = [] - const mutableInvocations = guardedInvocations as string[][] - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - return isTokenSignal(args) - ? result(tokenSignalRows([[4242, 1, 4242, "100"]])) - : result(linuxRows([[4242, 1, 4242, "100"]])) - } - if (isGuarded(args)) { - mutableInvocations.push([...args]) - return result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) - } - return result(linuxRows([[4242, 1, 4242, "100"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ - platform: "linux", - spawnSync: spawnCommand, - }) - - const stop = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(stop, WorkspaceStopTimeoutError) - assert.equal(guardedInvocations.length, 2) - assert.ok(guardedInvocations.every((args) => args.includes("codenomad-guarded-signal"))) - }) - - it("sends no second command when the guarded operation reports identity mismatch", async () => { - let launchProbe = true - let guardedCommands = 0 - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) return result(isTokenSignal(args) ? tokenSignalRows([]) : "") - if (isGuarded(args)) { - guardedCommands += 1 - return result("CODENOMAD_RESULT|0||0") - } - if (launchProbe) { - launchProbe = false - return result(linuxRows([[4242, 1, 4242, "100"]])) - } - return result(linuxRows([[4242, 1, 4242, "300"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }) - - const stop = harness.runtime.stop("workspace-1") - await stop - assert.equal(guardedCommands, 1) - }) - - it("tracks a descendant forked between the precheck and SIGTERM dispatch", async () => { - let phase: "launch" | "after-term" | "after-kill" = "launch" - const guardedTargets: string[][] = [] - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - const rows: Array<[number, number, number, string]> = phase === "after-term" ? [[5000, 4242, 4242, "120"]] : [] - return result(isTokenSignal(args) ? tokenSignalRows(rows) : linuxRows(rows)) - } - if (isGuarded(args)) { - guardedTargets.push([...args]) - if (phase === "launch") { - phase = "after-term" - return result(guardedRows(true, [[4242, 1, 4242, "100"], [5000, 4242, 4242, "120"]], "200")) - } - phase = "after-kill" - return result(guardedRows(false, [[5000, 4242, 4242, "120"]])) - } - if (phase === "launch") return result(linuxRows([[4242, 1, 4242, "100"]])) - if (phase === "after-term") return result(linuxRows([[5000, 1, 4242, "120"]])) - return result(linuxRows([[7, 1, 7, "10"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }) - - const stop = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await stop - - assert.ok(guardedTargets[1]?.includes("5000")) - assert.ok(guardedTargets[1]?.includes("120")) - }) - - it("adopts a descendant forked after SIGTERM and before the returned cutoff", async () => { - let phase: "launch" | "after-term" | "after-kill" = "launch" - const guardedTargets: string[][] = [] - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - const rows: Array<[number, number, number, string]> = phase === "after-term" ? [[5000, 1, 4242, "150"]] : [] - return result(isTokenSignal(args) ? tokenSignalRows(rows) : linuxRows(rows)) - } - if (isGuarded(args)) { - guardedTargets.push([...args]) - if (phase === "launch") { - phase = "after-term" - return result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) - } - phase = "after-kill" - return result(guardedRows(false, [[5000, 4242, 4242, "150"]])) - } - if (phase === "launch") return result(linuxRows([[4242, 1, 4242, "100"]])) - if (phase === "after-term") { - return result(linuxRows([[5000, 1, 4242, "150"]])) - } - return result(linuxRows([[7, 1, 7, "10"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }) - - const stop = harness.runtime.stop("workspace-1") - harness.child.exit(0, null) - harness.timers.runNext() - harness.timers.runNext() - await stop - - assert.equal(guardedTargets.length, 2) - assert.ok(guardedTargets[1]?.includes("5000"), "SIGKILL must include the newly tracked immutable descendant") - assert.ok(guardedTargets[1]?.includes("150")) - }) - - it("does not adopt a newly reused process group after original ownership is lost", async () => { - let launched = false - let termSent = false - let guardedCommands = 0 - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) return result(isTokenSignal(args) ? tokenSignalRows([]) : "") - if (isGuarded(args)) { - guardedCommands += 1 - termSent = true - return result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) - } - if (!launched) { - launched = true - return result(linuxRows([[4242, 1, 4242, "100"]])) - } - return termSent - ? result(linuxRows([[4242, 1, 4242, "300"], [6000, 4242, 4242, "150"]])) - : result(linuxRows([[4242, 1, 4242, "100"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }) - - const stop = harness.runtime.stop("workspace-1") - await stop - assert.equal(guardedCommands, 1) - }) - - it("uses a guarded WSL command and retains immutable Linux identities", async () => { - let alive = true - const guardedCommands: string[][] = [] - const spawnCommand = ((command: string, args: readonly string[]) => { - if (command === "powershell.exe") return result(windowsRows([[4242, 1, "host-start"]])) - if (isTokenCleanup(args)) { - const rows: Array<[number, number, number, string]> = alive ? [[99, 1, 99, "50"]] : [] - if (isTokenSignal(args)) alive = false - return result(isTokenSignal(args) ? tokenSignalRows(rows, "wsl-boot") : linuxRows(rows, "wsl-boot")) - } - if (args.includes("codenomad-wsl-guarded-signal")) { - guardedCommands.push([...args]) - alive = false - return result(guardedRows(true, [[99, 1, 99, "50"]], "80", "wsl-boot")) - } - if (args.includes("codenomad-wsl-identity")) { - return result(alive ? linuxRows([[99, 1, 99, "50"]], "wsl-boot") : linuxRows([[7, 1, 7, "10"]], "wsl-boot")) - } - return result() - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "win32", spawnSync: spawnCommand }, true, "opencode.cmd") - setWslIdentity(harness.runtime) - - const stop = harness.runtime.stop("workspace-1") - await stop - assert.equal(guardedCommands.length, 1) - assert.ok(guardedCommands[0]?.includes("wsl-boot")) - }) - - it("launches and stops a bare opencode cmd shim without PowerShell", async () => { - const commands: Array<{ command: string; args: readonly string[] }> = [] - const spawnCommand = ((command: string, args: readonly string[]) => { - commands.push({ command, args: [...args] }) - return command === "powershell.exe" ? result("", 1, "ETIMEDOUT") : result() - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "win32", spawnSync: spawnCommand }, true, "opencode") - - const stop = harness.runtime.stop("workspace-1") - assert.deepEqual(commands, [{ command: "taskkill.exe", args: ["/PID", "4242", "/T"] }]) - harness.child.exit(0, null) - await stop - assert.equal(commands.some(({ command }) => command === "powershell.exe"), false) - }) - - it("adds force only when Windows wrapper cleanup escalates", async () => { - const invocations: readonly string[][] = [] - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((_command: string, args: readonly string[]) => { - (invocations as string[][]).push([...args]) - return result() - }) as unknown as SpawnCommand, - }, true, "opencode.cmd") - - const stop = harness.runtime.stop("workspace-1") - harness.timers.runNext() - assert.deepEqual(invocations, [ - ["/PID", "4242", "/T"], - ["/PID", "4242", "/T", "/F"], - ]) - harness.child.exit(0, null) - await stop - }) - - it("keeps failed Windows wrapper cleanup bounded and retryable", async () => { - let available = false - const harness = await createRuntime({ - platform: "win32", - spawnSync: (() => available ? result() : result("", 1, "taskkill unavailable")) as unknown as SpawnCommand, - }, true, "opencode.cmd") - - const first = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(first, (error: unknown) => { - assert.ok(error instanceof WorkspaceStopTimeoutError) - assert.match(error.message, /taskkill \/T failed: taskkill unavailable/) - assert.match(error.message, /taskkill \/T \/F failed: taskkill unavailable/) - return true - }) - - available = true - const retry = harness.runtime.stop("workspace-1") - harness.child.exit(0, null) - await retry - }) - - it("never signals an exited wrapper PID after tree cleanup was not confirmed", async () => { - const invocations: readonly string[][] = [] - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((_command: string, args: readonly string[]) => { - (invocations as string[][]).push([...args]) - return result("", 1, "taskkill unavailable") - }) as unknown as SpawnCommand, - }, true, "opencode") - - const first = harness.runtime.stop("workspace-1") - harness.child.exit(1, null) - await assert.rejects(first, WorkspaceWindowsTreeCleanupIncompleteError) - assert.equal((harness.runtime as unknown as { processes: Map }).processes.size, 1) - assert.deepEqual(invocations, [["/PID", "4242", "/T"]]) - - const second = harness.runtime.stop("workspace-1") - await assert.rejects(second, (error: unknown) => { - assert.ok(error instanceof WorkspaceWindowsTreeCleanupIncompleteError) - assert.match(error.message, /taskkill unavailable/) - return true - }) - assert.equal((harness.runtime as unknown as { processes: Map }).processes.size, 1) - assert.deepEqual(invocations, [["/PID", "4242", "/T"]]) - }) - - it("persists confirmed wrapper tree cleanup across a later exit and retry", async () => { - const invocations: readonly string[][] = [] - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((_command: string, args: readonly string[]) => { - (invocations as string[][]).push([...args]) - return result() - }) as unknown as SpawnCommand, - }, true, "opencode.cmd") - - const first = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(first, WorkspaceStopTimeoutError) - harness.child.exit(0, null) - - await harness.runtime.stop("workspace-1") - assert.deepEqual(invocations, [ - ["/PID", "4242", "/T"], - ["/PID", "4242", "/T", "/F"], - ]) - assert.equal((harness.runtime as unknown as { processes: Map }).processes.size, 0) - }) - - it("retains launch identity so a transient stop failure can recover on retry", async () => { - let available = true - let alive = true - let guardedCommands = 0 - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - if (!available) return result("", 1, "identity service unavailable") - const rows: Array<[number, number, number, string]> = alive ? [[4242, 1, 4242, "100"]] : [] - if (isTokenSignal(args)) alive = false - return result(isTokenSignal(args) ? tokenSignalRows(rows) : linuxRows(rows)) - } - if (isGuarded(args)) { - guardedCommands += 1 - if (!available) return result("", 1, "identity service unavailable") - alive = false - return result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) - } - if (!available) return result("", 1, "identity service unavailable") - return result(alive ? linuxRows([[4242, 1, 4242, "100"]]) : linuxRows([[7, 1, 7, "10"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }) - - available = false - const first = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(first, /cleanup could not be confirmed/) - - available = true - const retry = harness.runtime.stop("workspace-1") - await retry - assert.equal(guardedCommands, 3) - }) - - it("shares one bounded stop operation across concurrent callers", async () => { - const harness = await createRuntime({ platform: "linux" }) - const first = harness.runtime.stop("workspace-1") - const second = harness.runtime.stop("workspace-1") - assert.strictEqual(first, second) - harness.timers.runNext() - harness.timers.runNext() - const results = await Promise.allSettled([first, second]) - assert.equal(results[0].status, "rejected") - assert.equal(results[1].status, "rejected") - }) - - it("cancels a no-port launch while retaining retryable stop state", async () => { - let alive = true - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - const rows: Array<[number, number, number, string]> = alive ? [[4242, 1, 4242, "100"]] : [] - return result(isTokenSignal(args) ? tokenSignalRows(rows) : linuxRows(rows)) - } - if (isGuarded(args)) return result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) - return result(alive ? linuxRows([[4242, 1, 4242, "100"]]) : linuxRows([[7, 1, 7, "10"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }, false) - - const firstStop = harness.runtime.stop("workspace-1") - await assert.rejects(harness.launch, WorkspaceRuntimeLaunchCancelledError) - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(firstStop, WorkspaceStopTimeoutError) - - alive = false - const retry = harness.runtime.stop("workspace-1") - await retry - }) -}) diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index 68c5e2a94..efc77f9a1 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -1,31 +1,10 @@ import { ChildProcess, spawn, spawnSync } from "child_process" -import { randomBytes } from "crypto" import { existsSync, statSync } from "fs" import path from "path" import { EventBus } from "../events/bus" import { LogLevel, WorkspaceLogEntry } from "../api-types" import { Logger } from "../logger" -import { buildSpawnSpec, type SpawnProcessKind } from "./spawn" -import { - descendantsOf, - probePosixProcesses, - probeWindowsProcesses, - probeWslProcesses, - sameProcess, - signalPosixProcesses, - signalOwnedPosixProcessGroup, - signalWindowsProcesses, - signalWslProcesses, - startedNoLaterThan, - type GuardedSignalResult, - type ProcessIdentity, - type ProcessSnapshot, -} from "./process-identity" -import { - LAUNCH_CLEANUP_TOKEN_ENV, - probeLaunchCleanupToken, - signalLaunchCleanupToken, -} from "./launch-cleanup" +import { buildSpawnSpec, buildWslSignalSpec } from "./spawn" const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:" @@ -60,139 +39,29 @@ export interface ProcessExitInfo { interface ManagedProcess { child: ChildProcess - cleanupToken: string - processKind: SpawnProcessKind - windowsTreeCleanupConfirmed?: boolean - windowsTreeCleanupFailures?: string[] - identityCaptureFailed?: boolean requestedStop: boolean - stopPromise?: Promise - cancelWaits?: () => void - finalizeExit?: (code: number | null, signal: NodeJS.Signals | null) => void - targets?: { - leader?: ProcessIdentity - groupId?: number - dispatchCutoff?: string - members: Map - } wsl?: { distro: string linuxPid: number | null - linuxPgid: number | null - leaderStartTime: string | null - bootId: string | null - dispatchCutoff?: string - members: Map - } -} - -type RuntimeTimeout = ReturnType - -export interface WorkspaceRuntimeOptions { - gracefulStopTimeoutMs?: number - forcedStopTimeoutMs?: number - stopCommandTimeoutMs?: number - platform?: NodeJS.Platform - spawn?: typeof spawn - spawnSync?: typeof spawnSync - setTimeout?: (callback: () => void, delayMs: number) => RuntimeTimeout - clearTimeout?: (timer: RuntimeTimeout) => void -} - -export class WorkspaceStopTimeoutError extends Error { - readonly code = "WORKSPACE_STOP_TIMEOUT" - readonly retryable = true - - constructor(workspaceId: string, pid: number | undefined, timeoutMs: number, liveness: string, failures: string[]) { - const failureDetails = failures.length > 0 ? ` Stop failures: ${failures.join("; ")}.` : "" - super( - `Workspace ${workspaceId} process ${pid ?? "with unknown PID"} did not stop within ${timeoutMs}ms; ${liveness}.` + - `${failureDetails} The stop can be retried.`, - ) - this.name = "WorkspaceStopTimeoutError" - } -} - -export class WorkspaceWindowsTreeCleanupIncompleteError extends Error { - readonly code = "WORKSPACE_WINDOWS_TREE_CLEANUP_INCOMPLETE" - readonly retryable = true - - constructor(workspaceId: string, pid: number | undefined, failures: string[]) { - const failureDetails = failures.length > 0 ? ` Stop failures: ${failures.join("; ")}.` : "" - super( - `Workspace ${workspaceId} Windows wrapper ${pid ?? "with unknown PID"} exited before taskkill confirmed process-tree cleanup.` + - `${failureDetails} The workspace record was retained because cleanup is incomplete.`, - ) - this.name = "WorkspaceWindowsTreeCleanupIncompleteError" - } -} - -export class WorkspaceRuntimeLaunchCancelledError extends Error { - readonly code = "WORKSPACE_RUNTIME_LAUNCH_CANCELLED" - - constructor(workspaceId: string) { - super(`Workspace ${workspaceId} runtime launch was cancelled`) - this.name = "WorkspaceRuntimeLaunchCancelledError" - } -} - -export class WorkspaceRuntimeIdentityCaptureError extends Error { - readonly code = "WORKSPACE_RUNTIME_IDENTITY_CAPTURE_FAILED" - - constructor(workspaceId: string, detail: string) { - super(`Workspace ${workspaceId} process identity capture failed: ${detail}`) - this.name = "WorkspaceRuntimeIdentityCaptureError" } } export class WorkspaceRuntime { private processes = new Map() - private readonly platform: NodeJS.Platform - private readonly spawnProcess: typeof spawn - private readonly spawnCommand: typeof spawnSync - private readonly scheduleTimeout: (callback: () => void, delayMs: number) => RuntimeTimeout - private readonly cancelTimeout: (timer: RuntimeTimeout) => void - private readonly gracefulStopTimeoutMs: number - private readonly forcedStopTimeoutMs: number - private readonly stopCommandTimeoutMs: number - - constructor( - private readonly eventBus: EventBus, - private readonly logger: Logger, - options: WorkspaceRuntimeOptions = {}, - ) { - this.platform = options.platform ?? process.platform - this.spawnProcess = options.spawn ?? spawn - this.spawnCommand = options.spawnSync ?? spawnSync - this.scheduleTimeout = options.setTimeout ?? setTimeout - this.cancelTimeout = options.clearTimeout ?? clearTimeout - this.gracefulStopTimeoutMs = Math.max(0, options.gracefulStopTimeoutMs ?? 2000) - this.forcedStopTimeoutMs = Math.max(0, options.forcedStopTimeoutMs ?? 2000) - this.stopCommandTimeoutMs = Math.max(1, options.stopCommandTimeoutMs ?? 1000) - } - async launch(options: LaunchOptions): Promise<{ - pid: number - port: number - exitPromise: Promise - cancellationPromise: Promise - getLastOutput: () => string - }> { + constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {} + + async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise; getLastOutput: () => string }> { this.validateFolder(options.folder) const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG" const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel] - const cleanupToken = randomBytes(32).toString("hex") - const env = { ...process.env, ...(options.environment ?? {}), [LAUNCH_CLEANUP_TOKEN_ENV]: cleanupToken } + const env = { ...process.env, ...(options.environment ?? {}) } let exitResolve: ((info: ProcessExitInfo) => void) | null = null const exitPromise = new Promise((resolveExit) => { exitResolve = resolveExit }) - let cancellationResolve: ((error: WorkspaceRuntimeLaunchCancelledError) => void) | null = null - const cancellationPromise = new Promise((resolveCancellation) => { - cancellationResolve = resolveCancellation - }) // Store recent output for debugging - keep last 50 lines from each stream const MAX_OUTPUT_LINES = 50 @@ -212,13 +81,12 @@ export class WorkspaceRuntime { } return new Promise((resolve, reject) => { - const propagatedEnvKeys = [...Object.keys(options.environment ?? {}), LAUNCH_CLEANUP_TOKEN_ENV] + const propagatedEnvKeys = Object.keys(options.environment ?? {}) const spec = buildSpawnSpec(options.binaryPath, args, { cwd: options.folder, env, propagateEnvKeys: propagatedEnvKeys, wslPidMarker: WSL_PID_MARKER, - platform: this.platform, }) const commandLine = [spec.command, ...spec.args].join(" ") this.logger.info( @@ -247,64 +115,25 @@ export class WorkspaceRuntime { }, "OpenCode spawn environment", ) - const detached = this.platform !== "win32" - const child = this.spawnProcess(spec.command, spec.args, { + const detached = process.platform !== "win32" + const child = spawn(spec.command, spec.args, { cwd: spec.cwd, env: spec.env, stdio: ["ignore", "pipe", "pipe"], detached, ...spec.options, }) - const handleEarlyError = (error: Error) => { - this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime failed before launch handlers were ready") - } - child.on("error", handleEarlyError) const managed: ManagedProcess = { child, - cleanupToken, - processKind: spec.processKind, requestedStop: false, - targets: { members: new Map() }, - ...(spec.wsl - ? { - wsl: { - distro: spec.wsl.distro, - linuxPid: null, - linuxPgid: null, - leaderStartTime: null, - bootId: null, - members: new Map(), - }, - } - : {}), + ...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}), } this.processes.set(options.workspaceId, managed) - if (spec.processKind === "posix" || spec.processKind === "wsl" || spec.processKind === "windows-direct") { - const launchSnapshot = child.pid - ? this.platform === "win32" - ? probeWindowsProcesses(this.spawnCommand, this.stopCommandTimeoutMs) - : probePosixProcesses(this.spawnCommand, this.stopCommandTimeoutMs, this.platform) - : { ok: false as const, error: "spawned child did not expose a PID" } - const launchLeader = launchSnapshot.ok && child.pid ? launchSnapshot.processes.get(child.pid) : undefined - if (!launchLeader) { - const detail = launchSnapshot.ok - ? `spawned PID ${child.pid ?? "unknown"} was absent from the identity snapshot` - : launchSnapshot.error - this.beginFailedLaunchCleanup(options.workspaceId, managed) - reject(new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, detail)) - return - } - managed.targets!.leader = launchLeader - managed.targets!.groupId = launchLeader.groupId - managed.targets!.members.set(launchLeader.pid, launchLeader) - } let stdoutBuffer = "" let stderrBuffer = "" let portFound = false - let pendingPort: number | null = null - let launchSettled = false let warningTimer: NodeJS.Timeout | null = null @@ -323,33 +152,15 @@ export class WorkspaceRuntime { startWarningTimer() - managed.cancelWaits = () => { - const error = new WorkspaceRuntimeLaunchCancelledError(options.workspaceId) - if (!launchSettled) { - launchSettled = true - stopWarningTimer() - reject(error) - } - if (cancellationResolve) { - cancellationResolve(error) - cancellationResolve = null - } - } - const cleanupStreams = () => { stopWarningTimer() child.stdout?.removeAllListeners() child.stderr?.removeAllListeners() } - let finalized = false const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { - if (finalized) return - finalized = true this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited") - if (!managed.requestedStop && this.processes.get(options.workspaceId) === managed) { - this.processes.delete(options.workspaceId) - } + this.processes.delete(options.workspaceId) cleanupStreams() child.removeListener("error", handleError) child.removeListener("exit", handleExit) @@ -366,59 +177,27 @@ export class WorkspaceRuntime { if (!portFound) { const recentOutput = getLastOutput().trim() const reason = recentOutput || stderrBuffer || `Process exited with code ${code}` - if (!launchSettled) { - launchSettled = true - reject(new Error(reason)) - } + reject(new Error(reason)) } else { options.onExit?.(exitInfo) } } - managed.finalizeExit = handleExit const handleError = (error: Error) => { cleanupStreams() child.removeListener("exit", handleExit) - if (!managed.requestedStop && this.processes.get(options.workspaceId) === managed) { - this.processes.delete(options.workspaceId) - } + this.processes.delete(options.workspaceId) this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error") if (exitResolve) { exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop }) exitResolve = null } - if (!launchSettled) { - launchSettled = true - reject(error) - } + reject(error) } - child.removeListener("error", handleEarlyError) child.on("error", handleError) child.on("exit", handleExit) - const resolveLaunchIfIdentified = () => { - if (launchSettled || pendingPort === null) return - if (managed.wsl && (!managed.wsl.linuxPid || !managed.wsl.linuxPgid || !managed.wsl.leaderStartTime || !managed.wsl.bootId)) { - return - } - portFound = true - launchSettled = true - stopWarningTimer() - child.removeListener("error", handleError) - this.logger.info({ workspaceId: options.workspaceId, port: pendingPort }, "Workspace runtime allocated port") - resolve({ pid: child.pid!, port: pendingPort, exitPromise, cancellationPromise, getLastOutput }) - } - - const failWslIdentityCapture = (detail: string) => { - if (launchSettled) return - launchSettled = true - managed.requestedStop = true - cleanupStreams() - this.beginFailedLaunchCleanup(options.workspaceId, managed) - reject(new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, detail)) - } - child.stdout?.on("data", (data: Buffer) => { const text = data.toString() stdoutBuffer += text @@ -430,34 +209,10 @@ export class WorkspaceRuntime { if (!trimmed) continue if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) { - const [linuxPidText, linuxPgidText, linuxStartTime = "", bootId = ""] = trimmed.slice(WSL_PID_MARKER.length).split(":", 4) - const linuxPid = Number.parseInt(linuxPidText ?? "", 10) - const linuxPgid = Number.parseInt(linuxPgidText ?? "", 10) - if (Number.isInteger(linuxPid) && linuxPid > 0 && Number.isInteger(linuxPgid) && linuxPgid > 0 && /^\d+$/.test(linuxStartTime) && bootId) { + const linuxPid = Number.parseInt(trimmed.slice(WSL_PID_MARKER.length), 10) + if (Number.isFinite(linuxPid) && linuxPid > 0) { managed.wsl.linuxPid = linuxPid - managed.wsl.linuxPgid = linuxPgid - managed.wsl.leaderStartTime = linuxStartTime - managed.wsl.bootId = bootId - managed.wsl.members.set(linuxPid, { - pid: linuxPid, - parentPid: 0, - groupId: linuxPgid, - startTime: linuxStartTime, - bootId, - startOrder: linuxStartTime, - }) - this.logger.debug( - { - workspaceId: options.workspaceId, - linuxPid, - linuxPgid: managed.wsl.linuxPgid, - linuxStartTime: managed.wsl.leaderStartTime, - }, - "Captured WSL OpenCode process identity", - ) - resolveLaunchIfIdentified() - } else { - failWslIdentityCapture("WSL launcher returned an incomplete Linux PID identity") + this.logger.debug({ workspaceId: options.workspaceId, linuxPid }, "Captured WSL OpenCode PID") } continue } @@ -471,13 +226,13 @@ export class WorkspaceRuntime { if (!portFound) { const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i) - if (portMatch && !launchSettled) { - pendingPort = parseInt(portMatch[1], 10) - if (managed.wsl && (!managed.wsl.leaderStartTime || !managed.wsl.bootId)) { - failWslIdentityCapture("WSL process reported a port before its Linux identity") - } else { - resolveLaunchIfIdentified() - } + if (portMatch) { + portFound = true + stopWarningTimer() + child.removeListener("error", handleError) + const port = parseInt(portMatch[1], 10) + this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port") + resolve({ pid: child.pid!, port, exitPromise, getLastOutput }) } } } @@ -504,495 +259,173 @@ export class WorkspaceRuntime { }) } - private beginFailedLaunchCleanup(workspaceId: string, managed: ManagedProcess): void { - const child = managed.child - managed.identityCaptureFailed = true - void this.stop(workspaceId).catch((error) => { - this.logger.warn({ workspaceId, err: error }, "Unpublished workspace cleanup remains pending") - }) - try { - child.kill("SIGTERM") - } catch (error) { - this.logger.debug({ workspaceId, err: error }, "Failed initial live-child cleanup signal") - } - } - - stop(workspaceId: string): Promise { + async stop(workspaceId: string): Promise { const managed = this.processes.get(workspaceId) - if (!managed) return Promise.resolve() - - if (managed.stopPromise) { - return managed.stopPromise - } - - const stopPromise = this.stopManagedProcess(workspaceId, managed) - managed.stopPromise = stopPromise - void stopPromise.then( - () => { - if (managed.stopPromise === stopPromise) managed.stopPromise = undefined - }, - () => { - if (managed.stopPromise === stopPromise) managed.stopPromise = undefined - }, - ) - return stopPromise - } + if (!managed) return - private stopManagedProcess(workspaceId: string, managed: ManagedProcess): Promise { managed.requestedStop = true - managed.cancelWaits?.() - managed.cancelWaits = undefined const child = managed.child this.logger.info({ workspaceId }, "Stopping OpenCode process") - if (managed.processKind === "windows-wrapper") { - return this.stopOwnedWindowsProcess(workspaceId, managed) - } - const pid = child.pid - const failures: string[] = [] - - type TargetLiveness = { state: "alive" | "gone" | "unknown"; detail: string } - type RefreshedTargets = { - snapshot: ProcessSnapshot - leaderMatches: boolean - aliveMembers: ProcessIdentity[] + if (!pid) { + this.logger.warn({ workspaceId }, "Workspace process missing PID; cannot stop") + return } - const wrapperExited = () => child.exitCode !== null || child.signalCode !== null - const rememberGroupMembers = ( - snapshot: ProcessSnapshot, - targets: NonNullable, - ): RefreshedTargets => { - if (!snapshot.ok || !pid) return { snapshot, leaderMatches: false, aliveMembers: [] } - const currentLeader = snapshot.processes.get(pid) - const leaderMatches = sameProcess(targets.leader, currentLeader) - const currentGroupLeader = targets.groupId ? snapshot.processes.get(targets.groupId) : undefined - const groupWasReused = Boolean(currentGroupLeader && !sameProcess(targets.leader, currentGroupLeader)) - if (targets.groupId) { - for (const process of snapshot.processes.values()) { - const launchBootMatches = !targets.leader?.bootId || process.bootId === targets.leader.bootId - const withinProvenLinuxDispatch = this.platform === "linux" && Boolean( - targets.dispatchCutoff && launchBootMatches && startedNoLaterThan(process, targets.dispatchCutoff), - ) - if (process.groupId === targets.groupId && (leaderMatches || (!groupWasReused && withinProvenLinuxDispatch))) { - targets.members.set(process.pid, process) - } - } - } - return { - snapshot, - leaderMatches, - aliveMembers: Array.from(targets.members.values()).filter((identity) => - sameProcess(identity, snapshot.processes.get(identity.pid)), - ), - } - } - - const refreshHostTargets = (): RefreshedTargets => { - const targets = managed.targets ?? { members: new Map() } - managed.targets = targets - const snapshot = this.platform === "win32" - ? probeWindowsProcesses(this.spawnCommand, this.stopCommandTimeoutMs) - : probePosixProcesses(this.spawnCommand, this.stopCommandTimeoutMs, this.platform) - if (!snapshot.ok) { - failures.push(`${this.platform === "win32" ? "Windows" : "POSIX"} identity discovery failed: ${snapshot.error}`) - return { snapshot, leaderMatches: false, aliveMembers: [] } - } + const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null - const refreshed = rememberGroupMembers(snapshot, targets) - if (this.platform === "win32" && refreshed.leaderMatches && targets.leader) { - for (const descendant of descendantsOf(snapshot.processes, targets.leader.pid)) { - targets.members.set(descendant.pid, descendant) + const tryKillPosixGroup = (signal: NodeJS.Signals) => { + try { + // Negative PID targets the process group (POSIX). + process.kill(-pid, signal) + return true + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err?.code === "ESRCH") { + return true } - refreshed.aliveMembers = Array.from(targets.members.values()).filter((identity) => - sameProcess(identity, snapshot.processes.get(identity.pid)), - ) + this.logger.debug({ workspaceId, pid, err }, "Failed to signal POSIX process group") + return false } - return refreshed } - const refreshWslTargets = (): RefreshedTargets => { - const wsl = managed.wsl! - const snapshot = probeWslProcesses(this.spawnCommand, wsl.distro, this.stopCommandTimeoutMs) - if (!snapshot.ok) { - failures.push(`WSL identity discovery failed: ${snapshot.error}`) - return { snapshot, leaderMatches: false, aliveMembers: [] } - } - if (!wsl.linuxPid || !wsl.leaderStartTime) { - return { snapshot, leaderMatches: false, aliveMembers: [] } - } - const leaderIdentity: ProcessIdentity = { - pid: wsl.linuxPid, - parentPid: 0, - groupId: wsl.linuxPgid ?? wsl.linuxPid, - startTime: wsl.leaderStartTime, - ...(wsl.bootId ? { bootId: wsl.bootId } : {}), - startOrder: wsl.leaderStartTime, - } - const currentLeader = snapshot.processes.get(wsl.linuxPid) - const leaderMatches = sameProcess(leaderIdentity, currentLeader) - const currentGroupLeader = wsl.linuxPgid ? snapshot.processes.get(wsl.linuxPgid) : undefined - const groupWasReused = Boolean(currentGroupLeader && !sameProcess(leaderIdentity, currentGroupLeader)) - if (wsl.linuxPgid) { - for (const process of snapshot.processes.values()) { - const withinProvenDispatch = Boolean( - wsl.dispatchCutoff && process.bootId === wsl.bootId && startedNoLaterThan(process, wsl.dispatchCutoff), - ) - if (process.groupId === wsl.linuxPgid && (leaderMatches || (!groupWasReused && withinProvenDispatch))) { - wsl.members.set(process.pid, process) - } + const tryKillSinglePid = (signal: NodeJS.Signals) => { + try { + process.kill(pid, signal) + return true + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err?.code === "ESRCH") { + return true } - } - return { - snapshot, - leaderMatches, - aliveMembers: Array.from(wsl.members.values()).filter((identity) => - sameProcess(identity, snapshot.processes.get(identity.pid)), - ), + this.logger.debug({ workspaceId, pid, err }, "Failed to signal workspace PID") + return false } } - const hasWslIdentity = () => Boolean( - managed.wsl?.linuxPid && managed.wsl.linuxPgid && managed.wsl.leaderStartTime && managed.wsl.bootId, - ) - - const canUseTokenCleanup = () => this.platform === "linux" || Boolean(managed.wsl) - const tokenTarget = () => managed.wsl ?? managed.targets! - const refreshTokenTargets = (): ProcessSnapshot | undefined => { - if (!canUseTokenCleanup()) return undefined - const snapshot = probeLaunchCleanupToken( - this.spawnCommand, - managed.cleanupToken, - this.stopCommandTimeoutMs, - managed.wsl?.distro, - ) - if (!snapshot.ok) { - failures.push(`${managed.wsl ? "WSL" : "Linux"} launch-token discovery failed: ${snapshot.error}`) - return snapshot + const tryTaskkill = (force: boolean) => { + const args = ["/PID", String(pid), "/T"] + if (force) { + args.push("/F") } - const target = tokenTarget() - for (const identity of snapshot.processes.values()) target.members.set(identity.pid, identity) - return snapshot - } - const recordSignalResult = ( - result: GuardedSignalResult, - target: NonNullable | NonNullable, - platformName: string, - signal: NodeJS.Signals, - ) => { - if (!result.ok) { - for (const identity of result.observed ?? []) target.members.set(identity.pid, identity) - failures.push(`${platformName} guarded ${signal} failed: ${result.error}`) - return + try { + const result = spawnSync("taskkill", args, { encoding: "utf8" }) + const exitCode = result.status + if (exitCode === 0) { + return true + } + // If the PID is already gone, treat it as success. + const stderr = (result.stderr ?? "").toString().toLowerCase() + const stdout = (result.stdout ?? "").toString().toLowerCase() + const combined = `${stdout}\n${stderr}` + if (combined.includes("not found") || combined.includes("no running instance") || combined.includes("process") && combined.includes("not")) { + return true + } + this.logger.debug({ workspaceId, pid, exitCode, stderr: result.stderr, stdout: result.stdout }, "taskkill failed") + return false + } catch (error) { + this.logger.debug({ workspaceId, pid, err: error }, "taskkill failed to execute") + return false } - if (result.matched && !result.signalSent) failures.push(`${platformName} guarded ${signal} matched but sent no signal`) - for (const identity of result.signaled) target.members.set(identity.pid, identity) - if (result.cutoff) target.dispatchCutoff = result.cutoff } - const sendStopSignal = (signal: NodeJS.Signals) => { - if (!pid) { - failures.push(`${signal} was not sent because the process PID is unavailable`) + const trySignalWslProcess = (signal: NodeJS.Signals) => { + if (process.platform !== "win32" || !managed.wsl?.linuxPid) { + return false } - let ownedPosixHandled = false - if (pid && managed.identityCaptureFailed && this.platform !== "linux" && this.platform !== "win32" && !wrapperExited()) { - const cleanup = signalOwnedPosixProcessGroup(this.spawnCommand, pid, signal, this.stopCommandTimeoutMs) - recordSignalResult(cleanup, managed.targets!, "owned POSIX group", signal) - if (cleanup.ok && cleanup.matched) { - const leader = cleanup.signaled.find((identity) => identity.pid === pid) - if (leader) { - managed.targets!.leader = leader - managed.targets!.groupId = pid - } + try { + const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal) + const result = spawnSync(spec.command, spec.args, { encoding: "utf8" }) + const exitCode = result.status + if (exitCode === 0) { + return true } - refreshHostTargets() - ownedPosixHandled = true - } - if (pid && managed.wsl && hasWslIdentity()) { - const wsl = managed.wsl - const leader: ProcessIdentity = { - pid: wsl.linuxPid!, - parentPid: 0, - groupId: wsl.linuxPgid!, - startTime: wsl.leaderStartTime!, - bootId: wsl.bootId!, - startOrder: wsl.leaderStartTime!, - } - const result = signalWslProcesses(this.spawnCommand, wsl.distro, { - leader, - groupId: wsl.linuxPgid!, - members: Array.from(wsl.members.values()), - signal, - }, this.stopCommandTimeoutMs) - recordSignalResult(result, wsl, "WSL", signal) - refreshWslTargets() - } else if (pid && !ownedPosixHandled) { - const targets = managed.targets! - const request = { - leader: targets.leader, - groupId: targets.groupId, - members: Array.from(targets.members.values()), - signal, - } - if (this.platform === "win32") { - recordSignalResult( - signalWindowsProcesses(this.spawnCommand, request, this.stopCommandTimeoutMs), - targets, - "Windows", - signal, - ) - } else { - recordSignalResult( - signalPosixProcesses(this.spawnCommand, request, this.stopCommandTimeoutMs, this.platform), - targets, - "POSIX", - signal, - ) + const stderr = (result.stderr ?? "").toString().toLowerCase() + const stdout = (result.stdout ?? "").toString().toLowerCase() + const combined = `${stdout}\n${stderr}` + if (combined.includes("no such process") || combined.includes("not found")) { + return true } - refreshHostTargets() - } - if (canUseTokenCleanup()) { - const tokenResult = signalLaunchCleanupToken( - this.spawnCommand, - managed.cleanupToken, - signal, - this.stopCommandTimeoutMs, - managed.wsl?.distro, + this.logger.debug( + { workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout }, + "WSL kill failed", ) - if (!tokenResult.ok) { - failures.push(`${managed.wsl ? "WSL" : "Linux"} launch-token ${signal} failed: ${tokenResult.error}`) - } else { - if (tokenResult.targets.length > 0 && !tokenResult.signalSent) { - failures.push(`${managed.wsl ? "WSL" : "Linux"} launch-token ${signal} matched but sent no signal`) - } - const target = tokenTarget() - for (const identity of tokenResult.targets) target.members.set(identity.pid, identity) - } - refreshTokenTargets() + return false + } catch (error) { + this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute") + return false } } - const probeTargetLiveness = (): TargetLiveness => { - const refreshed = pid - ? managed.wsl && hasWslIdentity() ? refreshWslTargets() : refreshHostTargets() - : undefined - const tokenSnapshot = refreshTokenTargets() - if (tokenSnapshot && !tokenSnapshot.ok) { - return { state: "unknown", detail: `${managed.wsl ? "WSL Linux" : "Linux"} launch-token cleanup could not be confirmed` } - } - if (refreshed && !refreshed.snapshot.ok && (managed.targets?.leader || !tokenSnapshot?.ok)) { - const platformName = managed.wsl ? "WSL Linux" : this.platform === "win32" ? "Windows" : "POSIX" - return { state: "unknown", detail: `${platformName} target identity could not be confirmed` } - } - const trackedCount = managed.wsl ? managed.wsl.members.size : (managed.targets?.members.size ?? 0) - if (managed.identityCaptureFailed && this.platform === "win32" && !managed.wsl) { - return { state: "unknown", detail: "Windows cleanup cannot prove exact launch ownership without a Job Object" } - } - if (managed.identityCaptureFailed && managed.wsl && !managed.targets?.leader && !wrapperExited()) { - return { state: "unknown", detail: "the unidentified Windows WSL wrapper is still alive" } - } - if (trackedCount === 0) { - if (tokenSnapshot?.ok && tokenSnapshot.processes.size === 0) { - return { state: "gone", detail: "no process carries the unpublished launch token" } + const sendStopSignal = (signal: NodeJS.Signals) => { + if (process.platform === "win32") { + // WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe. + if (!trySignalWslProcess(signal)) { + // Fallback to the Windows process tree rooted at pid. Use /F only for escalation. + tryTaskkill(signal === "SIGKILL") } - return { state: "unknown", detail: pid ? "the original process identity was not captured" : "the target PID is unavailable" } + return } - if ((refreshed?.aliveMembers.length ?? 0) === 0 && (!tokenSnapshot?.ok || tokenSnapshot.processes.size === 0)) { - return { state: "gone", detail: "all tracked original process identities are gone" } + + // Prefer process-group signaling so wrapper launchers (bun/node) don't orphan the real server. + const groupOk = tryKillPosixGroup(signal) + if (!groupOk) { + // Fallback to direct PID kill. + tryKillSinglePid(signal) } - const targetName = managed.wsl && hasWslIdentity() ? "WSL Linux process group" : this.platform === "win32" ? "Windows process tree" : "POSIX process group" - return { state: "alive", detail: `the tracked original ${targetName} is still alive` } } - return new Promise((resolve, reject) => { - let escalationTimer: RuntimeTimeout | null = null - let deadlineTimer: RuntimeTimeout | null = null - let settled = false + await new Promise((resolve, reject) => { + let escalationTimer: NodeJS.Timeout | null = null const cleanup = () => { child.removeListener("exit", onExit) child.removeListener("error", onError) if (escalationTimer) { - this.cancelTimeout(escalationTimer) + clearTimeout(escalationTimer) escalationTimer = null } - if (deadlineTimer) { - this.cancelTimeout(deadlineTimer) - deadlineTimer = null - } } - const confirmTargetGone = () => { - if (settled) return - settled = true - cleanup() - if (this.processes.get(workspaceId) === managed) { - this.processes.delete(workspaceId) - } - managed.finalizeExit?.(child.exitCode, child.signalCode) - resolve() - } - const checkForConfirmedStop = () => { - const liveness = probeTargetLiveness() - if (liveness.state === "gone") { - confirmTargetGone() - } - return liveness - } const onExit = () => { - checkForConfirmedStop() - } - const onError = (error: Error) => { - failures.push(`child process error while stopping: ${error.message}`) - } - - child.once("exit", onExit) - child.on("error", onError) - - escalationTimer = this.scheduleTimeout(() => { - escalationTimer = null - if (settled) return - const liveness = checkForConfirmedStop() - if (settled) return - this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating") - sendStopSignal("SIGKILL") - if (liveness.state === "unknown") { - this.logger.debug({ workspaceId, pid }, "Escalating because target liveness could not be confirmed") - } - }, this.gracefulStopTimeoutMs) - - const totalTimeoutMs = this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs - deadlineTimer = this.scheduleTimeout(() => { - deadlineTimer = null - if (settled) return - const liveness = checkForConfirmedStop() - if (settled) return - - settled = true - cleanup() - const wrapperDetail = wrapperExited() ? "the wrapper exited but " : "" - reject(new WorkspaceStopTimeoutError(workspaceId, pid, totalTimeoutMs, `${wrapperDetail}${liveness.detail}`, failures)) - }, totalTimeoutMs) - - this.logger.debug( - { workspaceId, pid, detached: this.platform !== "win32" }, - "Sending SIGTERM to workspace process (tree/group)", - ) - sendStopSignal("SIGTERM") - checkForConfirmedStop() - }) - } - - private stopOwnedWindowsProcess(workspaceId: string, managed: ManagedProcess): Promise { - const child = managed.child - const pid = child.pid - const failures = (managed.windowsTreeCleanupFailures ??= []) - - return new Promise((resolve, reject) => { - let escalationTimer: RuntimeTimeout | null = null - let deadlineTimer: RuntimeTimeout | null = null - let settled = false - - const cleanup = () => { - child.removeListener("exit", onExit) - child.removeListener("error", onError) - if (escalationTimer) this.cancelTimeout(escalationTimer) - if (deadlineTimer) this.cancelTimeout(deadlineTimer) - } - const finish = () => { - if (settled) return - settled = true cleanup() - if (this.processes.get(workspaceId) === managed) this.processes.delete(workspaceId) - managed.finalizeExit?.(child.exitCode, child.signalCode) resolve() } - const rejectIncompleteCleanup = () => { - if (settled) return - settled = true - cleanup() - reject(new WorkspaceWindowsTreeCleanupIncompleteError(workspaceId, pid, failures)) - } - const onExit = () => { - if (managed.windowsTreeCleanupConfirmed) { - finish() - } else { - rejectIncompleteCleanup() - } - } const onError = (error: Error) => { - if (settled) return - settled = true cleanup() - failures.push(`child process error while stopping: ${error.message}`) reject(error) } - const stopChild = (force: boolean) => { - if (child.exitCode !== null || child.signalCode !== null) return - if (!pid) { - failures.push(`${force ? "forced" : "graceful"} stop was not sent because the process PID is unavailable`) - return - } - const args = ["/PID", String(pid), "/T", ...(force ? ["/F"] : [])] - try { - const result = this.spawnCommand("taskkill.exe", args, { - encoding: "utf8", - timeout: this.stopCommandTimeoutMs, - }) - if (result.status === 0) { - managed.windowsTreeCleanupConfirmed = true - } else { - const detail = result.error?.message || String(result.stderr ?? result.stdout ?? "").trim() || `exit code ${result.status}` - failures.push(`taskkill ${force ? "/T /F" : "/T"} failed: ${detail}`) - } - } catch (error) { - failures.push(`taskkill ${force ? "/T /F" : "/T"} failed: ${error instanceof Error ? error.message : String(error)}`) - } + if (isAlreadyExited()) { + this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited") + cleanup() + resolve() + return } child.once("exit", onExit) child.once("error", onError) - if (child.exitCode !== null || child.signalCode !== null) { - if (managed.windowsTreeCleanupConfirmed) { - finish() - } else { - rejectIncompleteCleanup() - } - return - } - escalationTimer = this.scheduleTimeout(() => { + this.logger.debug( + { workspaceId, pid, detached: process.platform !== "win32" }, + "Sending SIGTERM to workspace process (tree/group)", + ) + sendStopSignal("SIGTERM") + + escalationTimer = setTimeout(() => { escalationTimer = null - if (settled) return - this.logger.warn({ workspaceId, pid }, "Owned Windows process did not stop after the graceful attempt, escalating") - stopChild(true) - }, this.gracefulStopTimeoutMs) - - const totalTimeoutMs = this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs - deadlineTimer = this.scheduleTimeout(() => { - deadlineTimer = null - if (settled) return - settled = true - cleanup() - reject(new WorkspaceStopTimeoutError( - workspaceId, - pid, - totalTimeoutMs, - child.exitCode !== null || child.signalCode !== null - ? "taskkill did not confirm tree cleanup before the owned Windows wrapper exited" - : "the owned Windows wrapper did not emit exit or error after tree termination", - failures, - )) - }, totalTimeoutMs) - - this.logger.debug({ workspaceId, pid }, "Stopping owned Windows workspace wrapper tree") - stopChild(false) + if (isAlreadyExited()) { + this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation") + return + } + this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating") + sendStopSignal("SIGKILL") + }, 2000) }) } diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index 52ec18d79..f40dcdb02 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -1,5 +1,4 @@ import { spawnSync } from "child_process" -import { statSync } from "fs" import path from "path" export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) @@ -14,28 +13,10 @@ const CODENOMAD_PLUGIN_FILE_SPEC_REGEX = new RegExp( `(${escapeRegex(CODENOMAD_PLUGIN_PACKAGE_NAME)}@file:)([A-Za-z]:[^"\\r\\n]+?\\.tgz)`, ) const WSL_PATH_ENV_KEYS = new Set(["NODE_EXTRA_CA_CERTS", WSL_PLUGIN_PATH_ENV]) -const WINDOWS_DIRECT_EXTENSIONS = new Set([".com", ".exe"]) -const DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD" -const WINDOWS_SHELL_NAMES = new Set([ - "bash", - "bash.exe", - "cmd", - "cmd.exe", - "command.com", - "powershell", - "powershell.exe", - "pwsh", - "pwsh.exe", - "sh", - "sh.exe", -]) - -export type SpawnProcessKind = "posix" | "windows-direct" | "windows-wrapper" | "wsl" export interface SpawnSpec { command: string args: string[] - processKind: SpawnProcessKind options: { windowsVerbatimArguments?: boolean } @@ -52,7 +33,6 @@ interface BuildSpawnSpecOptions { env?: NodeJS.ProcessEnv propagateEnvKeys?: string[] wslPidMarker?: string - platform?: NodeJS.Platform } interface WslPath { @@ -97,21 +77,17 @@ export function buildWindowsSpawnSpec(binaryPath: string, args: string[], option return buildWslSpawnSpec(wslPath, args, options) } - const resolvedBinaryPath = resolveBareWindowsCommand(binaryPath, options) ?? binaryPath - const extension = path.win32.extname(resolvedBinaryPath).toLowerCase() + const extension = path.extname(binaryPath).toLowerCase() if (WINDOWS_CMD_EXTENSIONS.has(extension)) { - const comspec = getWindowsEnvironmentValue(options.env, "COMSPEC") ?? - getWindowsEnvironmentValue(process.env, "COMSPEC") ?? - "cmd.exe" + const comspec = process.env.ComSpec || "cmd.exe" // cmd.exe requires the full command as a single string. // Using the ""