Skip to content
Open
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
6 changes: 6 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 @@ -34,6 +34,12 @@ ocx start --port 8080
백그라운드 서비스가 설치되어 있으면 `ocx stop`이 먼저 그 서비스를 중지하므로 프록시가 다시
올라올 수 없습니다. 웹 대시보드의 **Stop** 버튼도 같은 동작(`POST /api/stop`)을 하지만, Windows 작업 스케줄러는 예외입니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 대시보드는 `respawnable_service`로 거절하고 아무것도 바꾸지 않은 채 `ocx stop` 실행을 안내합니다.

프록시가 종료된 것만으로 Codex/Grok 공유 설정 복원까지 성공했다고 판단하지 않습니다. 종료 응답이
실패를 보고하거나, 읽을 수 없거나, 요청한 복원 처리 방식을 확인해 주지 않으면 기존 소유권·재시작
검사를 거친 부모 CLI가 복원을 맡습니다. 이미 종료가 확인된 프로세스를 강제 종료하는 경로로는
넘어가지 않습니다. 영수증에 근거한 지연 복원도 최종 복원과 영수증 정리를 부모가 담당하며,
부모의 공유 설정 복원이 실패하면 종료 실패로 남기고 미완료 영수증을 보존합니다.

### `ocx restart`

프록시가 실행 중이면 확인된 정확한 PID와 포트에 in-place 재시작을 요청하고, 정상 드레인을
Expand Down
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ native Codex is restored, leaving your client config pointed at a proxy that is
dashboard returns `self_unload_service`, changes nothing, and asks you to run `ocx stop` —
which stops the service from outside and completes the restore.

A proxy exit alone does not confirm that shared Codex/Grok restoration succeeded. If the stop
response reports failure, is unreadable, or does not confirm the assigned teardown mode, the CLI
keeps restoration with the stopping parent after the existing ownership and respawn checks.
It does not enter the forced-stop fallback for a process already observed to have exited. A
receipt-backed deferral still leaves final restoration and receipt cleanup with the parent;
failure to restore shared client configuration keeps the stop failed and its receipt outstanding.

### `ocx restart`

When a proxy is running, ask that exact attested PID and port to restart in place, wait for its
Expand Down
25 changes: 21 additions & 4 deletions src/lib/process-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ export function gracefulStopHost(hostname: string | undefined): string {
}

/**
* Outcome of a graceful stop attempt. `"refused"` is distinct from failure: the proxy answered
* that it must NOT be stopped from here, so callers must not escalate to a forced kill.
* `"refused"` forbids forced stop. `"teardown-unconfirmed"` means the process exited,
* but its assigned shared teardown was not confirmed; callers must not kill it again.
*/
export type GracefulStopResult = boolean | "refused";
export type GracefulStopResult = boolean | "refused" | "teardown-unconfirmed";

/**
* The server's own explanation for the most recent 409, captured so `stopProxy` can report
Expand Down Expand Up @@ -100,6 +100,8 @@ export class ProxyOwnershipRefusedError extends Error {}
* chance to run its shutdown handlers. Returns false when the proxy can't be reached
* or doesn't exit in time — callers fall back to {@link killProxy}. Returns `"refused"`
* when the proxy declines the stop (HTTP 409), which callers must NOT force past.
* True requires the expected shared-teardown response and an observed exit. It does not
* attest the process exit code or completion of every drain/shutdown hook.
*/
export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> {
const readRuntime = io.readRuntime ?? readRuntimePort;
Expand All @@ -110,6 +112,7 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv);
if (token) headers["x-opencodex-api-key"] = token;
const fetchFn = io.fetchFn ?? fetch;
let sharedTeardownConfirmed = false;
try {
// `ocx stop` asks the proxy NOT to restore shared client config: it does that itself,
// after verifying a stopped Task Scheduler did not respawn the proxy (#3008). Letting
Expand Down Expand Up @@ -139,14 +142,22 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
return "refused";
}
if (!res.ok) return false;
const body: unknown = await res.json().catch(() => null);
const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed";
sharedTeardownConfirmed = body !== null
&& typeof body === "object"
&& !Array.isArray(body)
&& "success" in body && body.success === true
&& "sharedTeardown" in body && body.sharedTeardown === expectedTeardown;
} catch {
return false;
}
const waitExit = io.waitExit ?? waitForExit;
// Honor the server's own drain window: /api/stop answers 200 first, then drains for
// config.shutdownTimeoutMs. Waiting less than that hard-kills mid-drain.
const exitTimeoutMs = io.exitTimeoutMs ?? drainDeadlineMs();
return waitExit(pid, exitTimeoutMs);
if (!waitExit(pid, exitTimeoutMs)) return false;
return sharedTeardownConfirmed ? true : "teardown-unconfirmed";
}

function drainDeadlineMs(): number {
Expand All @@ -171,6 +182,12 @@ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<b
+ "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.",
);
}
if (graceful === "teardown-unconfirmed") {
// Exit was observed, so do not enter the forced-stop fallback. Returning false keeps
// shared restoration with `ocx stop` instead of claiming that the proxy completed it.
await waitForStoppedPort(runtime, pid);
return false;
}
if (graceful) {
await waitForStoppedPort(runtime, pid);
return true;
Expand Down
2 changes: 1 addition & 1 deletion tests/cli/cli-management-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe("CLI management authentication", () => {
},
fetchFn: async (_input, init) => {
token = new Headers(init?.headers).get("x-opencodex-api-key");
return new Response(null, { status: 200 });
return Response.json({ success: true, sharedTeardown: "performed" });
},
});
expect(result).toBe(true);
Expand Down
99 changes: 99 additions & 0 deletions tests/fixtures/parent-stop-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/** Runs the real CLI/stop module graph with process and client I/O isolated in this child. */
import { mock } from "bun:test";
import * as childProcess from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { CodexNativeRestoreResult } from "../../src/codex/inject";

const options = JSON.parse(readFileSync(0, "utf8")) as {
receipt: boolean;
response: unknown;
restore: CodexNativeRestoreResult;
status?: number;
};
const home = process.env.OPENCODEX_HOME!;
const endpoint = { hostname: "127.0.0.1", port: 10100 };
const fakePid = 4242;
const calls = { killed: 0, native: 0, grok: 0, cleared: 0, exited: 0 };
const urls: string[] = [];
const unexpectedIo: string[] = [];
let alive = true;
let nonce: string | undefined;

function unexpected(operation: string): never {
unexpectedIo.push(operation);
throw new Error(`unexpected external I/O in parent stop fixture: ${operation}`);
}

// Neither an accidental POSIX signal nor the Windows taskkill fallback may reach the host.
process.kill = ((pid: number, signal?: number | NodeJS.Signals) => {
if (pid !== fakePid || signal !== 0) {
calls.killed += 1;
return unexpected(`process.kill(${pid}, ${signal})`);
}
if (alive) return true;
calls.exited += 1;
throw Object.assign(new Error("fixture process exited"), { code: "ESRCH" });
}) as typeof process.kill;
mock.module("node:child_process", () => ({
...childProcess,
execFileSync: () => { calls.killed += 1; return unexpected("execFileSync"); },
}));

const receipts = await import("../../src/config/pending-teardown");
process.on("exit", () => {
writeFileSync(join(home, "parent-stop-result.json"), JSON.stringify({
calls, urls, unexpectedIo, nonce,
receiptExists: nonce !== undefined && existsSync(receipts.pendingTeardownPathFor(nonce)),
}));
});
const claimReceipt = receipts.claimPendingTeardown;
const clearReceipt = receipts.clearPendingTeardown;
mock.module("../../src/config/pending-teardown", () => ({
...receipts,
claimPendingTeardown: (...args: Parameters<typeof claimReceipt>) => {
if (!options.receipt) throw new Error("receipt storage unavailable");
const receipt = claimReceipt(...args);
nonce = receipt.nonce;
return receipt;
},
clearPendingTeardown: (value: string) => { calls.cleared += 1; return clearReceipt(value); },
}));

const state = await import("../../src/config/process-state");
mock.module("../../src/config/process-state", () => ({
...state,
readPid: () => fakePid,
readRuntimePort: () => endpoint,
removePid() {},
removeRuntimePort() {},
}));
const service = await import("../../src/service");
mock.module("../../src/service", () => ({ ...service, stopServiceIfInstalledDetailed: () => "absent" }));
const native = await import("../../src/codex/inject");
mock.module("../../src/codex/inject", () => ({
...native,
restoreNativeCodexAsync: async () => { calls.native += 1; return options.restore; },
}));
const grok = await import("../../src/grok/inject");
mock.module("../../src/grok/inject", () => ({
...grok,
stripGrokConfig: () => { calls.grok += 1; return { ok: true, changed: true, message: "Grok restored" }; },
}));
const systemEnv = await import("../../src/server/system-env");
mock.module("../../src/server/system-env", () => ({ ...systemEnv, revertSystemEnv() {} }));
const portReclaim = await import("../../src/server/port-reclaim");
mock.module("../../src/server/port-reclaim", () => ({ ...portReclaim, reclaimListenPort: async () => {} }));
// Only shim preflight is unrelated to this stop contract; parsing and dispatch stay real.
mock.module("../../src/cli/codex-shim-autorestore", () => ({ maybeAutoRestoreCodexShim() {} }));

globalThis.fetch = (async (input: string | URL | Request) => {
const url = String(input);
if (!url.startsWith("http://127.0.0.1:10100/api/stop")) return unexpected(`fetch(${url})`);
urls.push(url);
if ((options.status ?? 200) !== 409) alive = false;
return Response.json(options.response, { status: options.status ?? 200 });
}) as typeof fetch;

process.argv = [process.execPath, "ocx", "stop"];
await import("../../src/cli/index");
59 changes: 58 additions & 1 deletion tests/lib/process-control-graceful.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { gracefulStopHost, lastStopRefusalMessage, stopProxyGracefully } from "../../src/lib/process-control";

function okResponse(): Response {
return new Response(JSON.stringify({ success: true }), { status: 200 });
return new Response(JSON.stringify({ success: true, sharedTeardown: "performed" }), { status: 200 });
}

describe("gracefulStopHost", () => {
Expand All @@ -22,6 +22,63 @@ describe("gracefulStopHost", () => {
});

describe("stopProxyGracefully", () => {
for (const [name, body] of [
["reported restore failure", JSON.stringify({ success: false, sharedTeardown: "performed" })],
["missing teardown result", JSON.stringify({ success: true })],
["unexpected deferral", JSON.stringify({ success: true, sharedTeardown: "deferred" })],
["nonboolean success", JSON.stringify({ success: "true", sharedTeardown: "performed" })],
["empty body", ""],
["invalid JSON", "{broken"],
["null body", "null"],
["array body", "[]"],
]) {
test(`process exit does not confirm shared teardown: ${name}`, async () => {
const waits: number[] = [];
const result = await stopProxyGracefully(4242, {
readRuntime: () => ({ port: 10100 }),
fetchFn: (async () => new Response(body, { status: 200 })) as typeof fetch,
waitExit: pid => { waits.push(pid); return true; },
exitTimeoutMs: 1,
env: {},
});
expect(result).toBe("teardown-unconfirmed");
expect(waits).toEqual([4242]);
});
}

test("requires the assigned deferred response when a receipt nonce was sent", async () => {
for (const sharedTeardown of ["deferred", "performed"]) {
const result = await stopProxyGracefully(4242, {
readRuntime: () => ({ port: 10100 }),
fetchFn: (async () => new Response(JSON.stringify({ success: true, sharedTeardown }))) as typeof fetch,
waitExit: () => true,
deferSharedTeardownNonce: "receipt-nonce",
exitTimeoutMs: 1,
env: {},
});
expect(result).toBe(sharedTeardown === "deferred" ? true : "teardown-unconfirmed");
}
});

test("an unconfirmed response still requires process exit", async () => {
expect(await stopProxyGracefully(4242, {
readRuntime: () => ({ port: 10100 }),
fetchFn: (async () => new Response(JSON.stringify({ success: false, sharedTeardown: "performed" }))) as typeof fetch,
waitExit: () => false,
exitTimeoutMs: 1,
env: {},
})).toBe(false);
});

test("ownership refusal never waits for exit or becomes a teardown retry", async () => {
expect(await stopProxyGracefully(4242, {
readRuntime: () => ({ port: 10100 }),
fetchFn: (async () => new Response("refused", { status: 409 })) as typeof fetch,
waitExit: () => { throw new Error("must not wait for a refused stop"); },
env: {},
})).toBe("refused");
});

test("follows the recorded bind hostname when it names a concrete address", async () => {
const calls: string[] = [];
await stopProxyGracefully(9, {
Expand Down
Loading
Loading