Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ko/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <install|status|uninstall|remove>`

macOS와 Linux에서 `ocx codex-shim install`은 확정된 OpenCodex 홈에 전용 wrapper `<OPENCODEX_HOME>/bin/codex`와 셸에서 읽을 `<OPENCODEX_HOME>/codex-shell-env.sh`를 설치합니다. 원래 런처는 brew, npm, fnm이 설치한 위치에 그대로 있어 패키지 관리자 업데이트와 버전 롤백 뒤에도 다시 감쌀 필요가 없습니다. Windows는 기존처럼 스크립트 런처를 제자리에서 감싸며 실제 `codex.exe`는 건드리지 않습니다. Windows에서 `codex.exe`만 제공된다면 `ocx service install`을 사용하세요.
Expand Down
3 changes: 3 additions & 0 deletions docs-site/src/content/docs/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <install|status|uninstall|remove>`

On macOS and Linux, install a private autostart wrapper at `<OPENCODEX_HOME>/bin/codex` and a
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/service/ownership-mutation-lease.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
12 changes: 5 additions & 7 deletions src/service/ownership-mutation-lease.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion structure/ops/service-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
127 changes: 127 additions & 0 deletions tests/service/ownership-mutation-lease-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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(); }
});
});
Loading