-
Notifications
You must be signed in to change notification settings - Fork 1.1k
wp7: report the actual reason a proxy refused to stop #4067
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -71,7 +71,25 @@ export function gracefulStopHost(hostname: string | undefined): string { | |
| */ | ||
| export type GracefulStopResult = boolean | "refused"; | ||
|
|
||
| /** A proxy declined shutdown because a service under another home owns it (HTTP 409). */ | ||
| /** | ||
| * The server's own explanation for the most recent 409, captured so `stopProxy` can report | ||
| * the real reason. There is more than one: a scheduler wrapper under another home, or the | ||
| * proxy being the installed service itself (#4023). Module-scoped because | ||
| * `GracefulStopResult` is a public contract with several callers, and widening it to carry | ||
| * the text would change every one of them for a message only this file reports. | ||
| */ | ||
| let lastRefusalMessage: string | null = null; | ||
|
|
||
| /** The server's explanation for the most recent 409, or `null` when it sent none. */ | ||
| export function lastStopRefusalMessage(): string | null { | ||
| return lastRefusalMessage; | ||
| } | ||
|
|
||
| /** | ||
| * A proxy declined shutdown (HTTP 409). There is more than one reason it can say no — a | ||
| * scheduler wrapper under another home, or the proxy being the installed service itself | ||
| * (#4023) — so the server's own message is carried through rather than guessed at. | ||
| */ | ||
| export class ProxyOwnershipRefusedError extends Error {} | ||
|
|
||
| /** | ||
|
|
@@ -111,7 +129,15 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): | |
| // would respawn it anyway). That is a policy answer, not a dead endpoint — escalating to | ||
| // SIGTERM here would run the daemon's cleanup and strip shared config out from under the | ||
| // still-running service. Report the refusal instead of forcing. | ||
| if (res.status === 409) return "refused"; | ||
| if (res.status === 409) { | ||
| lastRefusalMessage = await res.json() | ||
| .then(body => { | ||
| const message = (body as { message?: unknown } | null)?.message; | ||
| return typeof message === "string" && message.trim() ? message.trim() : null; | ||
| }) | ||
| .catch(() => null); | ||
| return "refused"; | ||
| } | ||
| if (!res.ok) return false; | ||
| } catch { | ||
| return false; | ||
|
|
@@ -140,8 +166,9 @@ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<b | |
| // The proxy refused on purpose (foreign service owns it). Forcing would strip shared | ||
| // config while that service keeps the proxy alive. | ||
| throw new ProxyOwnershipRefusedError( | ||
| "The running proxy refused to stop: a service installed under a different " | ||
| + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.", | ||
| lastRefusalMessage | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two Useful? React with 👍 / 👎. |
||
| ?? "The running proxy refused to stop: a service installed under a different " | ||
| + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.", | ||
| ); | ||
| } | ||
| if (graceful) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { gracefulStopHost, stopProxyGracefully } from "../../src/lib/process-control"; | ||
| import { gracefulStopHost, lastStopRefusalMessage, stopProxyGracefully } from "../../src/lib/process-control"; | ||
|
|
||
| function okResponse(): Response { | ||
| return new Response(JSON.stringify({ success: true }), { status: 200 }); | ||
|
|
@@ -107,3 +107,38 @@ describe("stopProxyGracefully", () => { | |
| expect(noExit).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("409 refusal reporting", () => { | ||
| test("a refusal carries the server's own reason, not the ownership guess", async () => { | ||
| // /api/stop answers 409 for more than one reason: a scheduler wrapper under another | ||
| // home, and (since #4023) the proxy being the installed launchd/systemd job itself. | ||
| // stopProxy used to report the first of those unconditionally, sending an operator | ||
| // whose proxy is simply the service to a CODEX_HOME that does not exist. | ||
| const selfUnload = "This proxy is running as the installed service, so stopping the manager" | ||
| + " from inside it would end this process before native Codex is restored." | ||
| + " Run `ocx stop`, which stops the service from outside and completes the restore." | ||
| + " Nothing was changed."; | ||
| const result = await stopProxyGracefully(7, { | ||
| readRuntime: () => ({ port: 10100 }), | ||
| fetchFn: (async () => new Response( | ||
| JSON.stringify({ success: false, code: "self_unload_service", message: selfUnload }), | ||
| { status: 409, headers: { "content-type": "application/json" } }, | ||
| )) as typeof fetch, | ||
| waitExit: () => true, | ||
| env: {}, | ||
| }); | ||
| expect(result).toBe("refused"); | ||
| expect(lastStopRefusalMessage()).toBe(selfUnload); | ||
|
Comment on lines
+130
to
+131
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Cover the thrown error path. These assertions verify only As per path instructions, behavior changes in 🤖 Prompt for AI AgentsSource: Path instructions |
||
| }); | ||
|
|
||
| test("a 409 with no readable body falls back rather than reporting a stale reason", async () => { | ||
| const result = await stopProxyGracefully(7, { | ||
| readRuntime: () => ({ port: 10100 }), | ||
| fetchFn: (async () => new Response("not json", { status: 409 })) as typeof fetch, | ||
| waitExit: () => true, | ||
| env: {}, | ||
| }); | ||
| expect(result).toBe("refused"); | ||
| expect(lastStopRefusalMessage()).toBeNull(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -509,7 +509,18 @@ describe("POST /api/stop teardown", () => { | |
| test("a 409 does not escalate to a forced kill", () => { | ||
| // Escalating would run the daemon's cleanup and strip shared config while the foreign | ||
| // service keeps the proxy alive — the exact hole the ownership gate exists to close. | ||
| expect(PROCESS_CONTROL_SOURCE).toContain('if (res.status === 409) return "refused"'); | ||
| // The 409 branch may capture the server's reason first (#4023 added a second refusal | ||
| // cause), but it must still return "refused" without falling through to !res.ok. | ||
| const stopGracefully = sliceFn( | ||
| PROCESS_CONTROL_SOURCE, | ||
| "export async function stopProxyGracefully(", | ||
| "export async function stopProxy(", | ||
| ); | ||
| const four09At = stopGracefully.indexOf("res.status === 409"); | ||
| expect(four09At).toBeGreaterThan(-1); | ||
| expect(stopGracefully.slice(four09At)).toContain('return "refused"'); | ||
| expect(stopGracefully.indexOf('return "refused"', four09At)) | ||
| .toBeLessThan(stopGracefully.indexOf("if (!res.ok) return false;", four09At)); | ||
|
Comment on lines
+521
to
+523
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Assert the 409 behavior at runtime. This check scans raw source text instead of calling Add or use a focused test in 🤖 Prompt for AI Agents |
||
|
|
||
| const stopProxyFn = sliceFn(PROCESS_CONTROL_SOURCE, "export async function stopProxy(", "export function killProxy("); | ||
| const refusedAt = stopProxyFn.indexOf('graceful === "refused"'); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🤖 get_repo_knowledge executed:
get_repo_knowledge lidge-jun/opencodex /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732Length of output: 4014
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 10431
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 15570
🤖 get_repo_knowledge executed:
get_repo_knowledge lidge-jun/opencodex /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732Length of output: 3988
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 26241
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 14342
Keep the refusal reason per stop operation.
The exported
stopProxyGracefullyhas no single-flight guard. Concurrent calls can both receive HTTP 409 responses at lines 132–139, and the module-scopedlastRefusalMessageat line 81 can be overwritten beforestopProxyreads it at lines 168–172. The first caller can then throwProxyOwnershipRefusedErrorwith the second caller’s reason. Return the refusal message through a private per-call result while preserving the publicGracefulStopResultcontract, or enforce single-flight execution.🧰 Tools
🪛 ast-grep (0.45.2)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFileSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents