From f2327ab35f22e64cbaf61b39dbe13964e7cf253d Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:06:08 +0900 Subject: [PATCH 1/2] fix(stop): confirm shared teardown before skipping parent restoration --- .../docs/ko/reference/cli/lifecycle.md | 6 + .../content/docs/reference/cli/lifecycle.md | 7 + src/lib/process-control.ts | 25 ++- tests/cli/cli-management-auth.test.ts | 2 +- tests/lib/process-control-graceful.test.ts | 59 ++++++- tests/service/stop-deferred-teardown.test.ts | 159 +++++++++++++++++- 6 files changed, 246 insertions(+), 12 deletions(-) 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 15a8bfee78..05cf7caf9e 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -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 재시작을 요청하고, 정상 드레인을 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index fdf24dd5f9..99c3035684 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -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 diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index e2262e9e96..49da43987c 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -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 @@ -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 { const readRuntime = io.readRuntime ?? readRuntimePort; @@ -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 @@ -139,6 +142,13 @@ 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; } @@ -146,7 +156,8 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): // 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 { @@ -171,6 +182,12 @@ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise { }, 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); diff --git a/tests/lib/process-control-graceful.test.ts b/tests/lib/process-control-graceful.test.ts index fa56486095..9fad73d38b 100644 --- a/tests/lib/process-control-graceful.test.ts +++ b/tests/lib/process-control-graceful.test.ts @@ -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", () => { @@ -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, { diff --git a/tests/service/stop-deferred-teardown.test.ts b/tests/service/stop-deferred-teardown.test.ts index 9e98375b63..74351fb00f 100644 --- a/tests/service/stop-deferred-teardown.test.ts +++ b/tests/service/stop-deferred-teardown.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; -import { readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { stopProxyGracefully } from "../../src/lib/process-control"; +import { ProxyOwnershipRefusedError, stopProxyGracefully, type GracefulStopIo } from "../../src/lib/process-control"; import { performStopTeardown } from "../../src/server/stop-teardown"; import type { CodexNativeRestoreResult } from "../../src/codex/inject"; +import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../../src/update/stop-contract.mjs"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -48,6 +48,153 @@ function restoreResult(success: boolean): CodexNativeRestoreResult { } as unknown as CodexNativeRestoreResult; } +/** Execute current function bodies with I/O dependencies, without importing the CLI dispatcher. */ +function functionSlice(source: string, start: string, end: string): string { + const from = source.indexOf(start); + const to = source.indexOf(end, from); + if (from < 0 || to <= from) throw new Error(`missing function boundary: ${start}`); + return source.slice(from, to); +} + +async function runParentStop(options: { receipt: boolean; response: unknown; restore: CodexNativeRestoreResult; status?: number }) { + const receipts = await import("../../src/config/pending-teardown"); + const cli = readFileSync(repoPath("src", "cli", "index.ts"), "utf8"); + const control = readFileSync(repoPath("src", "lib", "process-control.ts"), "utf8"); + const transpiler = new Bun.Transpiler({ loader: "ts" }); + const calls = { killed: 0, native: 0, grok: 0, cleared: 0, exited: 0 }; + const urls: string[] = []; + const stopResults: boolean[] = []; + let nonce: string | undefined; + const processState = { exitCode: 0 }; + const unused = () => { throw new Error("unexpected external I/O in parent stop fixture"); }; + const stopDependencies = { + stopProxyGracefully, + ProxyOwnershipRefusedError, + // The 409 fixture has no message; the imported helper reports null for that body. + lastRefusalMessage: null, + isProcessAlive: () => true, + readRuntimePort: () => ENDPOINT, + killProxy: () => { calls.killed += 1; }, + waitForStoppedPort: async () => {}, + }; + const stopBody = functionSlice(control, "async function stopProxy(", "/** After stop/kill,"); + const stop = new Function(...Object.keys(stopDependencies), + `${transpiler.transformSync(stopBody)}; return stopProxy;`)(...Object.values(stopDependencies)) as ( + pid: number, io: GracefulStopIo + ) => Promise; + const dependencies = { + process: processState, + console: { log() {}, warn() {}, error() {} }, + ProxyOwnershipRefusedError, + STOP_HISTORY_INCOMPLETE_EXIT_CODE, + loadConfig: unused, + listPendingTeardowns: receipts.listPendingTeardowns, + isPendingTeardownAbandoned: unused, + isProcessAlive: unused, + claimPendingTeardown: (...args: Parameters) => { + if (!options.receipt) throw new Error("receipt storage unavailable"); + const claimed = receipts.claimPendingTeardown(...args); + nonce = claimed.nonce; + return claimed; + }, + stopServiceIfInstalledDetailed: () => "absent", + isServiceOwnershipError: () => false, + readPid: () => 4242, + readRuntimePort: () => ENDPOINT, + stopProxy: async (pid: number, io: GracefulStopIo) => { + const result = await stop(pid, { + ...io, + fetchFn: (async (url: string | URL | Request) => { + urls.push(String(url)); + return new Response(JSON.stringify(options.response), { status: options.status ?? 200 }); + }) as typeof fetch, + waitExit: () => { calls.exited += 1; return true; }, + exitTimeoutMs: 1, + env: { OPENCODEX_HOME: home }, + }); + stopResults.push(result); + return result; + }, + removePid() {}, + removeRuntimePort() {}, + revertSystemEnv() {}, + findLiveProxy: unused, + proxyStillLiveAfterStop: unused, + clearPendingTeardown: (value: string) => { calls.cleared += 1; return receipts.clearPendingTeardown(value); }, + pendingTeardownPathFor: receipts.pendingTeardownPathFor, + quarantinePendingTeardown: unused, + restoreNativeCodexAsync: async () => { calls.native += 1; return options.restore; }, + stripGrokConfig: () => { calls.grok += 1; return { ok: true, changed: true, message: "Grok restored" }; }, + }; + const handlers = functionSlice(cli, "async function restoreSharedClientStateAfterStop(", "async function handleUninstall("); + const handleStop = new Function(...Object.keys(dependencies), + `${transpiler.transformSync(handlers)}; return handleStop;`)(...Object.values(dependencies)) as () => Promise; + const result = await handleStop(); + return { result, calls, urls, stopResults, nonce, exitCode: processState.exitCode, + receiptExists: nonce !== undefined && existsSync(receipts.pendingTeardownPathFor(nonce)) }; +} + +describe("parent CLI shared teardown completion", () => { + test("receipt failure and unconfirmed child teardown cause real parent restoration without a kill", async () => { + const outcome = await runParentStop({ receipt: false, + response: { success: false, sharedTeardown: "performed" }, restore: restoreResult(true) }); + expect(outcome.urls).toEqual(["http://127.0.0.1:10100/api/stop"]); + expect(outcome.calls).toMatchObject({ killed: 0, exited: 1, native: 1, grok: 1, cleared: 0 }); + expect(outcome.stopResults).toEqual([false]); + expect(outcome.result).toBe(true); + expect(outcome.exitCode).toBe(0); + }); + + test("a confirmed performed teardown prevents duplicate parent restoration", async () => { + const outcome = await runParentStop({ receipt: false, + response: { success: true, sharedTeardown: "performed" }, restore: restoreResult(true) }); + expect(outcome.stopResults).toEqual([true]); + expect(outcome.calls).toMatchObject({ killed: 0, native: 0, grok: 0 }); + expect(outcome.result).toBe(true); + }); + + test("a failed parent restoration leaves its actual receipt outstanding", async () => { + const outcome = await runParentStop({ receipt: true, + response: { success: false, sharedTeardown: "performed" }, restore: restoreResult(false) }); + expect(outcome.urls[0]).toContain(`teardownNonce=${outcome.nonce}`); + expect(outcome.stopResults).toEqual([false]); + expect(outcome.calls).toMatchObject({ killed: 0, native: 1, grok: 1, cleared: 0 }); + expect(outcome.result).toBe(false); + expect(outcome.exitCode).toBe(1); + expect(outcome.receiptExists).toBe(true); + }); + + test("confirmed deferral leaves restoration and receipt discharge to the parent", async () => { + const outcome = await runParentStop({ receipt: true, + response: { success: true, sharedTeardown: "deferred" }, restore: restoreResult(true) }); + expect(outcome.stopResults).toEqual([true]); + expect(outcome.calls).toMatchObject({ killed: 0, native: 1, grok: 1, cleared: 1 }); + expect(outcome.result).toBe(true); + expect(outcome.receiptExists).toBe(false); + }); + + test("history-only parent failure preserves its distinct exit and discharges restored client state", async () => { + const restore = { ...restoreResult(false), artifacts: { + config: { state: "restored" }, catalog: { state: "restored" }, history: { state: "failed" }, + } } as unknown as CodexNativeRestoreResult; + const outcome = await runParentStop({ receipt: true, + response: { success: false, sharedTeardown: "performed" }, restore }); + expect(outcome.calls).toMatchObject({ killed: 0, native: 1, grok: 1, cleared: 1 }); + expect(outcome.result).toBe(true); + expect(outcome.exitCode).toBe(STOP_HISTORY_INCOMPLETE_EXIT_CODE); + expect(outcome.receiptExists).toBe(false); + }); + + test("a refused stop keeps the parent from restoring or discharging its receipt", async () => { + const outcome = await runParentStop({ receipt: true, status: 409, + response: { success: false }, restore: restoreResult(true) }); + expect(outcome.calls).toMatchObject({ killed: 0, exited: 0, native: 0, grok: 0, cleared: 0 }); + expect(outcome.result).toBe(false); + expect(outcome.exitCode).toBe(1); + expect(outcome.receiptExists).toBe(true); + }); +}); + describe("stopProxyGracefully deferral flag", () => { test("the default stop asks for no deferral", async () => { const urls: string[] = []; @@ -55,7 +202,7 @@ describe("stopProxyGracefully deferral flag", () => { readRuntime: () => ({ port: 10100 }), fetchFn: (async (url: string | URL | Request) => { urls.push(String(url)); - return new Response(JSON.stringify({ success: true }), { status: 200 }); + return new Response(JSON.stringify({ success: true, sharedTeardown: "performed" }), { status: 200 }); }) as typeof fetch, waitExit: () => true, env: {}, @@ -69,7 +216,7 @@ describe("stopProxyGracefully deferral flag", () => { readRuntime: () => ({ port: 10100 }), fetchFn: (async (url: string | URL | Request) => { urls.push(String(url)); - return new Response(JSON.stringify({ success: true }), { status: 200 }); + return new Response(JSON.stringify({ success: true, sharedTeardown: "deferred" }), { status: 200 }); }) as typeof fetch, waitExit: () => true, env: {}, @@ -88,7 +235,7 @@ describe("stopProxyGracefully deferral flag", () => { runtimeEndpoint: { hostname: "127.0.0.1", port: 10100 }, fetchFn: (async (url: string | URL | Request) => { urls.push(String(url)); - return new Response(JSON.stringify({ success: true }), { status: 200 }); + return new Response(JSON.stringify({ success: true, sharedTeardown: "performed" }), { status: 200 }); }) as typeof fetch, waitExit: () => true, env: {}, From 7dc24054614c9454d27dbe0a619ec4a691958f66 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:01:36 +0900 Subject: [PATCH 2/2] test(stop): exercise parent restoration through the real CLI module graph --- tests/fixtures/parent-stop-runner.ts | 99 +++++++++++++++ tests/service/stop-deferred-teardown.test.ts | 120 ++++--------------- 2 files changed, 124 insertions(+), 95 deletions(-) create mode 100644 tests/fixtures/parent-stop-runner.ts diff --git a/tests/fixtures/parent-stop-runner.ts b/tests/fixtures/parent-stop-runner.ts new file mode 100644 index 0000000000..9a26d52845 --- /dev/null +++ b/tests/fixtures/parent-stop-runner.ts @@ -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) => { + 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"); diff --git a/tests/service/stop-deferred-teardown.test.ts b/tests/service/stop-deferred-teardown.test.ts index 74351fb00f..e9d116cfcb 100644 --- a/tests/service/stop-deferred-teardown.test.ts +++ b/tests/service/stop-deferred-teardown.test.ts @@ -1,13 +1,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ProxyOwnershipRefusedError, stopProxyGracefully, type GracefulStopIo } from "../../src/lib/process-control"; +import { stopProxyGracefully } from "../../src/lib/process-control"; import { performStopTeardown } from "../../src/server/stop-teardown"; import type { CodexNativeRestoreResult } from "../../src/codex/inject"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../../src/update/stop-contract.mjs"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { repoPath } from "../helpers/repo-root"; +import { fixturePath, repoPath } from "../helpers/repo-root"; /** * Behavioural cover for the deferred shared teardown (#3008). @@ -48,90 +49,26 @@ function restoreResult(success: boolean): CodexNativeRestoreResult { } as unknown as CodexNativeRestoreResult; } -/** Execute current function bodies with I/O dependencies, without importing the CLI dispatcher. */ -function functionSlice(source: string, start: string, end: string): string { - const from = source.indexOf(start); - const to = source.indexOf(end, from); - if (from < 0 || to <= from) throw new Error(`missing function boundary: ${start}`); - return source.slice(from, to); -} - async function runParentStop(options: { receipt: boolean; response: unknown; restore: CodexNativeRestoreResult; status?: number }) { - const receipts = await import("../../src/config/pending-teardown"); - const cli = readFileSync(repoPath("src", "cli", "index.ts"), "utf8"); - const control = readFileSync(repoPath("src", "lib", "process-control.ts"), "utf8"); - const transpiler = new Bun.Transpiler({ loader: "ts" }); - const calls = { killed: 0, native: 0, grok: 0, cleared: 0, exited: 0 }; - const urls: string[] = []; - const stopResults: boolean[] = []; - let nonce: string | undefined; - const processState = { exitCode: 0 }; - const unused = () => { throw new Error("unexpected external I/O in parent stop fixture"); }; - const stopDependencies = { - stopProxyGracefully, - ProxyOwnershipRefusedError, - // The 409 fixture has no message; the imported helper reports null for that body. - lastRefusalMessage: null, - isProcessAlive: () => true, - readRuntimePort: () => ENDPOINT, - killProxy: () => { calls.killed += 1; }, - waitForStoppedPort: async () => {}, + const child = spawnSync(process.execPath, [fixturePath("parent-stop-runner.ts")], { + cwd: repoPath(), + env: { ...process.env, OPENCODEX_HOME: home }, + input: JSON.stringify(options), + encoding: "utf8", + timeout: 20_000, + windowsHide: true, + }); + expect(child.error, child.stderr).toBeUndefined(); + expect(child.signal, child.stderr).toBeNull(); + const reportPath = join(home, "parent-stop-result.json"); + expect(existsSync(reportPath), child.stderr).toBe(true); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as { + calls: { killed: number; native: number; grok: number; cleared: number; exited: number }; + urls: string[]; nonce?: string; receiptExists: boolean; unexpectedIo: string[]; }; - const stopBody = functionSlice(control, "async function stopProxy(", "/** After stop/kill,"); - const stop = new Function(...Object.keys(stopDependencies), - `${transpiler.transformSync(stopBody)}; return stopProxy;`)(...Object.values(stopDependencies)) as ( - pid: number, io: GracefulStopIo - ) => Promise; - const dependencies = { - process: processState, - console: { log() {}, warn() {}, error() {} }, - ProxyOwnershipRefusedError, - STOP_HISTORY_INCOMPLETE_EXIT_CODE, - loadConfig: unused, - listPendingTeardowns: receipts.listPendingTeardowns, - isPendingTeardownAbandoned: unused, - isProcessAlive: unused, - claimPendingTeardown: (...args: Parameters) => { - if (!options.receipt) throw new Error("receipt storage unavailable"); - const claimed = receipts.claimPendingTeardown(...args); - nonce = claimed.nonce; - return claimed; - }, - stopServiceIfInstalledDetailed: () => "absent", - isServiceOwnershipError: () => false, - readPid: () => 4242, - readRuntimePort: () => ENDPOINT, - stopProxy: async (pid: number, io: GracefulStopIo) => { - const result = await stop(pid, { - ...io, - fetchFn: (async (url: string | URL | Request) => { - urls.push(String(url)); - return new Response(JSON.stringify(options.response), { status: options.status ?? 200 }); - }) as typeof fetch, - waitExit: () => { calls.exited += 1; return true; }, - exitTimeoutMs: 1, - env: { OPENCODEX_HOME: home }, - }); - stopResults.push(result); - return result; - }, - removePid() {}, - removeRuntimePort() {}, - revertSystemEnv() {}, - findLiveProxy: unused, - proxyStillLiveAfterStop: unused, - clearPendingTeardown: (value: string) => { calls.cleared += 1; return receipts.clearPendingTeardown(value); }, - pendingTeardownPathFor: receipts.pendingTeardownPathFor, - quarantinePendingTeardown: unused, - restoreNativeCodexAsync: async () => { calls.native += 1; return options.restore; }, - stripGrokConfig: () => { calls.grok += 1; return { ok: true, changed: true, message: "Grok restored" }; }, - }; - const handlers = functionSlice(cli, "async function restoreSharedClientStateAfterStop(", "async function handleUninstall("); - const handleStop = new Function(...Object.keys(dependencies), - `${transpiler.transformSync(handlers)}; return handleStop;`)(...Object.values(dependencies)) as () => Promise; - const result = await handleStop(); - return { result, calls, urls, stopResults, nonce, exitCode: processState.exitCode, - receiptExists: nonce !== undefined && existsSync(receipts.pendingTeardownPathFor(nonce)) }; + expect(report.unexpectedIo, child.stderr).toEqual([]); + expect(report.urls, child.stderr).toHaveLength(1); + return { ...report, exitCode: child.status, stderr: child.stderr }; } describe("parent CLI shared teardown completion", () => { @@ -140,26 +77,21 @@ describe("parent CLI shared teardown completion", () => { response: { success: false, sharedTeardown: "performed" }, restore: restoreResult(true) }); expect(outcome.urls).toEqual(["http://127.0.0.1:10100/api/stop"]); expect(outcome.calls).toMatchObject({ killed: 0, exited: 1, native: 1, grok: 1, cleared: 0 }); - expect(outcome.stopResults).toEqual([false]); - expect(outcome.result).toBe(true); expect(outcome.exitCode).toBe(0); }); test("a confirmed performed teardown prevents duplicate parent restoration", async () => { const outcome = await runParentStop({ receipt: false, response: { success: true, sharedTeardown: "performed" }, restore: restoreResult(true) }); - expect(outcome.stopResults).toEqual([true]); expect(outcome.calls).toMatchObject({ killed: 0, native: 0, grok: 0 }); - expect(outcome.result).toBe(true); + expect(outcome.exitCode).toBe(0); }); test("a failed parent restoration leaves its actual receipt outstanding", async () => { const outcome = await runParentStop({ receipt: true, response: { success: false, sharedTeardown: "performed" }, restore: restoreResult(false) }); expect(outcome.urls[0]).toContain(`teardownNonce=${outcome.nonce}`); - expect(outcome.stopResults).toEqual([false]); expect(outcome.calls).toMatchObject({ killed: 0, native: 1, grok: 1, cleared: 0 }); - expect(outcome.result).toBe(false); expect(outcome.exitCode).toBe(1); expect(outcome.receiptExists).toBe(true); }); @@ -167,9 +99,8 @@ describe("parent CLI shared teardown completion", () => { test("confirmed deferral leaves restoration and receipt discharge to the parent", async () => { const outcome = await runParentStop({ receipt: true, response: { success: true, sharedTeardown: "deferred" }, restore: restoreResult(true) }); - expect(outcome.stopResults).toEqual([true]); expect(outcome.calls).toMatchObject({ killed: 0, native: 1, grok: 1, cleared: 1 }); - expect(outcome.result).toBe(true); + expect(outcome.exitCode).toBe(0); expect(outcome.receiptExists).toBe(false); }); @@ -180,18 +111,17 @@ describe("parent CLI shared teardown completion", () => { const outcome = await runParentStop({ receipt: true, response: { success: false, sharedTeardown: "performed" }, restore }); expect(outcome.calls).toMatchObject({ killed: 0, native: 1, grok: 1, cleared: 1 }); - expect(outcome.result).toBe(true); expect(outcome.exitCode).toBe(STOP_HISTORY_INCOMPLETE_EXIT_CODE); expect(outcome.receiptExists).toBe(false); }); test("a refused stop keeps the parent from restoring or discharging its receipt", async () => { const outcome = await runParentStop({ receipt: true, status: 409, - response: { success: false }, restore: restoreResult(true) }); + response: { success: false, message: "Run the stop outside the installed service." }, restore: restoreResult(true) }); expect(outcome.calls).toMatchObject({ killed: 0, exited: 0, native: 0, grok: 0, cleared: 0 }); - expect(outcome.result).toBe(false); expect(outcome.exitCode).toBe(1); expect(outcome.receiptExists).toBe(true); + expect(outcome.stderr).toContain("Run the stop outside the installed service."); }); });