Skip to content
Closed
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
19 changes: 14 additions & 5 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,7 @@ async function handleStop() {
// restart-window wait; launchd, systemd and WinSW are down when they say so.
let schedulerCanRespawn = false;
let stoppedService = false;
let nativeRestoreHandledByProxy = false;
// An ownership mismatch means the service manager was never even contacted: the installed
// service is still live and will respawn the proxy. Tearing down SHARED state in that
// situation (native Codex config, the Grok fence) removes config out from under a running
Expand Down Expand Up @@ -805,17 +806,22 @@ async function handleStop() {
* guessed one fails closed into manual recovery rather than letting a later probe read
* "the configured port refuses" as proof that the right proxy is down.
*/
const stopWithDeferral = async (pid: number, discovered?: { hostname: string; port: number } | null): Promise<void> => {
const stopWithDeferral = async (pid: number, discovered?: { hostname: string; port: number } | null): Promise<boolean> => {
// Resolve ONCE. Reading the runtime record twice let the receipt name the configured
// guess while the request went to a runtime endpoint that appeared in between.
const exact = discovered ?? endpointOf(readRuntimePort(pid));
claimTeardown(exact ?? configuredEndpoint(), exact ? "exact" : "guessed");
await stopProxy(pid, {
const graceful = await stopProxy(pid, {
deferSharedTeardownNonce: teardownNonce,
// Only an exact endpoint may direct the request; the configured fallback is a guess
// good enough to record an obligation against, not to POST a stop to.
runtimeEndpoint: exact ?? undefined,
});
// A valid receipt means the proxy deferred shared teardown to this process. If the
// receipt could not be written, the proxy restores it itself and the caller must not
// attempt a second restore after a graceful stop. A hard-kill always leaves restore
// to this process.
return graceful && !teardownNonce;
};
try {
const serviceStop = stopServiceIfInstalledDetailed();
Expand Down Expand Up @@ -858,7 +864,7 @@ async function handleStop() {
// verification below, so a survivor does not get its client config pulled first.
// The receipt goes down first — the proxy honours the deferral only when it can
// see one, so an unrecordable claim degrades to the child doing its own teardown.
await stopWithDeferral(pid);
nativeRestoreHandledByProxy = await stopWithDeferral(pid);
console.log(`✅ Proxy (PID ${pid}) stopped.`);
removePid(pid);
removeRuntimePort(pid);
Expand Down Expand Up @@ -888,7 +894,10 @@ async function handleStop() {
try {
// The probe already found where it answers, and on this path the runtime record is
// typically what went missing in the first place.
await stopWithDeferral(live.pid, { hostname: live.hostname ?? "127.0.0.1", port: live.port });
nativeRestoreHandledByProxy = await stopWithDeferral(
live.pid,
{ hostname: live.hostname ?? "127.0.0.1", port: live.port },
);
console.log(`✅ Proxy (PID ${live.pid}) stopped.`);
} catch (err) {
stopFailed = true;
Expand Down Expand Up @@ -1006,7 +1015,7 @@ async function handleStop() {
console.error(" The obligation is preserved; retry once the proxy is confirmed stopped.");
}
}
const restoreBlocked = ownershipBlocked || inheritedBlocks;
const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy;
if (!restoreBlocked) {
if (recoveredNonces.length > 0) {
// A previous deferred stop died before restoring, and the probe says its endpoint is
Expand Down
7 changes: 4 additions & 3 deletions src/lib/process-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ function drainDeadlineMs(): number {
}

/** Graceful-first stop: management-API drain, then the platform kill ladder. */
export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<void> {
if (!isProcessAlive(pid)) return;
export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<boolean> {
if (!isProcessAlive(pid)) return false;
const runtime = io.runtimeEndpoint ?? readRuntimePort(pid);
const graceful = await stopProxyGracefully(pid, io);
if (graceful === "refused") {
Expand All @@ -146,10 +146,11 @@ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<v
}
if (graceful) {
await waitForStoppedPort(runtime, pid);
return;
return true;
}
killProxy(pid);
await waitForStoppedPort(runtime, pid);
return false;
}

/** After stop/kill, wait for the former listen port to become bindable (Windows drain). */
Expand Down
12 changes: 10 additions & 2 deletions tests/grok-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe("Grok fence lifecycle wiring", () => {
expect(stopFn).toContain("ownershipBlocked = true");
// Ownership is now one of two reasons to skip the restore; the other is an inherited
// obligation whose proxy could not be confirmed down (#3008).
expect(stopFn).toContain("const restoreBlocked = ownershipBlocked ||");
expect(stopFn).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy;");
expect(stopFn).toContain("if (!restoreBlocked) {");
expect(stopFn).toContain("await restoreSharedClientStateAfterStop()");
expect(restoreFn).toContain("restoreNativeCodexAsync()");
Expand All @@ -106,6 +106,14 @@ describe("Grok fence lifecycle wiring", () => {
expect(stopFn.indexOf("revertSystemEnv()")).toBeLessThan(stopFn.indexOf("if (!restoreBlocked) {"));
});

test("graceful stop skips caller restore only when the proxy performed it", () => {
const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall(");
expect(stopFn).toContain("const graceful = await stopProxy(pid, {");
expect(stopFn).toContain("return graceful && !teardownNonce;");
expect(stopFn).toContain("nativeRestoreHandledByProxy = await stopWithDeferral(pid);");
expect(stopFn).toContain("nativeRestoreHandledByProxy = await stopWithDeferral(");
});

test("a refused Grok strip makes ocx stop fail instead of reporting success", () => {
const restoreFn = sliceFn(CLI_SOURCE, "async function restoreSharedClientStateAfterStop(", "async function handleStop(");
const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall(");
Expand Down Expand Up @@ -261,7 +269,7 @@ describe("Grok fence lifecycle wiring", () => {
expect(stopFn).toContain("teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces");
// The orphan path hands over the endpoint the probe already found; its runtime record
// is typically what went missing in the first place.
expect(stopFn).toContain('stopWithDeferral(live.pid, { hostname: live.hostname ?? "127.0.0.1", port: live.port })');
expect(stopFn).toContain('nativeRestoreHandledByProxy = await stopWithDeferral(\n live.pid,\n { hostname: live.hostname ?? "127.0.0.1", port: live.port },\n );');
// A live proxy with no killable pid is not "no proxy found": purging state and
// restoring over it is the same failure arrived at from the other direction.
expect(stopFn).toContain("} else if (live) {");
Expand Down
Loading