diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 59e26108466..493df5501b2 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -355,6 +355,8 @@ Windows에서 Task Scheduler 항목을 만들려면 권한 상승이 필요합 시작할 때 `another process owns the runtime mutation lease`가 보고되거나 `ocx service status`에 `Runtime mutation lease busy`가 표시되면, 프록시가 실행 중이 아니더라도 이 리스가 시작이나 서비스 변경을 막고 있는 것입니다. 메시지에는 잠금 경로, 기록된 PID, 그 PID가 현재 살아 있는지 여부, 확인할 수 있는 경우 실행 파일 이름, 리스 경과 시간이 들어 있습니다. 프로세스의 신원은 검증되지 않습니다. PID가 재사용되었을 수 있으므로, 생존 여부와 실행 파일 이름은 지금 그 PID를 쓰고 있는 프로세스에 대한 정보입니다. 작업이 끝날 때까지 기다린 뒤 다시 시도하세요. 이 PID만 보고 잠금을 삭제하거나 프로세스를 중지하지 마세요. 리스 경과 시간이 30초를 넘고 기록된 PID가 더 이상 살아 있지 않으면 이후의 변경 시도가 오래된 리스를 회수할 수 있습니다. `ocx service status`는 리스를 확인만 합니다. +Windows에서는 이 공용 Node/Bun 진단 경로가 실행 파일 이름 조회용 프로그램을 실행하지 않습니다. 기록된 PID, 생존 여부, 리스 경과 시간과 복구 안내는 계속 표시됩니다. + ### `ocx codex-shim ` macOS와 Linux에서 `ocx codex-shim install`은 확정된 OpenCodex 홈에 전용 wrapper `/bin/codex`와 셸에서 읽을 `/codex-shell-env.sh`를 설치합니다. 원래 런처는 brew, npm, fnm이 설치한 위치에 그대로 있어 패키지 관리자 업데이트와 버전 롤백 뒤에도 다시 감쌀 필요가 없습니다. Windows는 기존처럼 스크립트 런처를 제자리에서 감싸며 실제 `codex.exe`는 건드리지 않습니다. Windows에서 `codex.exe`만 제공된다면 `ocx service install`을 사용하세요. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 7baa048e2ab..17f424b3317 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -827,6 +827,9 @@ that PID now. Wait for the operation to finish and retry; do not delete the lock process based only on this PID. A later mutation attempt can reclaim a stale lease once its age exceeds 30 seconds and the recorded PID is no longer alive; status only inspects it. +On Windows, this shared Node/Bun diagnostic does not launch an executable to look up the +image name; the recorded PID, liveness, lease age and recovery guidance remain available. + ### `ocx codex-shim ` On macOS and Linux, install a private autostart wrapper at `/bin/codex` and a diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5a6f6c0f21f..1317c303564 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1698,7 +1698,7 @@ "service-claim.test.ts": "service", "service-ownership-compatibility.test.ts": "service", "service-ownership-handover.test.ts": "service", - "service-ownership-state.test.ts": "service", + "service-ownership-state.test.ts": "service", "ownership-mutation-lease-diagnostics.test.ts": "service", "service-probe-docker.test.ts": "service", "service-secrets.test.ts": "service", "service-sqlite-home.test.ts": "service", diff --git a/src/service/ownership-mutation-lease.d.mts b/src/service/ownership-mutation-lease.d.mts index c01d63f7279..8ee827777ed 100644 --- a/src/service/ownership-mutation-lease.d.mts +++ b/src/service/ownership-mutation-lease.d.mts @@ -5,7 +5,7 @@ export interface OwnershipMutationLeaseOptions { readonly processAlive?: (pid: number) => boolean; readonly beforeRelease?: (lockPath: string) => void; readonly joinToken?: string; - /** Names the current executable at the recorded PID, identity unverified; defaults to `tasklist` or `ps`. */ + /** Names the current executable at the recorded PID, identity unverified; uses `ps` on POSIX and omits the default image lookup on Windows. */ readonly processImage?: (pid: number) => string | null; } diff --git a/src/service/ownership-mutation-lease.mjs b/src/service/ownership-mutation-lease.mjs index 07eab51e1a9..9b95bd5aa7f 100644 --- a/src/service/ownership-mutation-lease.mjs +++ b/src/service/ownership-mutation-lease.mjs @@ -112,13 +112,11 @@ function readIncompleteOwner(path) { /** The current executable at the recorded PID, identity unverified; null if unreadable within a second. */ function processImage(pid) { try { - if (process.platform === "win32") { - const listed = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { - encoding: "utf8", timeout: 1_000, windowsHide: true, - }); - const match = /^"([^"]+)","(\d+)"/.exec((listed.stdout ?? "").trim()); - return listed.status === 0 && match && Number(match[2]) === pid ? match[1] : null; - } + // This module also runs in the Node npm/pnpm launcher, before Bun is available. + // Without a kernel-backed system-directory resolver, omit the optional Windows + // image label rather than searching the caller's directory/PATH or trusting env roots. + // PID, liveness, age and lease ownership decisions do not depend on this label. + if (process.platform === "win32") return null; const listed = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000 }); const command = (listed.stdout ?? "").trim(); return listed.status === 0 && command ? basename(command) : null; diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 4d72608adbd..64cc27a2950 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -162,7 +162,7 @@ an owner claim committed during the early probe cannot be followed by shared Cod When that lease is still busy after its wait, `acquireOwnershipMutationLease` (`src/service/ownership-mutation-lease.mjs`) names the holder in its error and on the error's `holder` field. That means the owner's PID, whether it is alive, a live holder's executable name -when `tasklist`/`ps` answers within a second, and the owner's age on the clock stale recovery +when POSIX `ps` answers within a second (the shared Node/Bun Windows path omits image lookup), and the owner's age on the clock stale recovery uses, plus the 30-second reclaim rule. `ocx service status` prints the same holder line whenever the lease directory exists. That read never reclaims. The connected-client branch, which returns into `startClientRuntime` before the server path, diff --git a/structure/runtime.md b/structure/runtime.md index d3eac545039..ae428036666 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -593,7 +593,7 @@ manager children. A replacement refusal passes through owner-aware recovery: onl owner revives the stopped runtime; foreign ownership stays transferred and unknown ownership remains a reported recovery requirement. Dashboard restart delegates the lease token to its `service repair` child, and releases the lease before that refresh: the service manager spawns `ocx start` outside the worker's process tree with no token, so the managed child must take the lease itself (#5760). Before the direct-start fallthrough kills or spawns on the port, the worker takes the lease back — a refresh that stayed claimed stops the restart — and re-runs the recorded-owner veto under it because a claim could have landed during the unleased refresh window. Direct start holds the same lease through bind plus PID and runtime-address publication. If listener rollback cannot prove the socket closed, the process retains its lease until exit. The registration is never deleted; `ocx service install` releases the marker only after the -registration succeeds. Busy lease diagnostics name the recorded PID, current liveness and executable, and lease age. The process identity is unverified because a PID may be reused. +registration succeeds. Busy lease diagnostics name the recorded PID, current liveness, optional executable (omitted by default on Windows), and lease age. The process identity is unverified because a PID may be reused. Bun updater lease and recovery behavior follows the [update transaction contract](ops/service-and-sidecars.md#bun-updater-ownership-transaction). diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 700841acd0a..f57ac3b67ee 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -969,7 +969,7 @@ "service-auth-qualified-localhost.test.ts": "service", "service-child-ownership.test.ts": "service", "service-handled-signal-exit.test.ts": "service", "service-claim.test.ts": "service", "service-ownership-compatibility.test.ts": "service", "service-ownership-handover.test.ts": "service", - "service-ownership-state.test.ts": "service", "service-probe-docker.test.ts": "service", + "service-ownership-state.test.ts": "service", "ownership-mutation-lease-diagnostics.test.ts": "service", "service-probe-docker.test.ts": "service", "service-secrets.test.ts": "service", "service-sqlite-home.test.ts": "service", "service-stop-verification.test.ts": "service", "service-tier-capability.test.ts": "service", "service-wsl-home-ownership.test.ts": "service", "service.test.ts": "service", diff --git a/tests/service/ownership-mutation-lease-diagnostics.test.ts b/tests/service/ownership-mutation-lease-diagnostics.test.ts new file mode 100644 index 00000000000..29091e73576 --- /dev/null +++ b/tests/service/ownership-mutation-lease-diagnostics.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as childProcess from "node:child_process"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + acquireOwnershipMutationLease, + inspectOwnershipMutationLease, + ownershipMutationLeaseStatusLine, +} from "../../src/service/ownership-mutation-lease.mjs"; + +describe("ownership mutation lease diagnostic lookup (carry #6655)", () => { + const instance = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const token = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const pid = 6236; + let root: string; + let paths: string[]; + let lock: string; + let owner: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-lease-diagnostics-")); + paths = [join(root, "service-state.json")]; + lock = `${paths[0]}.mutation.lock`; + owner = join(lock, `v1-${pid}-${instance}-${token}.json`); + mkdirSync(lock); + writeFileSync(owner, JSON.stringify({ version: 1, pid, processInstance: instance, token, createdAt: 1_000 })); + utimesSync(owner, 1, 1); + }); + + afterEach(() => rmSync(root, { recursive: true, force: true })); + + function onPlatform(platform: NodeJS.Platform, run: () => void) { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + try { + Object.defineProperty(process, "platform", { ...descriptor, value: platform }); + run(); + } finally { + Object.defineProperty(process, "platform", descriptor); + } + } + + function interceptLookup(stdout: string) { + return spyOn(childProcess, "spawnSync").mockReturnValue({ + status: 0, stdout, stderr: "", pid: 1, output: [null, stdout, ""], signal: null, + }); + } + + test("Windows inspection, status and busy refusal launch zero diagnostic subprocesses and preserve the owner", () => { + const bytes = readFileSync(owner); + const identity = lstatSync(owner); + const spawned = interceptLookup(`"untrusted-tasklist.exe","${pid}"`); + try { + onPlatform("win32", () => { + const options = { now: () => 213_000, processAlive: () => true }; + expect(inspectOwnershipMutationLease(paths, options)) + .toMatchObject({ pid, alive: true, image: null, ageMs: 212_000, record: "complete" }); + const line = ownershipMutationLeaseStatusLine(paths, options); + expect(line).toContain(`recorded PID ${pid} [alive, identity unverified], lease age 212s`); + expect(line).toContain("stale leases are reclaimed after 30s once the recorded PID is no longer alive."); + let busy: unknown; + try { + acquireOwnershipMutationLease(paths, { ...options, waitMs: 0 }).release(); + } catch (error) { busy = error; } + expect(busy).toBeInstanceOf(Error); + expect(busy).toMatchObject({ + code: "OWNERSHIP_MUTATION_LEASE_BUSY", + holder: { pid, alive: true, image: null, ageMs: 212_000, record: "complete" }, + }); + expect((busy as Error).message).toContain("another process owns the runtime mutation lease"); + expect(spawned).not.toHaveBeenCalled(); + }); + } finally { spawned.mockRestore(); } + expect(readFileSync(owner)).toEqual(bytes); + expect(lstatSync(owner)).toMatchObject({ dev: identity.dev, ino: identity.ino, mtimeMs: identity.mtimeMs }); + expect(readdirSync(lock)).toEqual([`v1-${pid}-${instance}-${token}.json`]); + }); + + test("Windows status leaves a stale dead owner intact and acquisition still reclaims it", () => { + const bytes = readFileSync(owner); + const spawned = interceptLookup("must not be used"); + try { + onPlatform("win32", () => { + const options = { now: () => 600_000, processAlive: () => false }; + expect(inspectOwnershipMutationLease(paths, options)) + .toMatchObject({ pid, alive: false, image: null, ageMs: 599_000 }); + expect(ownershipMutationLeaseStatusLine(paths, options)).toContain(`[not alive, identity unverified]`); + expect(readFileSync(owner)).toEqual(bytes); + const lease = acquireOwnershipMutationLease(paths, { ...options, waitMs: 0 }); + try { + expect(existsSync(owner)).toBe(false); + const entry = readdirSync(lock)[0]; + expect(JSON.parse(readFileSync(join(lock, entry), "utf8"))) + .toMatchObject({ pid: process.pid, token: lease.token }); + } finally { lease.release(); } + expect(existsSync(lock)).toBe(false); + expect(spawned).not.toHaveBeenCalled(); + }); + } finally { spawned.mockRestore(); } + }); + + test("POSIX diagnostics retain the bounded ps lookup and executable basename", () => { + const spawned = interceptLookup(" /usr/local/bin/lease-holder \n"); + try { + onPlatform("linux", () => { + expect(inspectOwnershipMutationLease(paths, { now: () => 2_000, processAlive: () => true })) + .toMatchObject({ pid, alive: true, image: "lease-holder", ageMs: 1_000 }); + expect(spawned).toHaveBeenCalledTimes(1); + expect(spawned).toHaveBeenCalledWith("ps", ["-o", "comm=", "-p", String(pid)], { + encoding: "utf8", timeout: 1_000, + }); + }); + } finally { spawned.mockRestore(); } + }); + + test("Windows still accepts an explicitly supplied optional image resolver", () => { + const spawned = interceptLookup("must not be used"); + try { + onPlatform("win32", () => { + const image = (recordedPid: number) => recordedPid === pid ? "supplied-image.exe" : null; + expect(inspectOwnershipMutationLease(paths, { processAlive: () => true, processImage: image })) + .toMatchObject({ pid, alive: true, image: "supplied-image.exe" }); + expect(spawned).not.toHaveBeenCalled(); + }); + } finally { spawned.mockRestore(); } + }); +});