From d23419bea2f85935883aa051d3e5c46df1c41e28 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 20:39:58 +0900 Subject: [PATCH 1/2] fix(grok): recheck admission after startup catalog discovery --- src/cli/ensure-desired-integrations.ts | 7 +-- src/cli/index.ts | 5 +- src/grok/sync.ts | 11 ++++- structure/runtime.md | 2 +- .../ensure-desired-integrations-race.test.ts | 19 ++++++++ tests/providers/xai/grok-sync.test.ts | 46 +++++++++++++++++++ 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index d3804992fe4..9b160f0cf31 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -48,7 +48,7 @@ export interface EnsureDesiredIntegrationsDeps { syncGrokConfig: ( port: number, config: OcxConfig, - opts?: { hostname?: string; probeHostname?: string }, + opts?: { hostname?: string; probeHostname?: string; admit?: () => boolean }, ) => Promise; removeDesktop3pStandardPivot: typeof removeDesktop3pStandardPivot; removeDesktopFirstParty?: typeof removeDesktopFirstParty; @@ -67,7 +67,7 @@ export interface EnsureDesiredIntegrationsDeps { async function defaultSyncGrokConfig( port: number, config: OcxConfig, - opts: { hostname?: string; probeHostname?: string } = {}, + opts: { hostname?: string; probeHostname?: string; admit?: () => boolean } = {}, ): Promise { const { syncGrokConfig } = await import("../grok/sync"); return syncGrokConfig(port, config, opts); @@ -133,7 +133,8 @@ export async function ensureGrokFenceMatchesDesired( port, config, { ...(hostname !== undefined ? { hostname } : {}), - ...(opts.probeHostname !== undefined ? { probeHostname: opts.probeHostname } : {}) }, + ...(opts.probeHostname !== undefined ? { probeHostname: opts.probeHostname } : {}), + admit: () => shouldSyncGrokOnStart(deps.loadConfig()) }, ); if (g.changed) log(" + Grok Build config updated (~/.grok/config.toml)"); else if (!g.ok) error(`⚠️ ${g.message}`); diff --git a/src/cli/index.ts b/src/cli/index.ts index d1439754154..f34682260e1 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -780,7 +780,10 @@ async function handleStart(options: { block?: boolean } = {}) { // one restart, because the toggle removed the fence and start wrote it back. if (shouldSyncGrokOnStart(config)) try { const { syncGrokConfig } = await import("../grok/sync"); - const r = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}); + const r = await syncGrokConfig(port, config, { + ...(config.hostname ? { hostname: config.hostname } : {}), + admit: () => !shuttingDown && !cleaned && shouldSyncGrokOnStart(loadConfig()), + }); if (r.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); else if (!r.ok) console.error(`⚠️ ${r.message}`); } catch (err) { diff --git a/src/grok/sync.ts b/src/grok/sync.ts index 46571b030d4..fbd76ef7f3e 100644 --- a/src/grok/sync.ts +++ b/src/grok/sync.ts @@ -32,9 +32,15 @@ async function defaultFetchAllModels(config: OcxConfig): Promise export async function syncGrokConfig( port: number, config: OcxConfig, - opts: { hostname?: string; probeHostname?: string; grokHome?: string; refreshOnly?: { admit: () => boolean } } = {}, + opts: { hostname?: string; probeHostname?: string; grokHome?: string; refreshOnly?: { admit: () => boolean }; + /** Recheck caller-owned desired state/lifecycle after asynchronous catalog work. */ + admit?: () => boolean } = {}, deps: GrokSyncDeps = { fetchAllModels: defaultFetchAllModels, injectGrokConfig }, ): Promise { + const skipped = (): GrokInjectResult => ({ + ok: true, changed: false, message: "Grok config sync skipped: caller admission is no longer current.", + }); + if (opts.admit && !opts.admit()) return skipped(); let projection: ReturnType; try { const allRouted = await deps.fetchAllModels(config); @@ -54,6 +60,9 @@ export async function syncGrokConfig( unauthenticatedLoopbackListener: config.unauthenticatedLoopbackListener, }); const targetUrl = new URL(target.baseUrl); + // Admission and the synchronous writer share one event-loop turn. A toggle or + // shutdown that landed while catalog discovery awaited must win over this snapshot. + if (opts.admit && !opts.admit()) return skipped(); return deps.injectGrokConfig(Number(targetUrl.port), projection.models, { hostname: target.requiresAdmissionToken ? (opts.hostname ?? config.hostname) diff --git a/structure/runtime.md b/structure/runtime.md index e40e2fc0ed0..12ee7f3073e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -181,7 +181,7 @@ uninstall still restore. `src/service/cli.ts` removes the service token on unins `src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. The Windows tray startup-health probe in `src/tray/windows-tray.ps1` requests termination at its 30-second deadline without waiting on the UI thread. It retains the process through later ticks until actual exit and both redirected readers settle; replacements cannot start while that reference is held. Launch setup failures follow the same cleanup. Tray shutdown alone waits at most 500 ms before disposal. `ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept". See [Remote Link](remote-link.md) for the rollback. -The package-tree integrity fence for live package replacement follows the [update transaction contract](ops/docs-and-release.md#package-tree-integrity-fence). After client journal reconciliation, `ocx ensure` in `src/cli/dispatch.ts` treats validated connected-client state as a successful no-op without local startup or a remote health probe; invalid or mismatched state still fails, and disconnected state keeps local ensure. `tests/cli/cli-ensure-client-state.test.ts` pins this dispatch contract so the Codex shim does not warn merely because its proxy is delegated to a hub. Startup journal reconciliation, including replacement after restart/drain, follows the [Codex-home recovery contract](codex-home.md#codex-home): already-original recovery is silent, incomplete recovery retains its journal and warns without claiming restoration, and only complete recovery that rewrites or removes an artifact reports a restore; restart admission, drain deadlines and replacement ownership are unchanged. `src/cli/start-owner-fence.ts` holds the ownership mutation lease through recovery for ordinary and supervised starts. After an early negative probe, `start` and `ensure` recheck with `START_OWNERSHIP_LIVENESS` under that lease through `observeProxyLiveness` in `src/server/proxy-liveness.ts`, probing both available loopback families concurrently for every loopback or wildcard spelling classified after the same startup normalization (including `localhost.` and case variants) within the existing per-family retry ceilings and shared deadline. Only definitive absence permits PID deletion and journal reconciliation: no alive recorded PID unless fresh readable command-line evidence confirms it is foreign, no answering runtime owner, and only unanimous aggregate connection refusals or clean foreign identities at probed endpoints. Port zero names no existing listener. Timeouts, resets, unreadable/non-200 responses, PID mismatches and exhausted deadlines remain unknown; startup refuses with a fixed message while preserving PID, journal and shared config. Unreadable/empty PID command lines and any OpenCodex invocation remain unknown; a confirmed foreign PID is never signalled. A discovered live owner preserves both records and its runtime/config bind scope in `LiveProxy.hostname`; omitted or blank hostnames become explicit `127.0.0.1` in both early and recovery discovery, so a later config change cannot replace a default loopback bind. Explicit wildcard scope remains intact, and the concrete responding address is separate `probeHostname` metadata. Integration policy uses the bind scope, so loopback discovery of a wildcard listener does not authorize tokenless Grok registration. Ensure carries `probeHostname` separately through `src/cli/ensure-desired-integrations.ts` and `src/grok/sync.ts` to `src/grok/inject.ts` for the written destination; an explicit secondary loopback listener keeps its own address and port; after lease release, start applies `decideStartWithLiveOwner` and ensure follows its existing live-owner path. Cross-home sibling marking still precedes journal recovery. The bind/publication fence remains separate, and supervised recovery checks durable service ownership before liveness or mutation. `tests/cli/cli-start-journal-order.test.ts` covers an early miss; `tests/cli/cli-start-owner-absence.test.ts` covers consecutive misses and dead-owner recovery; `tests/server/proxy-liveness-recovery.test.ts` binds classification and existing budgets; `tests/server/proxy-liveness-bind-scope.test.ts` follows a missed early probe through desired-state reconciliation and the real Grok writer for runtime, orphan and configured-port discovery; `tests/server/proxy-liveness-owner-evidence.test.ts` and `tests/cli/cli-start-owner-evidence.test.ts` cover aggregate children, both listener families, shared deadlines and foreign-PID recovery. +The package-tree integrity fence for live package replacement follows the [update transaction contract](ops/docs-and-release.md#package-tree-integrity-fence). After client journal reconciliation, `ocx ensure` in `src/cli/dispatch.ts` treats validated connected-client state as a successful no-op without local startup or a remote health probe; invalid or mismatched state still fails, and disconnected state keeps local ensure. `tests/cli/cli-ensure-client-state.test.ts` pins this dispatch contract so the Codex shim does not warn merely because its proxy is delegated to a hub. Startup journal reconciliation, including replacement after restart/drain, follows the [Codex-home recovery contract](codex-home.md#codex-home): already-original recovery is silent, incomplete recovery retains its journal and warns without claiming restoration, and only complete recovery that rewrites or removes an artifact reports a restore; restart admission, drain deadlines and replacement ownership are unchanged. `src/cli/start-owner-fence.ts` holds the ownership mutation lease through recovery for ordinary and supervised starts. After an early negative probe, `start` and `ensure` recheck with `START_OWNERSHIP_LIVENESS` under that lease through `observeProxyLiveness` in `src/server/proxy-liveness.ts`, probing both available loopback families concurrently for every loopback or wildcard spelling classified after the same startup normalization (including `localhost.` and case variants) within the existing per-family retry ceilings and shared deadline. Only definitive absence permits PID deletion and journal reconciliation: no alive recorded PID unless fresh readable command-line evidence confirms it is foreign, no answering runtime owner, and only unanimous aggregate connection refusals or clean foreign identities at probed endpoints. Port zero names no existing listener. Timeouts, resets, unreadable/non-200 responses, PID mismatches and exhausted deadlines remain unknown; startup refuses with a fixed message while preserving PID, journal and shared config. Unreadable/empty PID command lines and any OpenCodex invocation remain unknown; a confirmed foreign PID is never signalled. A discovered live owner preserves both records and its runtime/config bind scope in `LiveProxy.hostname`; omitted or blank hostnames become explicit `127.0.0.1` in both early and recovery discovery, so a later config change cannot replace a default loopback bind. Explicit wildcard scope remains intact, and the concrete responding address is separate `probeHostname` metadata. Integration policy uses the bind scope, so loopback discovery of a wildcard listener does not authorize tokenless Grok registration. Grok start and ensure/restart synchronization also recheck current desired-state admission after catalog discovery, immediately before the synchronous writer in `src/grok/sync.ts`; start additionally rejects work after shutdown begins or cleanup runs. This closes the same-process await gap without turning the desired-state and Grok files into a cross-process transaction. Ensure carries `probeHostname` separately through `src/cli/ensure-desired-integrations.ts` and `src/grok/sync.ts` to `src/grok/inject.ts` for the written destination; an explicit secondary loopback listener keeps its own address and port; after lease release, start applies `decideStartWithLiveOwner` and ensure follows its existing live-owner path. Cross-home sibling marking still precedes journal recovery. The bind/publication fence remains separate, and supervised recovery checks durable service ownership before liveness or mutation. `tests/cli/cli-start-journal-order.test.ts` covers an early miss; `tests/cli/cli-start-owner-absence.test.ts` covers consecutive misses and dead-owner recovery; `tests/server/proxy-liveness-recovery.test.ts` binds classification and existing budgets; `tests/server/proxy-liveness-bind-scope.test.ts` follows a missed early probe through desired-state reconciliation and the real Grok writer for runtime, orphan and configured-port discovery; `tests/server/proxy-liveness-owner-evidence.test.ts` and `tests/cli/cli-start-owner-evidence.test.ts` cover aggregate children, both listener families, shared deadlines and foreign-PID recovery. A busy preferred port is never resolved by starting somewhere else. Both questions a start asks about an existing proxy — the pre-bind owner check and the port-is-busy check in `src/cli/index.ts` diff --git a/tests/cli/ensure-desired-integrations-race.test.ts b/tests/cli/ensure-desired-integrations-race.test.ts index e4e858cecca..f099c708717 100644 --- a/tests/cli/ensure-desired-integrations-race.test.ts +++ b/tests/cli/ensure-desired-integrations-race.test.ts @@ -1,11 +1,14 @@ import { describe, expect, test } from "bun:test"; import { + ensureGrokFenceMatchesDesired, reconcileEnsureDesiredIntegrations, type EnsureDesiredIntegrationsDeps, } from "../../src/cli/ensure-desired-integrations"; import type { OcxConfig } from "../../src/types"; import type { GrokInjectResult } from "../../src/grok/inject"; import type { Desktop3pRemovalResult } from "../../src/claude/desktop-3p"; +import { syncGrokConfig } from "../../src/grok/sync"; +import type { CatalogModel } from "../../src/codex/catalog"; function config(overrides: { grok?: boolean; @@ -159,6 +162,22 @@ test("CLI intent with disabled intercept retains the env through Desktop-off cle }); describe("ensure desired-state races", () => { + test("Grok disabled during catalog fetch cannot be reinjected by ensure", async () => { + const h = harness(config()); + let finishCatalog!: (models: CatalogModel[]) => void; + const catalog = new Promise(resolve => { finishCatalog = resolve; }); + let injectCalls = 0; + h.deps.syncGrokConfig = (port, current, opts) => syncGrokConfig(port, current, opts, { + fetchAllModels: () => catalog, + injectGrokConfig: () => { injectCalls++; return okGrok(true); }, + }); + const pending = ensureGrokFenceMatchesDesired(10100, {}, h.deps); + h.flip(config({ grok: false })); + finishCatalog([]); + await pending; + expect(injectCalls).toBe(0); + }); + test("live-proxy OFF→ON uses the current ON snapshot instead of stripping", async () => { const staleOff = config({ grok: false, desktop: false, hostname: "stale-host", fingerprint: "fp-off" }); const currentOn = config({ hostname: "fresh-host", fingerprint: "fp-on" }); diff --git a/tests/providers/xai/grok-sync.test.ts b/tests/providers/xai/grok-sync.test.ts index a045aa515cb..5b7392659c5 100644 --- a/tests/providers/xai/grok-sync.test.ts +++ b/tests/providers/xai/grok-sync.test.ts @@ -25,6 +25,52 @@ function tempGrokHome(): { root: string; grokHome: string } { } describe("syncGrokConfig", () => { + test("declined caller admission performs no catalog work or file writes", async () => { + let fetchCalls = 0, injectCalls = 0; + const result = await syncGrokConfig(10190, baseConfig, { admit: () => false }, { + fetchAllModels: async () => { fetchCalls++; return []; }, + injectGrokConfig: () => { injectCalls++; return { ok: true, changed: true, message: "unexpected" }; }, + }); + expect(result).toMatchObject({ ok: true, changed: false }); + expect(fetchCalls).toBe(0); + expect(injectCalls).toBe(0); + }); + + for (const change of ["disabled", "stopping", "unchanged"] as const) { + test(`rechecks caller admission after a delayed catalog: ${change}`, async () => { + const { root, grokHome } = tempGrokHome(); + const original = "# user Grok config\n"; + writeFileSync(join(grokHome, "config.toml"), original); + let finishCatalog!: (models: CatalogModel[]) => void; + const catalog = new Promise(resolve => { finishCatalog = resolve; }); + let enabled = true, running = true, injectCalls = 0; + const options = { grokHome, admit: () => enabled && running }; + try { + const pending = syncGrokConfig(10190, baseConfig, options, { + fetchAllModels: () => catalog, + injectGrokConfig: (...args) => { injectCalls++; return injectGrokConfig(...args); }, + }); + if (change === "disabled") enabled = false; + if (change === "stopping") running = false; + finishCatalog([]); + const result = await pending; + if (change === "unchanged") { + expect(result).toMatchObject({ ok: true, changed: true }); + expect(injectCalls).toBe(1); + expect(readFileSync(join(grokHome, "config.toml"), "utf8")).toContain("opencodex managed block"); + } else { + expect(result).toMatchObject({ ok: true, changed: false }); + expect(injectCalls).toBe(0); + expect(readFileSync(join(grokHome, "config.toml"), "utf8")).toBe(original); + expect(existsSync(join(grokHome, "config.toml.bak-opencodex"))).toBe(false); + } + } finally { + finishCatalog([]); + removeTreeWithRetry(root); + } + }); + } + test("injects natives plus routed models with catalog context windows", async () => { const { root, grokHome } = tempGrokHome(); try { From 7e2638c5795e68220c3839274ee0920faa3c0682 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 06:05:45 +0900 Subject: [PATCH 2/2] test(grok): cover real startup admission after discovery --- .../codex-composed-acceptance.test.ts | 89 ++++++++++++++++++- .../grok-startup-discovery-preload.ts | 38 ++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/grok-startup-discovery-preload.ts diff --git a/tests/codex-integration/codex-composed-acceptance.test.ts b/tests/codex-integration/codex-composed-acceptance.test.ts index b383c7a35c4..eb309836dc2 100644 --- a/tests/codex-integration/codex-composed-acceptance.test.ts +++ b/tests/codex-integration/codex-composed-acceptance.test.ts @@ -94,6 +94,7 @@ type StartedServer = { /** Captured during start(): the child's streams can only be read once. */ stdout: Promise; stderr: Promise; + stdoutSnapshot: () => string; }; type CapturedChildStream = { @@ -334,8 +335,8 @@ class Fixture { return { exitCode, stdout, stderr }; } - async start(): Promise { - const child = this.spawnCli(["start"]); + async start(options: { readonly preloadPaths?: readonly string[]; readonly env?: Record } = {}): Promise { + const child = this.spawnCli(["start"], this.homeA, this.userprofileA, options); const pidPath = join(this.ocx, "ocx.pid"); const runtimePath = join(this.ocx, "runtime-port.json"); // Run 35093667426 waited the full 45 s Windows watchdog with the child alive, but @@ -387,7 +388,7 @@ class Fixture { } }, "child /healthz").catch(() => diagnose("timed out waiting for child /healthz")); expect(health).toMatchObject({ pid: child.pid, port: runtime.port }); - return { process: child, runtime, stdout: stdout.completed, stderr: stderr.completed }; + return { process: child, runtime, stdout: stdout.completed, stderr: stderr.completed, stdoutSnapshot: stdout.snapshot }; } async stop(server: StartedServer): Promise { @@ -821,6 +822,88 @@ describe("WP13 composed toggle acceptance", () => { expect(await holder.exited).toBe(0); }, CASE_TIMEOUT_MS); + /** RED: remove the corresponding handleStart admission term; discovery recreates the fence. */ + test.each(["persisted-off", "shutdown"] as const)("Grok E2E: held startup discovery respects %s", async boundary => { + const fx = fixture(); + let turnEntered = false; + const enteredFile = join(fx.root, "grok-catalog-entered"); + const releaseFile = join(fx.root, "grok-catalog-release"); + const releaseCatalog = () => writeFileSync(releaseFile, "release\n"); + let releaseTurn!: () => void; + const turnGate = new Promise(resolve => { releaseTurn = resolve; }); + const provider = Bun.serve({ + port: 0, + idleTimeout: 255, // The gates below, not an idle socket timeout, bound these deliberate holds. + fetch: async request => { + const path = new URL(request.url).pathname; + if (path.endsWith("/models")) { + return Response.json({ data: [{ id: "held-model" }] }); + } + if (path.endsWith("/chat/completions")) { + turnEntered = true; + await turnGate; + return Response.json({ id: "held-turn", choices: [{ index: 0, + message: { role: "assistant", content: "done" }, finish_reason: "stop" }] }); + } + return new Response("not found", { status: 404 }); + }, + }); + const providers = { fixture: { adapter: "openai-chat", baseUrl: `http://127.0.0.1:${provider.port}/v1`, + apiKey: "fixture-key", allowPrivateNetwork: true, liveModels: true, models: ["held-model"] } }; + const grokHome = join(fx.homeA, ".grok"); + mkdirSync(grokHome); + const original = "# user grok config\n"; + writeFileSync(join(grokHome, "config.toml"), original); + fx.writeConfig({ providers, clientIntegrations: { codex: false }, shutdownTimeoutMs: HELD_REQUEST_BUDGET_MS }); + const signalFile = join(fx.root, "signal-startup-stop"); + let server: StartedServer | undefined; + let heldTurn: Promise | undefined; + const turnAbort = new AbortController(); + try { + server = await fx.start({ + preloadPaths: [resolve(repoRoot, "tests/fixtures/grok-startup-discovery-preload.ts")], + env: { OCX_TEST_GROK_ENTERED_FILE: enteredFile, OCX_TEST_GROK_RELEASE_FILE: releaseFile, + OCX_TEST_GROK_HOLD_MS: String(HELD_REQUEST_BUDGET_MS), + ...(boundary === "shutdown" ? { OCX_TEST_STARTUP_SIGNAL_FILE: signalFile } : {}) }, + }); + await waitFor(() => existsSync(enteredFile) || null, "Grok startup catalog hold"); + expect(server.stdoutSnapshot()).not.toContain("Client startup work complete."); + if (boundary === "shutdown") { + // Keep real admission occupied so shutdown cannot exit before discovery resumes. + heldTurn = fetch(`http://127.0.0.1:${server.runtime.port}/v1/chat/completions`, { + method: "POST", headers: { "content-type": "application/json", "x-opencodex-api-key": fx.dataToken }, + body: JSON.stringify({ model: "fixture/held-model", messages: [{ role: "user", content: "fixture" }] }), + signal: AbortSignal.any([turnAbort.signal, AbortSignal.timeout(HELD_REQUEST_BUDGET_MS)]), + }).then(async response => { await response.text(); return response.status; }); + void heldTurn.catch(() => {}); // Teardown joins it even when an earlier assertion fails. + await waitFor(() => turnEntered || null, "admitted provider turn"); + writeFileSync(signalFile, "SIGINT\n"); + await waitFor(() => server!.stdoutSnapshot().includes("Shutting down opencodex proxy") || null, "startup shutdown handler"); + } else { + fx.writeConfig({ providers, clientIntegrations: { codex: false, grok: false } }); + } + releaseCatalog(); + await waitFor(() => server!.stdoutSnapshot().includes("Client startup work complete.") || null, "resumed Grok startup"); + // Read before shutdown cleanup: a transient write followed by strip must fail too. + expect(server.process.exitCode).toBeNull(); + expect(readFileSync(join(grokHome, "config.toml"), "utf8")).toBe(original); + expect(existsSync(join(grokHome, "config.toml.bak-opencodex"))).toBe(false); + expect(server.stdoutSnapshot()).not.toContain("Grok Build config updated"); + releaseTurn(); + if (heldTurn) { + expect(await heldTurn).toBe(200); + expect(await server.process.exited).toBe(0); + } + } finally { + releaseCatalog(); + releaseTurn(); + turnAbort.abort(); + await heldTurn?.catch(() => {}); + try { if (server) await fx.stop(server); } + finally { provider.stop(true); } + } + }, CASE_TIMEOUT_MS); + /** RED: delete the durable Grok intent or bypass `shouldSyncGrokOnStart`; startup recreates the fence. */ test("Grok E2E: route-disabled Grok stays absent across a real startup", async () => { const fx = fixture(); diff --git a/tests/fixtures/grok-startup-discovery-preload.ts b/tests/fixtures/grok-startup-discovery-preload.ts new file mode 100644 index 00000000000..d27d3cfe2a8 --- /dev/null +++ b/tests/fixtures/grok-startup-discovery-preload.ts @@ -0,0 +1,38 @@ +// Hold only the real Grok startup caller's catalog result. Earlier Desktop/Claude +// catalog consumers must complete normally, or the test misses the post-await check. +import { mock } from "bun:test"; +import { existsSync, writeFileSync } from "node:fs"; +import { syncGrokConfig } from "../../src/grok/sync"; +import { injectGrokConfig } from "../../src/grok/inject"; +import { fetchAllModels } from "../../src/server/management/shared"; + +const entered = process.env.OCX_TEST_GROK_ENTERED_FILE; +const release = process.env.OCX_TEST_GROK_RELEASE_FILE; +if (!entered || !release) throw new Error("Grok discovery fixture requires its owned markers"); +const actualSync = syncGrokConfig; +mock.module("../../src/grok/sync", () => ({ + syncGrokConfig: (...args: Parameters) => actualSync(args[0], args[1], args[2], { + injectGrokConfig, + fetchAllModels: async config => { + const models = await fetchAllModels(config); + writeFileSync(entered, "grok-catalog-ready\n"); + const deadline = Date.now() + Number(process.env.OCX_TEST_GROK_HOLD_MS ?? 60_000); + while (!existsSync(release)) { + if (Date.now() >= deadline) throw new Error("Grok discovery fixture release timeout"); + await Bun.sleep(10); + } + return models; + }, + }), +})); + +// Invoke the child's installed handler instead of Windows TerminateProcess. +const signal = process.env.OCX_TEST_STARTUP_SIGNAL_FILE; +if (signal) { + const poller = setInterval(() => { + if (!existsSync(signal)) return; + clearInterval(poller); + process.emit("SIGINT", "SIGINT"); + }, 10); + poller.unref(); +}