Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d35592b
merge dev into main for the v2.32.1 release
lidge-jun Aug 25, 2026
71c57ea
release: v2.32.1
lidge-jun Aug 25, 2026
d560ac6
merge dev into main for the v2.33.0 release
lidge-jun Aug 25, 2026
08ada6f
Merge pull request #2553 from lidge-jun/codex/promote-main-2330
lidge-jun Aug 25, 2026
ec51e42
release: v2.33.0
lidge-jun Aug 25, 2026
e25b653
merge dev into main for the v2.34.0 release
lidge-jun Aug 27, 2026
80fff9a
Merge pull request #2760 from lidge-jun/codex/promote-main-2340
lidge-jun Aug 27, 2026
fc4de77
Merge pull request #2826 from lidge-jun/codex/promote-main-2350
lidge-jun Aug 28, 2026
c7d8407
Merge pull request #3002 from lidge-jun/codex/promote-main-2360
lidge-jun Aug 30, 2026
54e2274
Merge pull request #3037 from lidge-jun/codex/promote-main-2370
lidge-jun Aug 31, 2026
2c4dca1
merge dev into the promotion branch for v2.38.0
lidge-jun Aug 31, 2026
a34e8b7
merge dev into the promotion branch for v2.38.0 (picks up the ReDoS fix)
lidge-jun Aug 31, 2026
ebb4d55
Merge pull request #3073 from lidge-jun/codex/promote-main-2380
lidge-jun Aug 31, 2026
682112e
Merge remote-tracking branch 'origin/dev' into codex/promote-main-2390
lidge-jun Sep 1, 2026
af6113a
merge dev into main for the v2.39.0 release
lidge-jun Sep 1, 2026
847f4f1
merge dev into main for the v2.40.0 release
Sep 2, 2026
ac78647
Merge pull request #3261 from lidge-jun/codex/promote-main-2400
lidge-jun Sep 2, 2026
aaa9eaf
fix(release): pass the bump job's permissions through the reusable-wo…
lidge-jun Sep 2, 2026
35ff3a4
Merge pull request #3263 from lidge-jun/codex/promote-main-2400-relfix
lidge-jun Sep 2, 2026
1664ab5
fix(stop): validate shared teardown response
luvs01 Sep 3, 2026
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
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ jobs:
bump-dev-version:
needs: publish
if: ${{ inputs.dry-run != true }}
# A reusable-workflow CALL cannot grant the callee more than the calling job holds,
# and GitHub refuses the whole run at startup when the called workflow's own job
# declares permissions the caller did not pass down ("startup_failure", runs
# 33615174183 / 33615177849 — the first dispatches since #3129 wired this call).
# The callee's job declares exactly these two; nothing else in this file gains them.
permissions:
contents: write
pull-requests: write
uses: ./.github/workflows/dev-version-bump.yml
with:
released-version: v${{ inputs.version }}
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.
* Outcome of a graceful stop attempt. The string results are distinct from transport failure:
* the proxy answered and is stopping, so callers must not escalate to a forced kill.
*/
export type GracefulStopResult = boolean | "refused";
export type GracefulStopResult = boolean | "refused" | "teardown-unconfirmed";

/** A proxy declined shutdown because a service under another home owns it (HTTP 409). */
export class ProxyOwnershipRefusedError extends Error {}
Expand All @@ -92,6 +92,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 All @@ -113,14 +114,23 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
// still-running service. Report the refusal instead of forcing.
if (res.status === 409) return "refused";
if (!res.ok) return false;
const body: unknown = await res.json().catch(() => null);
const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed";
sharedTeardownConfirmed = !!body
&& typeof body === "object"
&& "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 @@ -144,6 +154,13 @@ 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") {
// The proxy accepted the request and exited, but did not explicitly confirm that its
// assigned shared teardown succeeded. Do not hard-kill an already stopping process;
// return false so the caller conservatively retries and reports restoration failures.
await waitForStoppedPort(runtime, pid);
return false;
}
if (graceful) {
await waitForStoppedPort(runtime, pid);
return true;
Expand Down
2 changes: 1 addition & 1 deletion tests/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
3 changes: 3 additions & 0 deletions tests/grok-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,9 +505,12 @@ describe("POST /api/stop teardown", () => {

const stopProxyFn = sliceFn(PROCESS_CONTROL_SOURCE, "export async function stopProxy(", "export function killProxy(");
const refusedAt = stopProxyFn.indexOf('graceful === "refused"');
const unconfirmedAt = stopProxyFn.indexOf('graceful === "teardown-unconfirmed"');
const killAt = stopProxyFn.indexOf("killProxy(pid)");
expect(refusedAt).toBeGreaterThan(-1);
expect(refusedAt).toBeLessThan(killAt);
expect(unconfirmedAt).toBeGreaterThan(-1);
expect(unconfirmedAt).toBeLessThan(killAt);
expect(stopProxyFn).toContain("throw new ProxyOwnershipRefusedError(");
});
});
31 changes: 30 additions & 1 deletion tests/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, 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 Down Expand Up @@ -106,4 +106,33 @@ describe("stopProxyGracefully", () => {
});
expect(noExit).toBe(false);
});

test("does not treat process exit as proof that shared teardown succeeded", async () => {
for (const body of [
{ success: false, sharedTeardown: "performed" },
{ success: true },
]) {
const result = await stopProxyGracefully(7, {
readRuntime: () => ({ port: 10100 }),
fetchFn: (async () => new Response(JSON.stringify(body), { status: 200 })) as typeof fetch,
waitExit: () => true,
env: {},
});
expect(result).toBe("teardown-unconfirmed");
}
});

test("accepts an explicitly confirmed deferred teardown", async () => {
const result = await stopProxyGracefully(7, {
readRuntime: () => ({ port: 10100 }),
fetchFn: (async () => new Response(JSON.stringify({
success: true,
sharedTeardown: "deferred",
}), { status: 200 })) as typeof fetch,
waitExit: () => true,
env: {},
deferSharedTeardownNonce: "owned-receipt",
});
expect(result).toBe(true);
});
});
6 changes: 3 additions & 3 deletions tests/stop-deferred-teardown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,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: {},
Expand All @@ -67,7 +67,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: {},
Expand All @@ -86,7 +86,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: {},
Expand Down
Loading