-
Notifications
You must be signed in to change notification settings - Fork 595
fix(codex): warn when a startup write leaves an app-server stale (#1046) #1072
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 |
|---|---|---|
|
|
@@ -11,8 +11,10 @@ import { | |
| isWindowsCodexCandidateCommandLine, | ||
| listCodexAppServerProcesses, | ||
| listWindowsSnapshots, | ||
| resetCodexAppServerCatalogStateCache, | ||
| restartCodexAppServers, | ||
| STALE_CODEX_APP_SERVER_HINT, | ||
| warnIfStaleCodexAppServersAfterStartupWrite, | ||
| WINDOWS_CODEX_BASENAME_CANDIDATE_RE, | ||
| } from "../src/codex/app-server-processes"; | ||
|
|
||
|
|
@@ -500,3 +502,119 @@ describe("Windows Win32_Process owner enumeration (#476)", () => { | |
| { timeout: 35_000 }, | ||
| ); | ||
| }); | ||
|
|
||
| /* | ||
| * #1046. Service startup rewrites the catalog and the models cache while an | ||
| * app-server that booted earlier keeps its own in-memory model list, so the | ||
| * picker shows a roster that no longer exists on disk. The startup path warns; | ||
| * it must never signal, because a boot is not a user consenting to have an | ||
| * in-flight turn interrupted. | ||
| */ | ||
| describe("warnIfStaleCodexAppServersAfterStartupWrite (#1046)", () => { | ||
| const APP_SERVER_CMD = "/usr/local/bin/codex app-server"; | ||
| const collectErrors = () => { | ||
| const errors: string[] = []; | ||
| return { log: { error: (m?: unknown) => { errors.push(String(m)); } }, errors }; | ||
| }; | ||
|
|
||
| test("warns when an app-server predates the catalog write", () => { | ||
| const { log, errors } = collectErrors(); | ||
| const result = warnIfStaleCodexAppServersAfterStartupWrite({ | ||
| log, | ||
| io: { | ||
| listSnapshots: () => [{ pid: 4242, commandLine: APP_SERVER_CMD }], | ||
| readStartMs: () => 1_000, | ||
| catalogMtimeMs: () => 2_000, | ||
| }, | ||
| }); | ||
| expect(result.warned).toBe(true); | ||
| expect(errors).toHaveLength(1); | ||
| expect(errors[0]).toContain("4242"); | ||
| }); | ||
|
|
||
| test("stays quiet for fresh, not_running, and unknown", () => { | ||
| const fresh = collectErrors(); | ||
| expect(warnIfStaleCodexAppServersAfterStartupWrite({ | ||
| log: fresh.log, | ||
| io: { | ||
| listSnapshots: () => [{ pid: 1, commandLine: APP_SERVER_CMD }], | ||
| readStartMs: () => 3_000, | ||
| catalogMtimeMs: () => 1_000, | ||
| }, | ||
| }).warned).toBe(false); | ||
|
|
||
| const none = collectErrors(); | ||
| expect(warnIfStaleCodexAppServersAfterStartupWrite({ | ||
| log: none.log, | ||
| io: { listSnapshots: () => [], catalogMtimeMs: () => 1_000 }, | ||
| }).warned).toBe(false); | ||
|
|
||
| const unknown = collectErrors(); | ||
| expect(warnIfStaleCodexAppServersAfterStartupWrite({ | ||
| log: unknown.log, | ||
| io: { | ||
| listSnapshots: () => [{ pid: 2, commandLine: APP_SERVER_CMD }], | ||
| readStartMs: () => null, | ||
| catalogMtimeMs: () => 1_000, | ||
| }, | ||
| }).warned).toBe(false); | ||
|
|
||
| expect([...fresh.errors, ...none.errors, ...unknown.errors]).toEqual([]); | ||
| }); | ||
|
|
||
| /* | ||
| * The masking risk this layer has to defend against, stated honestly. | ||
| * | ||
| * `collectCodexAppServerCatalogState` memoizes for 5s, but ONLY when every io | ||
| * field is defaulted (`fullyDefault`). That has two consequences: | ||
| * | ||
| * - Production startup runs on the default path, so a `fresh` reading taken | ||
| * before the catalog write CAN be replayed after it, and the helper drops the | ||
| * memo first for exactly that reason. | ||
| * - Any test that injects io bypasses the cache, so it cannot reproduce the | ||
| * masking and would pass with or without the reset. Writing one anyway would | ||
| * be a test that looks like proof and is not. | ||
| * | ||
| * So this asserts the mechanism the fix depends on — that a defaulted read is | ||
| * memoized and an explicit invalidation clears it — rather than pretending to | ||
| * exercise a path the seam makes unreachable. The helper's own call to the | ||
| * invalidation is verified by reading it, not by a test that cannot fail. | ||
| */ | ||
| test("a defaulted read is memoized, and invalidation is what clears it", () => { | ||
| resetCodexAppServerCatalogStateCache(); | ||
| const first = collectCodexAppServerCatalogState(); | ||
| const second = collectCodexAppServerCatalogState(); | ||
|
Comment on lines
+585
to
+586
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.
On slower Windows/macOS CI this new test can become flaky because it calls the real default collector twice and asserts object identity. The collector records the cache timestamp before the cold process scan and only reuses the memo for 5s; its own default path can spend up to ~8s+5s on Windows or ~5s+3s on macOS, so by the time AGENTS.md reference: AGENTS.md:L136-L137 Useful? React with 👍 / 👎. |
||
| // Same object identity: the second call served the memo rather than recomputing. | ||
| expect(second).toBe(first); | ||
|
|
||
| resetCodexAppServerCatalogStateCache(); | ||
| expect(collectCodexAppServerCatalogState()).not.toBe(first); | ||
| }); | ||
|
|
||
| /* | ||
| * The assertion that would catch a future refactor pointing startup at | ||
| * `afterCatalogWriteHandleAppServers({ restart: true })`, which SIGTERMs matching | ||
| * processes and says so in its own log line. | ||
| */ | ||
| test("never signals a process", () => { | ||
| const killed: number[] = []; | ||
| warnIfStaleCodexAppServersAfterStartupWrite({ | ||
| io: { | ||
| listSnapshots: () => [{ pid: 999, commandLine: APP_SERVER_CMD }], | ||
| readStartMs: () => 1_000, | ||
| catalogMtimeMs: () => 2_000, | ||
| kill: pid => { killed.push(pid); }, | ||
| }, | ||
| }); | ||
| expect(killed).toEqual([]); | ||
| }); | ||
|
|
||
| test("a discovery failure is swallowed so startup still comes up", () => { | ||
| const { log, errors } = collectErrors(); | ||
| expect(warnIfStaleCodexAppServersAfterStartupWrite({ | ||
| log, | ||
| io: { listSnapshots: () => { throw new Error("ps unavailable"); } }, | ||
| }).warned).toBe(false); | ||
| expect(errors).toEqual([]); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -180,9 +180,9 @@ describe("the startup gate", () => { | |
| const ran = await syncCodexOnStartIfEnabled( | ||
| 10100, | ||
| { clientIntegrations: { codex: false } }, | ||
| async () => { calls += 1; }, | ||
| async () => { calls += 1; return undefined; }, | ||
| ); | ||
| expect(ran).toBe(false); | ||
| expect(ran.ran).toBe(false); | ||
| expect(calls).toBe(0); | ||
| }); | ||
|
|
||
|
|
@@ -192,16 +192,16 @@ describe("the startup gate", () => { | |
| const ran = await syncCodexOnStartIfEnabled( | ||
| 10100, | ||
| { clientIntegrations }, | ||
| async () => { calls += 1; }, | ||
| async () => { calls += 1; return undefined; }, | ||
| ); | ||
| expect(ran).toBe(true); | ||
| expect(ran.ran).toBe(true); | ||
| expect(calls).toBe(1); | ||
| } | ||
| }); | ||
|
|
||
| test("the port reaches the sync", async () => { | ||
| const ports: number[] = []; | ||
| await syncCodexOnStartIfEnabled(43210, {}, async port => { ports.push(port); }); | ||
| await syncCodexOnStartIfEnabled(43210, {}, async port => { ports.push(port); return undefined; }); | ||
|
Comment on lines
+195
to
+204
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 | 🟠 Major | ⚡ Quick win Add positive write-outcome coverage. These tests only cover Without this test, a false-valued mapping can disable the startup warning gate while the changed tests still pass. As per path instructions, “A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.” 🤖 Prompt for AI AgentsSource: Path instructions |
||
| expect(ports).toEqual([43210]); | ||
| }); | ||
|
|
||
|
|
@@ -216,7 +216,10 @@ describe("the startup gate", () => { | |
| {}, | ||
| async () => { throw new Error("provider unreachable"); }, | ||
| ); | ||
| expect(ran).toBe(true); | ||
| expect(ran.ran).toBe(true); | ||
| // #1046: a failed sync reports no writes, so the caller does not warn. | ||
| expect(ran.catalogWritten).toBe(false); | ||
| expect(ran.cacheSynced).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
|
|
||
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.
When startup is driven through
ocx ensure, this warning only runs inside the detachedocx startchild, which is spawned withstdio: "ignore"; the parent then returns as soon as/healthzresponds and runs its ownsyncModelsToCodex(port)without any stale-app-server handler. In that shim/autostart path the warning is either discarded or can run before the parent writes the final catalog, so users can still be left with a stale Codex picker and no actionable message. Mirror this post-write warning around the parent-side ensure sync, or avoid the duplicate parent write.Useful? React with 👍 / 👎.