diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 74353197f7d..a47b2c6894a 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -194,6 +194,8 @@ export function shouldExposeRoutedModel(model: CatalogModel): boolean { } export function readCodexCatalogPath(): string { + const home = activeCodexHome(); + if (home) return readCodexCatalogPathForHome(home); try { const configPath = activeCodexConfigPath(); if (existsSync(configPath)) { @@ -205,6 +207,19 @@ export function readCodexCatalogPath(): string { return activeDefaultCatalogPath(); } +/** Resolve the configured catalog without consulting ambient CODEX_HOME again. */ +export function readCodexCatalogPathForHome(codexHome: string): string { + try { + const configPath = join(codexHome, "config.toml"); + if (existsSync(configPath)) { + const toml = readFileSync(configPath, "utf-8"); + const path = readRootTomlString(toml, "model_catalog_json"); + if (path) return resolve(codexHome, path); + } + } catch { /* ignore */ } + return join(codexHome, "opencodex-catalog.json"); +} + export function parseCatalogJson(raw: string): RawCatalog | null { try { const cat = JSON.parse(raw); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 70b93ee7bdb..258474447d1 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -41,7 +41,7 @@ import { } from "../model-entitlements"; -import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata"; import { @@ -1832,11 +1832,12 @@ export function invalidateCodexModelsCacheWithPermit( // The catalog-only sync override applies here too so an explicit refresh // keeps the cache consistent with the catalog it just wrote. if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false; - const catalogPath = readCodexCatalogPath(); + const catalogPath = readCodexCatalogPathForHome(owningCodexHome); + const cachePath = join(owningCodexHome, "models_cache.json"); if (!existsSync(catalogPath)) return false; const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); const models = catalog.models ?? catalog; - const currentCache = readCatalog(activeCodexModelsCachePath()); + const currentCache = readCatalog(cachePath); const existingSlugs = new Set(models.flatMap((entry: RawEntry) => typeof entry.slug === "string" ? [entry.slug] : [])); const currentConfig = loadConfig(); @@ -1864,7 +1865,7 @@ export function invalidateCodexModelsCacheWithPermit( models: [...models, ...observedAccountModels], }; replaceCodexModelsCache(permit, owningCodexHome, { - path: activeCodexModelsCachePath(), + path: cachePath, content: `${JSON.stringify(wrapper, null, 2)}\n`, }); return true; diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index e873579197f..25f59ba23e3 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -3,6 +3,7 @@ import { clearAccountNeedsReauth } from "./account-runtime-state"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { probeNativeProfileRecoveryState, + resolveNativeProfileContext, type NativeProfileRecoveryState, } from "./native-profile-store"; import { @@ -43,6 +44,8 @@ export interface NativeMainStartupGateDeps { probeRecoveryState?: typeof probeNativeProfileRecoveryState; owner?: NativeMainOwnerOptions; stageSweepIntervalMs?: number; + /** Test seam / activation-time revalidation for the ambient physical auth home. */ + currentHomeId?: () => string | null; } export interface NativeMainStartupLifecycle { @@ -51,6 +54,11 @@ export interface NativeMainStartupLifecycle { release(): Promise; } +export interface PreparedNativeMainStartupLifecycle { + readonly homeId: string; + start(): NativeMainStartupLifecycle; +} + let epoch = 0; let snapshot: NativeMainStartupGateSnapshot = { status: "ready", homeId: null }; let settled: Promise = Promise.resolve(snapshot); @@ -75,6 +83,7 @@ interface StartupEntry { } const startupEntries = new Map(); const serverLifecycles = new WeakMap(); +const serverLifecycleReleases = new WeakMap>(); function ready(homeId: string | null): NativeMainStartupGateSnapshot { return { status: "ready", homeId }; @@ -311,14 +320,48 @@ export function startNativeMainStartupLifecycle( }; } +/** Resolve and pin the owned lifecycle target without acquiring ownership or creating artifacts. */ +export function prepareNativeMainStartupLifecycle( + deps: NativeMainStartupGateDeps = {}, + homes?: { codexHome: string; configDir: string }, +): PreparedNativeMainStartupLifecycle | null { + let manager: NativeProfileManager; + try { + manager = deps.manager ?? new NativeProfileManager(homes); + if (homes) { + const expected = resolveNativeProfileContext(homes); + if ( + manager.context.homeId !== expected.homeId + || manager.context.instanceId !== expected.instanceId + ) return null; + } + } catch { + return null; + } + const currentHomeId = deps.currentHomeId ?? (() => { + try { return resolveNativeProfileContext().homeId; } catch { return null; } + }); + const pinnedDeps = { ...deps, manager }; + return { + homeId: manager.context.homeId, + start: () => { + if (currentHomeId() !== manager.context.homeId) { + throw new Error("The native-main startup home changed after ownership inspection."); + } + return startNativeMainStartupLifecycle(pinnedDeps); + }, + }; +} + /** * How many times a service-ownership fence will re-ask before it stops asking (#2108). * * A host that is permanently unaskable must not re-probe on every request forever, and a * host that recovers usually does so within the first few. The budget belongs to the - * REASON, not to an individual fence: raising a second fence deliberately does not hand - * out a fresh allowance, or a caller looping over fences could spin the probe forever. - * It is dropped when the last fence for that reason releases. + * live hook owner, not to an individual fence: raising a second fence deliberately does + * not hand out a fresh allowance, or a caller looping over fences could spin the probe + * forever. Spending or releasing that hook owner ends its budget generation; a later + * fence can install a new owner even if an older hookless fence is still draining. */ export const NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT = 5; @@ -327,6 +370,11 @@ const serviceOwnershipReprobes = new Map NativeCodexOwnership; + readonly expectedHomeId: () => string | null; + readonly activate: () => NativeMainStartupLifecycle; + readonly adopt: (lifecycle: NativeMainStartupLifecycle) => boolean; + readonly discard: (lifecycle: NativeMainStartupLifecycle) => void; + activating: boolean; attempts: number; /** The fence that installed this hook; only its own release may drop the entry. */ readonly owner: NativeMainStartupLifecycle; @@ -334,6 +382,10 @@ interface ServiceOwnershipReprobe { readonly spend: () => void; } +function releaseUnadoptedLifecycle(lifecycle: NativeMainStartupLifecycle): Promise { + try { return Promise.resolve(lifecycle.release()).catch(() => {}); } catch { return Promise.resolve(); } +} + /** Test-only: the retry budget is module state and would otherwise leak across tests. */ export function __resetNativeMainOwnershipRetries(): void { for (const entry of serviceOwnershipReprobes.values()) entry.attempts = 0; @@ -359,23 +411,53 @@ function reprobeServiceOwnership(reason: NativeMainServiceOwnershipBlockReason): if (reason !== "ownership-unknown") return false; const entry = serviceOwnershipReprobes.get(reason); if (!entry) return false; + if (entry.activating) return false; if (entry.attempts >= NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT) return false; entry.attempts += 1; - let answer: NativeCodexOwnership; + let activated: NativeMainStartupLifecycle | undefined; + let expectedHomeId: string | null = null; + entry.activating = true; try { - answer = entry.probe(); + const answer = entry.probe(); + if (answer !== "owned") return false; + expectedHomeId = entry.expectedHomeId(); + if (expectedHomeId === null) return false; + // Ownership becoming knowable is not itself startup completion. Install the + // normal owner/recovery lifecycle while this fence is still held, so native + // traffic cannot get ahead of owner registration, journal recovery, auth-temp + // scrubbing, or the initial stage sweep. + activated = entry.activate(); } catch { - // An inspection that throws is not evidence the host became ownable. + // Neither a failed inspection nor a failed activation is evidence that + // native-main is safe to admit. Keep the fence and retry hook intact. + return false; + } finally { + entry.activating = false; + } + if ( + !activated + || activated.homeId === null + || activated.homeId !== expectedHomeId + || typeof activated.release !== "function" + ) { + if (activated && typeof activated.release === "function") entry.discard(activated); + return false; + } + if (serviceOwnershipReprobes.get(reason) !== entry || !entry.adopt(activated)) { + // Shutdown or a re-entrant release can retire this fence while activation + // runs. A lifecycle that was never attached to the server must not retain + // another owner reference in the background. + entry.discard(activated); return false; } - if (answer !== "owned") return false; // Release through the fence that installed this hook, and only that one. // // Several servers can hold a fence for the same reason while only one carries a hook, so // clearing the shared refcount here would unblock fences this probe never spoke for. // Decrementing here directly is just as wrong the other way: that fence's own release() - // would then pay a second time for one fence, leaving the count short. Delegating to the - // fence's idempotent release keeps exactly one payment per fence. + // would then pay a second time for one fence, leaving the count short. The + // fence's idempotent spend hook keeps exactly one payment per fence while the + // transitioned owned lifecycle remains attached until server shutdown. entry.spend(); return true; } @@ -395,22 +477,44 @@ function serviceOwnershipSnapshot( /** Close native-main admission without resolving or creating any CODEX_HOME artifacts. */ export function blockNativeMainStartupForUnownedServiceHome( reason: NativeMainServiceOwnershipBlockReason, - options?: { reprobe?: () => NativeCodexOwnership }, + options?: { + reprobe: () => NativeCodexOwnership; + expectedHomeId: string | (() => string | null); + startOwnedLifecycle: () => NativeMainStartupLifecycle; + }, ): NativeMainStartupLifecycle { serviceOwnershipRefs.set(reason, (serviceOwnershipRefs.get(reason) ?? 0) + 1); - let released = false; - const lifecycle: NativeMainStartupLifecycle = { - homeId: null, - settled: Promise.resolve(serviceOwnershipSnapshot(reason)), - async release() { - if (released) return; - released = true; - const remaining = Math.max(0, (serviceOwnershipRefs.get(reason) ?? 0) - 1); - if (remaining === 0) serviceOwnershipRefs.delete(reason); - else serviceOwnershipRefs.set(reason, remaining); - if (serviceOwnershipReprobes.get(reason)?.owner === lifecycle) { - serviceOwnershipReprobes.delete(reason); - } + let fenceSpent = false; + let ownedLifecycle: NativeMainStartupLifecycle | undefined; + let releaseFlight: Promise | undefined; + const orphanReleaseFlights = new Set>(); + let lifecycle!: NativeMainStartupLifecycle; + const blockedSettled = Promise.resolve(serviceOwnershipSnapshot(reason)); + const spendFence = () => { + if (fenceSpent) return; + fenceSpent = true; + const remaining = Math.max(0, (serviceOwnershipRefs.get(reason) ?? 0) - 1); + if (remaining === 0) serviceOwnershipRefs.delete(reason); + else serviceOwnershipRefs.set(reason, remaining); + if (serviceOwnershipReprobes.get(reason)?.owner === lifecycle) { + serviceOwnershipReprobes.delete(reason); + } + }; + lifecycle = { + get homeId() { return ownedLifecycle?.homeId ?? null; }, + get settled() { return ownedLifecycle?.settled ?? blockedSettled; }, + release() { + return releaseFlight ??= (async () => { + spendFence(); + // A synchronous activator can re-enter release before its returned + // lifecycle is adopted or discarded. Let that call stack finish so the + // cleanup set is complete before this shared release flight drains it. + await Promise.resolve(); + await ownedLifecycle?.release(); + if (orphanReleaseFlights.size > 0) { + await Promise.allSettled([...orphanReleaseFlights]); + } + })(); }, }; // Do NOT reset an existing budget: keying the reprobe by reason means a caller raising @@ -418,12 +522,28 @@ export function blockNativeMainStartupForUnownedServiceHome( // the probe forever. But once the holder is gone its entry is removed above, so a LATER // fence installs its own hook — a server started after an earlier probe must not be left // needing `ocx restart`, which is the very symptom this exists to remove. - if (options?.reprobe && reason === "ownership-unknown" && !serviceOwnershipReprobes.has(reason)) { + if (options && reason === "ownership-unknown" && !serviceOwnershipReprobes.has(reason)) { + const expectedHomeId = options.expectedHomeId; serviceOwnershipReprobes.set(reason, { probe: options.reprobe, + expectedHomeId: typeof expectedHomeId === "function" + ? expectedHomeId + : () => expectedHomeId, + activate: options.startOwnedLifecycle, + adopt: activated => { + if (releaseFlight !== undefined || fenceSpent || ownedLifecycle !== undefined) return false; + ownedLifecycle = activated; + return true; + }, + discard: activated => { + const flight = releaseUnadoptedLifecycle(activated); + orphanReleaseFlights.add(flight); + void flight.finally(() => orphanReleaseFlights.delete(flight)); + }, + activating: false, attempts: 0, owner: lifecycle, - spend: () => { void lifecycle.release(); }, + spend: spendFence, }); } return lifecycle; @@ -434,10 +554,20 @@ export function bindNativeMainStartupLifecycle(server: object, lifecycle: Native } export async function releaseNativeMainStartupLifecycle(server: object): Promise { + const existing = serverLifecycleReleases.get(server); + if (existing) return existing; const lifecycle = serverLifecycles.get(server); if (!lifecycle) return; - serverLifecycles.delete(server); - await lifecycle.release(); + const flight = Promise.resolve().then(() => lifecycle.release()); + serverLifecycleReleases.set(server, flight); + try { + await flight; + } finally { + if (serverLifecycleReleases.get(server) === flight) { + serverLifecycleReleases.delete(server); + serverLifecycles.delete(server); + } + } } export function isNativeMainTrafficBlocked(): boolean { diff --git a/src/server/index.ts b/src/server/index.ts index 70433d845c1..f59b4d0ae37 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,10 +22,11 @@ import { import { reconcileOAuthProviders } from "../oauth"; import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization"; import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync"; -import { getCodexHome } from "../codex/paths"; +import { currentServiceHomes, serviceStatePathsForOpenCodexHome } from "../service"; import { shouldSyncCodexOnStart } from "../codex/desired-state"; import { inspectNativeCodexOwnership, + type NativeCodexOwnership, type OwnershipInspection, } from "../integrations/native/ownership-preflight"; import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api"; @@ -174,8 +175,8 @@ import { runClaudeAuthModeMigration } from "../claude/auth-mode-migration"; import { bindNativeMainStartupLifecycle, blockNativeMainStartupForUnownedServiceHome, + prepareNativeMainStartupLifecycle, releaseNativeMainStartupLifecycle, - startNativeMainStartupLifecycle, type NativeMainStartupGateDeps, type NativeMainStartupLifecycle, } from "../codex/native-profile-startup"; @@ -443,6 +444,8 @@ export interface StartServerDeps { nativeMainStartup?: NativeMainStartupGateDeps; /** Test-only ownership evidence; production inspects the installed service state. */ inspectNativeCodexOwnership?: typeof inspectNativeCodexOwnership; + /** Test-only service-home resolver; production resolves the current homes directly. */ + resolveServiceHomes?: typeof currentServiceHomes; /** Test-only seam for an upstream that cannot complete its WebSocket close handshake. */ liveSidebandWebSocketFactory?: LiveSidebandWebSocketFactory; /** Test-only seam; production derives a fresh local-attestation secret per process. */ @@ -451,9 +454,22 @@ export interface StartServerDeps { readinessGate?: ReadinessGate; } -function inspectStartupOwnership(deps: StartServerDeps): OwnershipInspection { +function inspectStartupOwnership( + deps: StartServerDeps, + currentHomes: ReturnType | null, + statePaths: readonly string[] | null, +): OwnershipInspection { try { - return (deps.inspectNativeCodexOwnership ?? inspectNativeCodexOwnership)(); + if (currentHomes === null || statePaths === null) { + return { + ownership: "unknown", + reason: "startup service-home resolution failed", + }; + } + if (deps.inspectNativeCodexOwnership) { + return deps.inspectNativeCodexOwnership({ currentHomes, statePaths }); + } + return inspectNativeCodexOwnership({ currentHomes, statePaths }); } catch { return { ownership: "unknown", @@ -540,15 +556,27 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server | null = null; + let startupOwnershipStatePaths: readonly string[] | null = null; + try { + const homes = resolveServiceHomes(); + const statePaths = serviceStatePathsForOpenCodexHome(homes.opencodexHome); + startupOwnershipHomes = homes; + startupOwnershipStatePaths = statePaths; + } catch { /* inspection below stays unknown */ } + const startupCacheOwnership = inspectStartupOwnership( + deps, + startupOwnershipHomes, + startupOwnershipStatePaths, + ); // Startup cache invalidation is best-effort and must never block the server from - // serving. It now takes K so it cannot race a convergence commit, but both the - // home resolution and the acquisition can fail on a machine with no Codex home — - // `getCodexHome()` THROWS when CODEX_HOME names a missing directory, which would - // otherwise turn "no Codex installed" into "proxy will not start". - if (startupCacheOwnership.ownership === "owned") { + // serving. It now takes K so it cannot race a convergence commit. Use the home + // paired with the ownership inspection; re-reading ambient CODEX_HOME here could + // invalidate a different installation after an environment or mount change. + if (startupCacheOwnership.ownership === "owned" && startupOwnershipHomes !== null) { try { - const startupCodexHome = getCodexHome(); + const startupCodexHome = startupOwnershipHomes.codexHome; // #1046: record whether this actually rewrote the cache. `handleStart` ORs this // with the later startup sync and warns ONCE about stale app-servers; warning // here instead would read a catalog mtime the sync is about to move. @@ -705,18 +733,71 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + // If startup could not resolve the homes at all, preserve a bounded retry + // without guessing an authority. The first successful resolution is pinned + // together with its service-state paths before ownership is inspected. + if (retryOwnershipHomes === null || retryOwnershipStatePaths === null) { + try { + const homes = resolveServiceHomes(); + const statePaths = serviceStatePathsForOpenCodexHome(homes.opencodexHome); + retryOwnershipHomes = homes; + retryOwnershipStatePaths = statePaths; + } catch { + return "unknown"; + } + } + const homes = retryOwnershipHomes; + const statePaths = retryOwnershipStatePaths; + const answer = inspectStartupOwnership(deps, homes, statePaths).ownership; + if (answer !== "owned") return answer; + retryPreparedNativeMainLifecycle ??= prepareNativeMainStartupLifecycle( + deps.nativeMainStartup, + { codexHome: homes.codexHome, configDir: homes.opencodexHome }, + ); + // An ownership verdict without a lifecycle bound to that same home is not + // enough to reopen native-main admission. + return retryPreparedNativeMainLifecycle ? "owned" : "unknown"; + }; + const ownershipRetryOptions = { + reprobe: reprobeNativeOwnership, + expectedHomeId: () => retryPreparedNativeMainLifecycle?.homeId ?? null, + startOwnedLifecycle: () => { + if (!retryPreparedNativeMainLifecycle) { + throw new Error("Native-main ownership became known before its startup lifecycle was prepared."); + } + return retryPreparedNativeMainLifecycle.start(); + }, + }; const nativeMainLifecycle: NativeMainStartupLifecycle = shouldSyncCodexOnStart(config) ? nativeOwnership.ownership === "owned" - ? startNativeMainStartupLifecycle(deps.nativeMainStartup) - : blockNativeMainStartupForUnownedServiceHome( - nativeOwnership.ownership === "foreign" ? "foreign-ownership" : "ownership-unknown", - // #2108: an `unknown` verdict means the probe could not answer, not that this host - // is unownable. Hand the fence a way to re-ask so a host that becomes answerable - // after boot reopens on its own instead of needing `ocx restart`. A `foreign` - // verdict ignores this by design — that one is a fact, not a question. - { reprobe: () => inspectStartupOwnership(deps).ownership }, - ) + ? preparedNativeMainLifecycle + ? preparedNativeMainLifecycle.start() + : blockNativeMainStartupForUnownedServiceHome( + "ownership-unknown", + ownershipRetryOptions, + ) + : nativeOwnership.ownership === "foreign" + ? blockNativeMainStartupForUnownedServiceHome("foreign-ownership") + : blockNativeMainStartupForUnownedServiceHome( + "ownership-unknown", + // #2108: an `unknown` verdict means the probe could not answer, not that this host + // is unownable. Hand the fence a way to re-ask so a host that becomes answerable + // after boot reopens on its own instead of needing `ocx restart`. A `foreign` + // verdict ignores this by design — that one is a fact, not a question. + ownershipRetryOptions, + ) : { homeId: null, settled: Promise.resolve({ status: "ready", homeId: null }), diff --git a/src/service.ts b/src/service.ts index cf61a657fb3..3c65f42e40c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -97,13 +97,17 @@ function defaultOpenCodexHome(): string { return resolve(join(homedir(), ".opencodex")); } -function serviceStatePaths(): string[] { - const paths = [serviceStatePath()]; +export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] { + const paths = [join(opencodexHome, "service-state.json")]; const defaultPath = join(defaultOpenCodexHome(), "service-state.json"); if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath); return paths; } +function serviceStatePaths(): string[] { + return serviceStatePathsForOpenCodexHome(currentOpenCodexHome()); +} + function currentCodexHome(deps: CodexHomeDeps = {}): string { // Service ownership must identify the same home as the runtime. In WSL an // unset CODEX_HOME can resolve to the single Windows Desktop home rather than diff --git a/tests/codex-models-cache-invalidate.test.ts b/tests/codex-models-cache-invalidate.test.ts index 2c7d3896ecf..ca4eb722896 100644 --- a/tests/codex-models-cache-invalidate.test.ts +++ b/tests/codex-models-cache-invalidate.test.ts @@ -3,6 +3,8 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; import { invalidateCodexModelsCache } from "../src/codex/catalog"; +import { invalidateCodexModelsCacheWithPermit } from "../src/codex/catalog/sync"; +import { withCatalogWriteSerialization } from "../src/codex/catalog-write-serialization"; import { afterCatalogWriteHandleAppServers } from "../src/codex/app-server-processes"; import { refreshCodexModelCatalog } from "../src/codex/refresh"; import { syncModelsToCodex } from "../src/codex/sync"; @@ -54,6 +56,33 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { expect(cache.models).toEqual([{ slug: "gpt-5.5" }]); }); + test("permit-bound invalidation stays on its owning home after ambient drift", () => { + const ambientCodexHome = mkdtempSync(join(tmpdir(), "ocx-invalidate-ambient-")); + try { + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "pinned-catalog.json"\n'); + writeFileSync(join(codexHome, "pinned-catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-pinned-home" }], + }, null, 2) + "\n"); + writeFileSync(join(ambientCodexHome, "opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-ambient-home" }], + }, null, 2) + "\n"); + process.env.CODEX_HOME = ambientCodexHome; + + const outcome = withCatalogWriteSerialization(codexHome, permit => + invalidateCodexModelsCacheWithPermit(permit, codexHome)); + + expect(outcome).toMatchObject({ kind: "completed", value: true }); + expect(existsSync(join(ambientCodexHome, "models_cache.json"))).toBe(false); + const cache = JSON.parse(readFileSync(join(codexHome, "models_cache.json"), "utf8")) as { + models: Array<{ slug: string }>; + }; + expect(cache.models).toEqual([{ slug: "gpt-pinned-home" }]); + } finally { + process.env.CODEX_HOME = codexHome; + rmSync(ambientCodexHome, { recursive: true, force: true }); + } + }); + test("preserves an observed unknown native as a hidden sync observation", () => { writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ models: [{ slug: "gpt-5.5" }], diff --git a/tests/native-profile-startup.test.ts b/tests/native-profile-startup.test.ts index fe59dc2224a..247f2df7fa8 100644 --- a/tests/native-profile-startup.test.ts +++ b/tests/native-profile-startup.test.ts @@ -30,11 +30,16 @@ import type { } from "../src/codex/native-profile-types"; import type { OcxConfig } from "../src/types"; import { + bindNativeMainStartupLifecycle, blockNativeMainStartupForUnownedServiceHome, initializeNativeMainStartupGate, isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot, NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT, + prepareNativeMainStartupLifecycle, + releaseNativeMainStartupLifecycle, + type NativeMainStartupLifecycle, + waitForNativeMainStartupGate, __resetNativeMainOwnershipRetries, } from "../src/codex/native-profile-startup"; import type { NativeCodexOwnership } from "../src/integrations/native/ownership-preflight"; @@ -47,10 +52,12 @@ import { resetLifecycleDrainStateForTests, tryAdmitTurn, } from "../src/server/lifecycle"; +import { startServer } from "../src/server"; const roots: string[] = []; const previousOpencodexHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; +const OWNERSHIP_REPROBE_TEST_HOME = "ownership-reprobe-test-home"; function restoreEnv(name: "OPENCODEX_HOME" | "CODEX_HOME", value: string | undefined): void { if (value === undefined) delete process.env[name]; @@ -81,6 +88,16 @@ function envelope(accountId: string, marker: string): string { }, null, 2) + "\n"; } +/** Lightweight owned transition for retry/refcount tests that do not exercise real recovery. */ +function startReadyOwnershipRetryLifecycle(): NativeMainStartupLifecycle { + const homeId = OWNERSHIP_REPROBE_TEST_HOME; + const readySettled = initializeNativeMainStartupGate({ + manager: { context: { homeId } } as unknown as NativeProfileManager, + probeRecoveryState: () => "none", + }); + return { homeId, settled: readySettled, release: async () => {} }; +} + type Phase = "prepared" | "auth-replaced" | "vault-committed"; type Observation = "source-exact" | "source-changed" | "target-exact" | "target-changed" | "unreadable" | "third"; @@ -95,7 +112,12 @@ interface Fixture { targetProfileId: string; } -async function fixture(phase: Phase, observation: Observation, activePool = false): Promise { +async function fixture( + phase: Phase, + observation: Observation, + activePool = false, + codexAccountMode: "pool" | "direct" = "pool", +): Promise { const root = mkdtempSync(join(tmpdir(), "ocx-native-startup-")); roots.push(root); const codexHome = join(root, "codex"); @@ -182,7 +204,7 @@ async function fixture(phase: Phase, observation: Observation, activePool = fals adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", - codexAccountMode: "pool", + codexAccountMode, }, }, codexAccounts: activePool ? [{ id: "pool-a", email: "pool@test", isMain: false }] : [], @@ -649,6 +671,8 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { let answer: NativeCodexOwnership = "unknown"; const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => answer, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, }); try { expect(isNativeMainTrafficBlocked()).toBe(true); @@ -661,15 +685,487 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { } }); + test("a later successful probe starts owned recovery before reopening admission", async () => { + let answer: NativeCodexOwnership = "unknown"; + let finishRecovery!: () => void; + const recoveryBarrier = new Promise(resolve => { finishRecovery = resolve; }); + const f = await fixture("prepared", "source-exact"); + let currentHomeId: string | null = f.manager.context.homeId; + let ownedReleases = 0; + const prepared = prepareNativeMainStartupLifecycle({ + manager: f.manager, + beforeRecovery: () => recoveryBarrier, + owner: { retryMs: 10, hardenPath: async () => {} }, + currentHomeId: () => currentHomeId, + }, { + codexHome: f.codexHome, + configDir: f.configDir, + }); + expect(prepared).not.toBeNull(); + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => answer, + expectedHomeId: f.manager.context.homeId, + startOwnedLifecycle: () => { + const owned = prepared!.start(); + return { + get homeId() { return owned.homeId; }, + get settled() { return owned.settled; }, + async release() { + ownedReleases += 1; + await owned.release(); + }, + }; + }, + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(true); + answer = "owned"; + + currentHomeId = "different-home"; + expect(isNativeMainTrafficBlocked()).toBe(true); + expect(nativeMainStartupGateSnapshot()).toMatchObject({ + status: "blocked", + reason: "ownership-unknown", + }); + currentHomeId = f.manager.context.homeId; + + expect(isNativeMainTrafficBlocked()).toBe(true); + expect(fence.homeId).toBe(f.manager.context.homeId); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: f.manager.context.homeId, + reason: "recovery-pending", + }); + + finishRecovery(); + await fence.settled; + expect(isNativeMainTrafficBlocked()).toBe(false); + } finally { + currentHomeId = f.manager.context.homeId; + finishRecovery(); + await fence.release(); + await fence.release(); + } + expect(ownedReleases).toBe(1); + }); + + test("startServer promotes one pinned unknown scope through the owned lifecycle", async () => { + const f = await fixture("prepared", "source-exact", false, "direct"); + process.env.CODEX_HOME = f.codexHome; + process.env.OPENCODEX_HOME = f.configDir; + let answer: NativeCodexOwnership = "unknown"; + let finishRecovery!: () => void; + const recoveryBarrier = new Promise(resolve => { finishRecovery = resolve; }); + const scopes: Array<{ + currentHomes?: { codexHome: string; opencodexHome: string }; + statePaths?: readonly string[]; + }> = []; + const server = startServer(0, { + inspectNativeCodexOwnership: (scope = {}) => { + scopes.push({ + currentHomes: scope.currentHomes ? { ...scope.currentHomes } : undefined, + statePaths: scope.statePaths ? [...scope.statePaths] : undefined, + }); + return { ownership: answer, reason: "pinned startup test" }; + }, + nativeMainStartup: { + manager: f.manager, + beforeRecovery: () => recoveryBarrier, + owner: { retryMs: 10, hardenPath: async () => {} }, + }, + }); + try { + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + answer = "owned"; + + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: f.manager.context.homeId, + reason: "recovery-pending", + }); + expect(scopes.length).toBeGreaterThanOrEqual(3); + const firstScope = scopes[0]!; + expect(firstScope.currentHomes).toEqual({ + codexHome: f.codexHome, + opencodexHome: f.configDir, + }); + expect(firstScope.statePaths?.[0]).toBe(join(f.configDir, "service-state.json")); + for (const scope of scopes.slice(1)) expect(scope).toEqual(firstScope); + + finishRecovery(); + expect(await waitForNativeMainStartupGate()).toEqual({ + status: "ready", + homeId: f.manager.context.homeId, + }); + const allowed = tryAcquireNativeMainProfileClaim(); + expect(allowed).not.toBeNull(); + allowed?.release(); + } finally { + finishRecovery(); + await server.stop(true); + } + }); + + test("startServer rejects initially owned activation after the inspected homes drift", async () => { + const inspected = await fixture("prepared", "source-exact", false, "direct"); + process.env.CODEX_HOME = inspected.codexHome; + process.env.OPENCODEX_HOME = inspected.configDir; + let inspections = 0; + let started: ReturnType | undefined; + try { + expect(() => { + started = startServer(0, { + inspectNativeCodexOwnership: scope => { + inspections += 1; + expect(scope.currentHomes).toEqual({ + codexHome: inspected.codexHome, + opencodexHome: inspected.configDir, + }); + expect(scope.statePaths?.[0]).toBe(join(inspected.configDir, "service-state.json")); + return { ownership: "owned", reason: "initially owned pinned scope test" }; + }, + nativeMainStartup: { + manager: inspected.manager, + currentHomeId: () => null, + owner: { retryMs: 10, hardenPath: async () => {} }, + }, + }); + }).toThrow("The native-main startup home changed after ownership inspection."); + } finally { + await started?.stop(true); + } + expect(inspections).toBe(2); + }); + + test("startServer keeps a retry when service homes are initially unavailable", async () => { + const f = await fixture("prepared", "source-exact", false, "direct"); + process.env.CODEX_HOME = f.codexHome; + process.env.OPENCODEX_HOME = f.configDir; + let homesReady = false; + let statePathsReady = false; + let homeResolutions = 0; + let answer: NativeCodexOwnership = "unknown"; + let finishRecovery!: () => void; + const recoveryBarrier = new Promise(resolve => { finishRecovery = resolve; }); + const scopes: Array<{ + currentHomes?: { codexHome: string; opencodexHome: string }; + statePaths?: readonly string[]; + }> = []; + const server = startServer(0, { + resolveServiceHomes: () => { + homeResolutions += 1; + if (!homesReady) throw new Error("service homes are not mounted yet"); + return { + codexHome: f.codexHome, + opencodexHome: statePathsReady ? f.configDir : (null as unknown as string), + }; + }, + inspectNativeCodexOwnership: (scope = {}) => { + scopes.push({ + currentHomes: scope.currentHomes ? { ...scope.currentHomes } : undefined, + statePaths: scope.statePaths ? [...scope.statePaths] : undefined, + }); + return { ownership: answer, reason: "deferred startup scope test" }; + }, + nativeMainStartup: { + manager: f.manager, + beforeRecovery: () => recoveryBarrier, + owner: { retryMs: 10, hardenPath: async () => {} }, + }, + }); + try { + expect(homeResolutions).toBe(1); + expect(scopes).toHaveLength(0); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: null, + reason: "ownership-unknown", + }); + + homesReady = true; + answer = "owned"; + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + expect(homeResolutions).toBe(2); + expect(scopes).toHaveLength(0); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: null, + reason: "ownership-unknown", + }); + + statePathsReady = true; + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + expect(homeResolutions).toBe(3); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: f.manager.context.homeId, + reason: "recovery-pending", + }); + + const pinnedScopes = scopes.filter(scope => scope.currentHomes !== undefined); + expect(pinnedScopes.length).toBeGreaterThanOrEqual(1); + for (const scope of pinnedScopes) { + expect(scope.currentHomes).toEqual({ + codexHome: f.codexHome, + opencodexHome: f.configDir, + }); + expect(scope.statePaths?.[0]).toBe(join(f.configDir, "service-state.json")); + } + + finishRecovery(); + expect(await waitForNativeMainStartupGate()).toEqual({ + status: "ready", + homeId: f.manager.context.homeId, + }); + const allowed = tryAcquireNativeMainProfileClaim(); + expect(allowed).not.toBeNull(); + allowed?.release(); + } finally { + finishRecovery(); + await server.stop(true); + } + }); + + test("startServer retries lifecycle preparation after a pinned home appears", async () => { + const f = await fixture("prepared", "source-exact", false, "direct"); + const lateCodexHome = join(f.root, "late-codex-home"); + process.env.CODEX_HOME = lateCodexHome; + process.env.OPENCODEX_HOME = f.configDir; + let answer: NativeCodexOwnership = "unknown"; + let finishRecovery!: () => void; + const recoveryBarrier = new Promise(resolve => { finishRecovery = resolve; }); + const server = startServer(0, { + inspectNativeCodexOwnership: scope => ({ + ownership: answer, + reason: scope.currentHomes?.codexHome === lateCodexHome + ? "pinned missing home test" + : "unexpected startup scope", + }), + nativeMainStartup: { + beforeRecovery: () => recoveryBarrier, + owner: { retryMs: 10, hardenPath: async () => {} }, + }, + }); + try { + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: null, + reason: "ownership-unknown", + }); + + answer = "owned"; + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: null, + reason: "ownership-unknown", + }); + + mkdirSync(lateCodexHome, { recursive: true }); + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + const pending = nativeMainStartupGateSnapshot(); + expect(pending).toMatchObject({ status: "blocked", reason: "recovery-pending" }); + expect(pending.homeId).not.toBeNull(); + + finishRecovery(); + expect(await waitForNativeMainStartupGate()).toEqual({ + status: "ready", + homeId: pending.homeId, + }); + const allowed = tryAcquireNativeMainProfileClaim(); + expect(allowed).not.toBeNull(); + allowed?.release(); + } finally { + finishRecovery(); + await server.stop(true); + } + }); + + test("an owned activation failure keeps the fence and retry hook intact", () => { + let activationFails = true; + let activations = 0; + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned", + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + activations += 1; + if (activationFails) throw new Error("test activation failure"); + return startReadyOwnershipRetryLifecycle(); + }, + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(true); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: null, + reason: "ownership-unknown", + }); + + activationFails = false; + expect(isNativeMainTrafficBlocked()).toBe(false); + expect(activations).toBe(2); + } finally { + void fence.release(); + } + }); + + test("activation re-entry stays fenced and starts the owned lifecycle once", () => { + let activations = 0; + let reentrantBlocked: boolean | undefined; + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned", + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + activations += 1; + reentrantBlocked = isNativeMainTrafficBlocked(); + return startReadyOwnershipRetryLifecycle(); + }, + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(false); + expect(reentrantBlocked).toBe(true); + expect(activations).toBe(1); + } finally { + void fence.release(); + } + }); + + test("probe re-entry stays fenced and consumes one bounded attempt", () => { + let probes = 0; + let activations = 0; + let reentrantBlocked: boolean | undefined; + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => { + probes += 1; + reentrantBlocked = isNativeMainTrafficBlocked(); + return "owned"; + }, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + activations += 1; + return startReadyOwnershipRetryLifecycle(); + }, + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(false); + expect(reentrantBlocked).toBe(true); + expect(probes).toBe(1); + expect(activations).toBe(1); + } finally { + void fence.release(); + } + }); + + test("preparing an injected manager rejects a different pinned home", async () => { + const one = await fixture("prepared", "source-exact"); + const other = await fixture("prepared", "source-exact"); + expect(prepareNativeMainStartupLifecycle( + { manager: one.manager }, + { codexHome: other.codexHome, configDir: other.configDir }, + )).toBeNull(); + }); + + test("stale activation cleanup is joined by the wrapper release flight", async () => { + let finishOrphanRelease!: () => void; + const orphanBarrier = new Promise(resolve => { finishOrphanRelease = resolve; }); + let orphanReleases = 0; + let fence!: NativeMainStartupLifecycle; + fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned", + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + void fence.release(); + return { + homeId: OWNERSHIP_REPROBE_TEST_HOME, + settled: Promise.resolve({ status: "ready", homeId: OWNERSHIP_REPROBE_TEST_HOME }), + release: async () => { + orphanReleases += 1; + await orphanBarrier; + }, + }; + }, + }); + expect(isNativeMainTrafficBlocked()).toBe(true); + let releaseSettled = false; + const release = fence.release().then(() => { releaseSettled = true; }); + await Bun.sleep(0); + expect(orphanReleases).toBe(1); + expect(releaseSettled).toBe(false); + finishOrphanRelease(); + await release; + expect(releaseSettled).toBe(true); + }); + + test("server lifecycle cleanup callers join one release flight", async () => { + const server = {}; + let finishRelease!: () => void; + const releaseBarrier = new Promise(resolve => { finishRelease = resolve; }); + let releases = 0; + bindNativeMainStartupLifecycle(server, { + homeId: OWNERSHIP_REPROBE_TEST_HOME, + settled: Promise.resolve({ status: "ready", homeId: OWNERSHIP_REPROBE_TEST_HOME }), + release: async () => { + releases += 1; + await releaseBarrier; + }, + }); + let secondSettled = false; + const first = releaseNativeMainStartupLifecycle(server); + const second = releaseNativeMainStartupLifecycle(server).then(() => { secondSettled = true; }); + await Bun.sleep(0); + expect(releases).toBe(1); + expect(secondSettled).toBe(false); + finishRelease(); + await Promise.all([first, second]); + expect(releases).toBe(1); + await releaseNativeMainStartupLifecycle(server); + expect(releases).toBe(1); + }); + + test("a null or mismatched owned lifecycle never spends the unknown fence", async () => { + for (const homeId of [null, "different-home"] as const) { + let releases = 0; + const gate = { status: "ready", homeId } as const; + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned", + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => ({ + homeId, + settled: Promise.resolve(gate), + release: async () => { releases += 1; }, + }), + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(true); + await Promise.resolve(); + expect(releases).toBe(1); + expect(nativeMainStartupGateSnapshot()).toMatchObject({ + status: "blocked", + reason: "ownership-unknown", + }); + } finally { + await fence.release(); + } + } + }); + test("a foreign owner is a fact, not a question — it never retries", () => { let asked = 0; + let activations = 0; const fence = blockNativeMainStartupForUnownedServiceHome("foreign-ownership", { reprobe: () => { asked += 1; return "owned"; }, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + activations += 1; + return startReadyOwnershipRetryLifecycle(); + }, }); try { expect(isNativeMainTrafficBlocked()).toBe(true); expect(isNativeMainTrafficBlocked()).toBe(true); expect(asked).toBe(0); + expect(activations).toBe(0); } finally { void fence.release(); } @@ -677,14 +1173,21 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { test("a host that stays unaskable stops being asked", () => { let asked = 0; + let activations = 0; const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => { asked += 1; return "unknown"; }, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + activations += 1; + return startReadyOwnershipRetryLifecycle(); + }, }); try { for (let i = 0; i < 25; i++) isNativeMainTrafficBlocked(); expect(isNativeMainTrafficBlocked()).toBe(true); - expect(asked).toBeLessThanOrEqual(NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT); + expect(asked).toBe(NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT); + expect(activations).toBe(0); } finally { void fence.release(); } @@ -712,11 +1215,19 @@ describe("the retryable fence respects its own refcount (#2108)", () => { test("raising a second fence does not hand out a fresh retry budget", () => { let asked = 0; const probe = () => { asked += 1; return "unknown" as NativeCodexOwnership; }; - const first = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: probe }); + const first = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: probe, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, + }); for (let i = 0; i < 20; i++) isNativeMainTrafficBlocked(); const afterFirst = asked; - const second = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: probe }); + const second = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: probe, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, + }); try { for (let i = 0; i < 20; i++) isNativeMainTrafficBlocked(); @@ -732,6 +1243,8 @@ describe("the retryable fence respects its own refcount (#2108)", () => { const hookless = blockNativeMainStartupForUnownedServiceHome("ownership-unknown"); const hooked = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => "owned" as NativeCodexOwnership, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, }); try { isNativeMainTrafficBlocked(); @@ -758,6 +1271,8 @@ describe("a foreign fence is never reopened by a probe (#2108)", () => { test("a foreign fence stays closed even when the host reports owned", () => { const fence = blockNativeMainStartupForUnownedServiceHome("foreign-ownership", { reprobe: () => "owned" as NativeCodexOwnership, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { throw new Error("foreign ownership must never activate"); }, }); try { for (let i = 0; i < 10; i++) isNativeMainTrafficBlocked(); @@ -785,6 +1300,8 @@ describe("a spent reprobe leaves the refcount coherent (#2108)", () => { const hookless = blockNativeMainStartupForUnownedServiceHome("ownership-unknown"); const hooked = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => "owned" as NativeCodexOwnership, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, }); try { isNativeMainTrafficBlocked(); @@ -799,21 +1316,35 @@ describe("a spent reprobe leaves the refcount coherent (#2108)", () => { }); test("a fence raised after a spent probe still gets to re-ask", () => { + let firstStarts = 0; const first = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => "owned" as NativeCodexOwnership, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + firstStarts += 1; + return startReadyOwnershipRetryLifecycle(); + }, }); isNativeMainTrafficBlocked(); void first.release(); let asked = 0; + let laterStarts = 0; const later = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => { asked += 1; return "owned" as NativeCodexOwnership; }, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + laterStarts += 1; + return startReadyOwnershipRetryLifecycle(); + }, }); try { isNativeMainTrafficBlocked(); // A server started after an earlier probe must not be stuck needing `ocx restart`. expect(asked).toBeGreaterThan(0); + expect(firstStarts).toBe(1); + expect(laterStarts).toBe(1); expect(isNativeMainTrafficBlocked()).toBe(false); } finally { void later.release(); @@ -828,6 +1359,8 @@ describe("a spent reprobe leaves the refcount coherent (#2108)", () => { let asked = 0; const owner = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => { asked += 1; return "owned" as NativeCodexOwnership; }, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, }); const other = blockNativeMainStartupForUnownedServiceHome("ownership-unknown"); try { diff --git a/tests/service.test.ts b/tests/service.test.ts index 69ef8209db8..29712adca95 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -58,6 +58,14 @@ function expectTextToContainPath(text: string, path: string): void { } describe("service listen-port bake", () => { + test("service ownership state paths stay pinned to the captured OpenCodex home", () => { + const pinned = join(TEST_DIR, "pinned-opencodex"); + process.env.OPENCODEX_HOME = join(TEST_DIR, "ambient-opencodex"); + const paths = serviceModule.serviceStatePathsForOpenCodexHome(pinned); + expect(paths[0]).toBe(join(pinned, "service-state.json")); + expect(paths).not.toContain(join(process.env.OPENCODEX_HOME, "service-state.json")); + }); + test("resolveServiceListenPort prefers override, then OCX_BAKE_PORT, then config", () => { process.env.OPENCODEX_HOME = TEST_DIR; mkdirSync(TEST_DIR, { recursive: true });