diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index e65b9c74cfe..f27aaea4cde 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -94,6 +94,14 @@ runtime the leak itself remains an upstream problem: #32111 fix; today it is opt-in only (see below). On macOS it stays opt-in even after such a release — flipping macOS `auto` is a separate decision. +Catalog metadata reads use the selected runtime's last confirmed bundled catalog. +Cold or changed selections refresh through asynchronous version/model probes, so +those probes do not hold the proxy event loop. Shared refreshes have a 45-second +deadline; failed request-triggered refreshes retain matching rows and apply a +60-second retry cooldown. Request abort releases translator accounting even +when a response body is unread. These liveness changes do not establish that +Windows native stream retention ([#6671](https://github.com/lidge-jun/opencodex/issues/6671)) is resolved. + Real-world RSS improvement from these changes is **awaiting verification by Windows users** — we do not claim the leak is fixed. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5f226f55b41..ad4b833e824 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1802,7 +1802,7 @@ "tool-catalog-nudge.test.ts": "adapters", "tool-choice-performance.test.ts": "adapters", "transient-budget-scope-source.test.ts": "lib", - "translator-budget.test.ts": "adapters", + "translator-budget.test.ts": "adapters", "translator-budget-lifetime.test.ts": "adapters", "catalog-async-lifetime.test.ts": "codex-integration", "transport-null-body.test.ts": "lib", "tray-proxy-deadline.test.ts": "windows", "tray-proxy.test.ts": "windows", diff --git a/src/codex/catalog-auto-refresh-sources.ts b/src/codex/catalog-auto-refresh-sources.ts index d23a92c5d8c..5f346b3a207 100644 --- a/src/codex/catalog-auto-refresh-sources.ts +++ b/src/codex/catalog-auto-refresh-sources.ts @@ -34,10 +34,9 @@ export async function refreshCatalogAutoRefreshSources( current: () => boolean, ): Promise { // Runtime selection also supplies the roster's trusted client version after an upgrade. - await bestEffortSource(async active => { - const { loadBundledCodexCatalog } = await import("./catalog/bundled"); - if (active() && current()) loadBundledCodexCatalog(); - // The synchronous loader bounds its selected-runtime subprocess probes itself. + await bestEffortSource(async (active, signal) => { + const { loadBundledCodexCatalogAsync } = await import("./catalog/bundled"); + if (active() && current()) await loadBundledCodexCatalogAsync({}, () => active() && current(), signal); }); if (!current()) return; await bestEffortSource(async active => { diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index 766c9d99fcd..795858214fb 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -39,6 +39,10 @@ import { parsePersistedCodexRuntime, peekCodexRuntimeProcessCache, resolveAndPersistCodexRuntime, + resolveAndPersistCodexRuntimeAsync, + codexRuntimeSelectionIdentity, + codexRuntimeStateEpoch, + execCodexFileAsync, } from "../runtime"; import type { DeepReadonly, @@ -63,7 +67,9 @@ export type ReadonlyRawCatalog = DeepReadonly; interface BundledCatalogMemo { /** Selected runtime identity; must change when doctor/sync picks a different binary. */ readonly key: string; + readonly inputs: string; readonly expiresAt: number; + readonly refreshAt: number; readonly epoch: number; readonly valueIdentity: string; readonly value: ReadonlyRawCatalog | null; @@ -76,6 +82,9 @@ export interface BundledCatalogCacheState { let bundledCatalogEpoch = 0; let bundledCatalogCache: BundledCatalogMemo | null = null; +let bundledCatalogRetry: { inputs: string; epoch: number; after: number } | null = null; +let bundledCatalogFlight: { inputs: string; epoch: number; controller: AbortController; promise: Promise } | null = null; +const catalogAlwaysCurrent = () => true; function cloneAndDeepFreeze(value: T): DeepReadonly { const clone = (current: unknown): unknown => { @@ -110,14 +119,18 @@ function publishBundledCatalogCache( const epoch = ++bundledCatalogEpoch; bundledCatalogCache = { key, + inputs: codexRuntimeSelectionIdentity({ discoverAlternatives: false }), expiresAt, + refreshAt: Math.min(expiresAt, Date.now() + 15_000), epoch, valueIdentity: `bundled:${epoch}`, value: value === null ? null : cloneAndDeepFreeze(value), }; + bundledCatalogRetry = null; } function clearBundledCatalogCache(): void { + bundledCatalogFlight?.controller.abort(); bundledCatalogEpoch += 1; bundledCatalogCache = null; } @@ -162,12 +175,17 @@ export type ExecFile = ( windowsHide: boolean; shell?: boolean; windowsVerbatimArguments?: boolean; + signal?: AbortSignal; + maxBuffer?: number; }, ) => string; export interface BundledCatalogDeps { + /** Already-observed source for server clamping; null never triggers a probe. */ + observedCatalog?: ReadonlyRawCatalog | null; commandCandidates?: () => string[]; execFileSync?: ExecFile; + execFile?: (...args: Parameters) => Promise; onEffortClamp?: (diagnostic: EffortClampDiagnostic) => void; configDir?: string; env?: NodeJS.ProcessEnv; @@ -224,11 +242,11 @@ export function codexShimCommandCandidates(): string[] { } } -export function runCodexDebugModels( +export function runCodexDebugModels( command: string, - execFile: ExecFile, + execFile: (...args: Parameters) => T, deps: Pick = {}, -): string { +): T { const args = ["debug", "models", "--bundled"]; const invocation = codexExecInvocation(command, args, deps.platform ?? process.platform, { env: deps.env, @@ -238,23 +256,19 @@ export function runCodexDebugModels( encoding: "utf8" as const, stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"], timeout: 10_000, + maxBuffer: 64 * 1024 * 1024, windowsHide: true, ...invocation.options, }); } -export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): ReadonlyRawCatalog | null { - const useCache = !deps.commandCandidates && !deps.execFileSync && !deps.configDir && !deps.env; - const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile); - // Prefer the single resolved runtime so sync/clamp never probe a different binary - // than OpenCodex will launch. Tests may inject commandCandidates to stub probing. - let cacheKey: string | null = null; - const candidates = deps.commandCandidates?.() ?? (() => { - const resolved = resolveAndPersistCodexRuntime({ +function bundledRuntimeDeps(deps: BundledCatalogDeps) { + return { // Forward an INJECTED execFileSync only. Passing the real one unconditionally made // resolveCacheKey() bail out (it refuses to memoize injected-dep resolves), so every // catalog read re-ran the ~1s `codex --version` probe even on a warm cache hit. ...(deps.execFileSync ? { execFileSync: deps.execFileSync } : {}), + ...(deps.execFile ? { execFile: deps.execFile } : {}), configDir: deps.configDir, env: deps.env, platform: deps.platform, @@ -266,28 +280,41 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): Readonly // which alone can exceed the 3s budget `ocx claude` allows /api/claude-code. Priority // selection is identical either way; callers wanting discovery diagnostics opt in. discoverAlternatives: deps.discoverAlternatives ?? false, - }); - if (useCache) { - cacheKey = bundledRuntimeKey(resolved.runtime); - } - return [resolved.runtime.command]; - })(); + }; +} + +function* bundledCatalogSteps( + candidates: string[], + cacheKey: string | null, + current: () => boolean = () => true, + publishFailure = true, +): Generator { + if (cacheKey && bundledCatalogCache?.key === cacheKey && current()) { + // The driver validated this same command/version under the new pin/env inputs. + // Re-stamp the memo even if its TTL is due, so failed refresh keeps confirmed rows. + bundledCatalogCache = { ...bundledCatalogCache, + inputs: codexRuntimeSelectionIdentity({ discoverAlternatives: false }) }; + } if ( - useCache - && cacheKey + cacheKey && bundledCatalogCache && bundledCatalogCache.key === cacheKey + && bundledCatalogCache.inputs === codexRuntimeSelectionIdentity({ discoverAlternatives: false }) && bundledCatalogCache.expiresAt > Date.now() ) { + if (!current()) return null; + bundledCatalogCache = { ...bundledCatalogCache, refreshAt: Date.now() + 15_000 }; return bundledCatalogCache.value === null ? null : cloneAndDeepFreeze(bundledCatalogCache.value); } for (const command of unique(candidates)) { - try { - const catalog = parseCatalogJson(runCodexDebugModels(command, execFile, deps)); + if (!current()) return null; + const output = yield command; + if (!current()) return null; + const catalog = output === null ? null : parseCatalogJson(output); if (catalog && findNativeTemplate(catalog)) { - if (useCache && cacheKey) { + if (cacheKey) { publishBundledCatalogCache( cacheKey, Date.now() + BUNDLED_CATALOG_CACHE_MS, @@ -297,9 +324,8 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): Readonly } return cloneAndDeepFreeze(catalog); } - } catch { /* try next candidate */ } } - if (useCache && cacheKey) { + if (cacheKey && (publishFailure || bundledCatalogCache?.key !== cacheKey || !bundledCatalogCache?.value) && current()) { publishBundledCatalogCache( cacheKey, Date.now() + BUNDLED_CATALOG_CACHE_MS, @@ -309,6 +335,109 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): Readonly return null; } +/** Explicit synchronous CLI driver; request readers use the snapshot below. */ +export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): ReadonlyRawCatalog | null { + const useCache = !deps.commandCandidates && !deps.execFileSync && !deps.execFile && !deps.configDir && !deps.env; + const resolved = deps.commandCandidates ? null : resolveAndPersistCodexRuntime(bundledRuntimeDeps(deps)); + const inputs = codexRuntimeSelectionIdentity(bundledRuntimeDeps(deps)); + const steps = bundledCatalogSteps(deps.commandCandidates?.() ?? [resolved!.runtime.command], + useCache ? bundledRuntimeKey(resolved!.runtime) : null, + () => inputs === codexRuntimeSelectionIdentity(bundledRuntimeDeps(deps))); + let step = steps.next(); + while (!step.done) { + let output: string | null = null; + try { output = runCodexDebugModels(step.value, deps.execFileSync ?? (execFileSync as unknown as ExecFile), deps); } + catch { /* try next candidate */ } + step = steps.next(output); + } + return step.value; +} + +/** Same candidate/parser authority, with version and bundled-model execs off the event loop. */ +export function loadBundledCodexCatalogAsync( + deps: BundledCatalogDeps = {}, + isCurrent: () => boolean = catalogAlwaysCurrent, + signal?: AbortSignal, +): Promise { + if (signal?.aborted || !isCurrent()) return Promise.resolve(null); + const view = (promise: Promise) => { + if (!signal) return promise.then(value => isCurrent() ? value : null); + let abort: (() => void) | undefined; + return Promise.race([promise, new Promise(resolve => { + abort = () => resolve(null); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + })]).then(value => isCurrent() && !signal.aborted ? value : null) + .finally(() => { if (abort) signal.removeEventListener("abort", abort); }); + }; + const runtimeDeps = bundledRuntimeDeps(deps); + const inputs = codexRuntimeSelectionIdentity(runtimeDeps); + const epoch = bundledCatalogEpoch; + const useCache = !deps.commandCandidates && !deps.execFileSync && !deps.execFile && !deps.configDir && !deps.env; + if (useCache && bundledCatalogFlight?.inputs === inputs && bundledCatalogFlight.epoch === epoch) { + return view(bundledCatalogFlight.promise); + } + const controller = new AbortController(); + const flight = { inputs, epoch, controller, promise: Promise.resolve(null) }; + if (useCache && !signal && isCurrent === catalogAlwaysCurrent) { + bundledCatalogFlight?.controller.abort(); + bundledCatalogFlight = flight; + } + const abort = () => controller.abort(); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + let timer: ReturnType | undefined; + let deadlineReached = false; + const work = (async () => { + const active = () => !controller.signal.aborted && isCurrent() && bundledCatalogEpoch === epoch; + const resolved = deps.commandCandidates ? null : await resolveAndPersistCodexRuntimeAsync({ ...runtimeDeps, signal: controller.signal }, + () => active() && codexRuntimeSelectionIdentity(runtimeDeps) === inputs); + if (!active() || (!deps.commandCandidates && !resolved)) return null; + flight.inputs = codexRuntimeSelectionIdentity(runtimeDeps); + const runtimeEpoch = codexRuntimeStateEpoch(); + const current = () => active() && runtimeEpoch === codexRuntimeStateEpoch() + && flight.inputs === codexRuntimeSelectionIdentity(runtimeDeps); + const steps = bundledCatalogSteps(deps.commandCandidates?.() ?? [resolved!.runtime.command], + useCache ? bundledRuntimeKey(resolved!.runtime) : null, current, false); + let step = steps.next(); + while (!step.done) { + let output: string | null = null; + try { output = await runCodexDebugModels(step.value, + (file, args, options) => (deps.execFile ?? execCodexFileAsync)(file, args, { ...options, signal: controller.signal }), deps); } + catch { /* preserve last confirmed evidence on failure */ } + step = steps.next(output); + } + return step.value; + })().catch(() => null); + flight.promise = Promise.race([work, new Promise(resolve => { + timer = setTimeout(() => { deadlineReached = true; controller.abort(); resolve(null); }, 45_000); + timer.unref?.(); + })]).then(value => { + if (useCache && value === null && (!controller.signal.aborted || deadlineReached) + && isCurrent() && bundledCatalogEpoch === epoch + && flight.inputs === codexRuntimeSelectionIdentity(runtimeDeps)) { + bundledCatalogRetry = { inputs: flight.inputs, epoch, after: Date.now() + BUNDLED_CATALOG_CACHE_MS }; + } + return value; + }).finally(() => { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + controller.abort(); + if (bundledCatalogFlight === flight) bundledCatalogFlight = null; + }); + return view(flight.promise); +} + +/** Serve only the same selection's last confirmed catalog; refresh never holds a request. */ +export function bundledCodexCatalogSnapshot(): ReadonlyRawCatalog | null { + const inputs = codexRuntimeSelectionIdentity({ discoverAlternatives: false }); + const memo = bundledCatalogCache?.inputs === inputs ? bundledCatalogCache : null; + const cooling = bundledCatalogRetry?.inputs === inputs && bundledCatalogRetry.epoch === bundledCatalogEpoch + && bundledCatalogRetry.after > Date.now(); + if ((!memo || memo.refreshAt <= Date.now()) && !cooling) void loadBundledCodexCatalogAsync().catch(() => { /* bounded retry */ }); + return memo?.value ? cloneAndDeepFreeze(memo.value) : null; +} + export type CatalogGatherProcessLocalObservation = | Readonly<{ state: "unused" }> | Readonly<{ state: "used"; epoch: number; valueIdentity: string }>; @@ -524,7 +653,7 @@ export function loadCatalogForSync(path: string): RawCatalog | null { export function readCurrentCatalogOrCache(): RawCatalog | null { const path = readCodexCatalogPath(); - const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null; + const bundled = isDefaultCatalogPath(path) ? bundledCodexCatalogSnapshot() : null; if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog; return readCatalog(path) ?? readCatalog(activeCodexModelsCachePath()); } @@ -547,7 +676,7 @@ export function readCurrentCodexModelsCache(): RawCatalog | null { export function loadCatalogTemplate(): RawEntry | null { const catalogPath = readCodexCatalogPath(); - const bundled = loadBundledCodexCatalog(); + const bundled = bundledCodexCatalogSnapshot(); // Template inheritance only. The validity gates in this file keep `findNativeTemplate` // so a catalog carrying only a newly launched native row stays valid (#2813). const native = findSupportedNativeTemplate(readCatalog(catalogPath)) diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 5d4152210ec..d865860fe85 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -47,6 +47,7 @@ import { displayCodexRuntimePath, persistEffortClamp, resolveAndPersistCodexRuntime, + getCodexRuntimeSnapshot, UNCLAMPABLE_REASONING_EFFORTS, type EffortClampDiagnostic, } from "../runtime"; @@ -366,7 +367,9 @@ export function supportedCodexReasoningEffortsFromObservedCatalog( } export function codexSupportedReasoningEfforts(deps: BundledCatalogDeps = {}): ReadonlySet | null { - return supportedCodexReasoningEffortsFromObservedCatalog(loadBundledCodexCatalog(deps)); + return supportedCodexReasoningEffortsFromObservedCatalog( + deps.observedCatalog !== undefined ? deps.observedCatalog : loadBundledCodexCatalog(deps), + ); } export function clampedDefaultEffort(original: string, surviving: readonly string[]): string { @@ -558,7 +561,11 @@ export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: Bundl let runtimePath = "codex"; let runtimeVersion: string | null = null; - if (!deps.commandCandidates) { + if (deps.observedCatalog !== undefined && !deps.commandCandidates) { + const snapshot = getCodexRuntimeSnapshot(); + runtimePath = snapshot.runtime.command; + runtimeVersion = snapshot.runtime.version; + } else if (!deps.commandCandidates) { try { const resolved = resolveAndPersistCodexRuntime({ execFileSync: deps.execFileSync, diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 68164a6d156..2f205fe7a55 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -54,7 +54,7 @@ import { upstreamNativeEntry, } from "./metadata"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; -import { bundledCatalogCacheState, loadBundledCodexCatalog } from "./bundled"; +import { bundledCatalogCacheState, bundledCodexCatalogSnapshot, loadBundledCodexCatalogAsync } from "./bundled"; import { isMultiAgentV2Enabled } from "../features"; import { clampCatalogModelsToCodexSupport } from "./effort"; import { suppressedSyntheticMaxCatalogSlugs } from "./model-hints"; @@ -155,7 +155,7 @@ function optionalFileBytes(path: string): string | null { } function loadCatalogForRetainedSync(path: string): RawCatalog | null { - const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null; + const bundled = isDefaultCatalogPath(path) ? bundledCodexCatalogSnapshot() : null; if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog; const active = readCatalog(path); // A valid configured custom file remains the content authority even when it has no bare native @@ -542,7 +542,7 @@ function writeRetainedCatalogSync({ }, }); applyNativeAccessPrograms(catalog.models, modelEntitlements, accountTargets); - clampCatalogModelsToCodexSupport(catalog.models); + clampCatalogModelsToCodexSupport(catalog.models, { observedCatalog: bundledCodexCatalogSnapshot() }); finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config); // Last mutation before serialization; see `enforceCatalogSlugUniqueness` for why the ordering // against the effort clamp is load-bearing rather than cosmetic. @@ -623,6 +623,8 @@ export async function syncCatalogModels( await resolvePendingInitialModelSelection(config); } const owningCodexHome = getCodexHome(); + // Settle before reading content and its matching evidence; no pre-refresh catalog is reused. + await loadBundledCodexCatalogAsync(); const preflightRead = readRetainedCatalogSync(config); if (preflightRead === null) { return { @@ -643,7 +645,6 @@ export async function syncCatalogModels( // The persisted runtime selection is covered by the filesystem evidence above // rather than by a process epoch; see `retainedCatalogProcessEvidence` for why // the in-memory runtime memo cannot be baselined honestly from this path. - loadBundledCodexCatalog(); const prepared: RetainedCatalogSyncRead = { ...preflightRead, evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog), diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 27ce62c5a6f..d7c9fe4f5b5 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -7,6 +7,7 @@ import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocatio import { resolveCodexHomeDir } from "./home"; import { decodeOverlayState } from "./shim-state-file"; import { redactSecretString, redactUserPath } from "../lib/redact"; +import { SUBPROCESS_KILL_GRACE_MS, waitForSubprocessExit } from "../lib/bounded-subprocess"; export type CodexRuntimeSource = | "environment" @@ -68,6 +69,8 @@ export type RuntimeExecFile = ( shell?: boolean; windowsVerbatimArguments?: boolean; env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + maxBuffer?: number; }, ) => string; @@ -77,6 +80,7 @@ export type RuntimeExecFileAsync = ( ) => Promise; export interface ResolveCodexRuntimeDeps { + signal?: AbortSignal; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; configDir?: string; @@ -487,23 +491,62 @@ function probeVersion(command: string, deps: ResolveCodexRuntimeDeps): VersionPr } } -const defaultExecFileAsync: RuntimeExecFileAsync = (file, args, options) => +export const execCodexFileAsync: RuntimeExecFileAsync = (file, args, options) => new Promise((resolve, reject) => { + if (options.signal?.aborted) { reject(new Error("Codex subprocess aborted")); return; } // execFile always pipes stdout/stderr; it takes no stdio option. - const { stdio: _stdio, ...execOptions } = options; - execFile(file, args, execOptions, (error, stdout) => (error ? reject(error) : resolve(String(stdout)))); + // Own abort/deadline termination; execFile's built-in observer can settle the + // callback while a SIGTERM-ignoring child is still alive. + const { stdio: _stdio, signal: _signal, timeout: _timeout, ...execOptions } = options; + let settled = false; + let timer: ReturnType | undefined; + let abort = () => {}; + const finish = (error: Error | null, stdout = "") => { + if (settled) return; + settled = true; + clearTimeout(timer); + options.signal?.removeEventListener("abort", abort); + if (error) reject(error); else resolve(stdout); + }; + const child = execFile(file, args, execOptions, (error, stdout) => { + if (error && child.exitCode === null && child.signalCode === null) stop(error); + else finish(error, String(stdout)); + }); + const exited = new Promise(resolveExit => { + child.once("exit", code => resolveExit(code ?? 1)); + // A kill error is not exit proof; only failed spawn has no child to reap. + child.once("error", () => { if (!child.pid) resolveExit(1); }); + }); + // A reaper owns actual exit independently of caller-facing promise settlement. + let stopping = false; + const stop = (error: Error) => { + if (stopping) return; + stopping = true; + try { child.kill(); } catch { /* exit/error observation still owns reaping */ } + child.stdout?.destroy(); + child.stderr?.destroy(); + void waitForSubprocessExit({ exited, kill: () => child.kill("SIGKILL") }, SUBPROCESS_KILL_GRACE_MS); + finish(error); + }; + abort = () => stop(new Error("Codex subprocess aborted")); + options.signal?.addEventListener("abort", abort, { once: true }); + if (options.timeout > 0) { + timer = setTimeout(() => stop(new Error("Codex subprocess deadline exceeded")), options.timeout); + timer.unref?.(); + } + if (options.signal?.aborted) abort(); }); /** probeVersion with the same sandbox and outcomes, but the exec never holds the event loop. */ async function probeVersionAsync(command: string, deps: ResolveCodexRuntimeDeps): Promise { const settled = versionProbePrecheck(command, deps); if (settled) return settled; - const exec = deps.execFile ?? defaultExecFileAsync; + const exec = deps.execFile ?? execCodexFileAsync; let probeHome: string | undefined; try { probeHome = mkdtempSync(join(tmpdir(), "ocx-codex-probe-")); const { file, args, options } = versionProbeInvocation(command, deps, probeHome); - return versionProbeOutput(await exec(file, args, options)); + return versionProbeOutput(await exec(file, args, { ...options, signal: deps.signal })); } catch (error) { return versionProbeError(error, probeHome); } finally { @@ -782,9 +825,7 @@ function persistedRuntimeCacheStamp(deps: ResolveCodexRuntimeDeps): string { const configDir = deps.configDir ?? getConfigDir(); const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding)); try { - const raw = parsePersistedCodexRuntime(read(codexRuntimeStatePath(configDir), "utf8")); - if (!raw) return ""; - return `${raw.command}|${raw.selectedVersion ?? ""}|${raw.updatedAt ?? ""}`; + return read(codexRuntimeStatePath(configDir), "utf8"); } catch { return ""; } @@ -793,7 +834,8 @@ function persistedRuntimeCacheStamp(deps: ResolveCodexRuntimeDeps): string { function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null { // Only memoize uninjected process-env resolves (settings/status hot paths). if ( - deps.execFileSync + deps.signal + || deps.execFileSync || deps.execFile || deps.existsSync || deps.readFileSync @@ -804,8 +846,14 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null { ) { return null; } + return codexRuntimeSelectionIdentity(deps); +} + +/** Exec-free input identity, including pin changes made by another process. */ +export function codexRuntimeSelectionIdentity(deps: ResolveCodexRuntimeDeps = {}): string { const env = deps.env ?? process.env; return JSON.stringify({ + configDir: deps.configDir ?? getConfigDir(), cli: env.CODEX_CLI_PATH ?? "", path: env.PATH ?? "", platform: deps.platform ?? process.platform, @@ -859,7 +907,12 @@ export function resolveCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): Resolve return cloneAndDeepFreeze(result); } -let asyncResolveInflight: { key: string; epoch: number; promise: Promise } | null = null; +interface AsyncRuntimeObservation { + readonly result: ResolveCodexRuntimeResult; + readonly inputs: string; + readonly epoch: number | null; +} +let asyncResolveInflight: { key: string; epoch: number; promise: Promise } | null = null; /** * resolveCodexRuntime for server request paths: same selection and process memo, but every @@ -869,16 +922,29 @@ let asyncResolveInflight: { key: string; epoch: number; promise: Promise { + return cloneAndDeepFreeze((await resolveCodexRuntimeObservationAsync(deps)).result); +} + +async function resolveCodexRuntimeObservationAsync(deps: ResolveCodexRuntimeDeps): Promise { + const inputs = codexRuntimeSelectionIdentity(deps); + const observed = (result: ResolveCodexRuntimeResult, epoch: number | null): AsyncRuntimeObservation => ({ result, inputs, epoch }); // A deferred selection never execs, so the sync path is already nonblocking. - if (deps.probeVersion === false) return resolveCodexRuntime(deps); - const cacheKey = resolveCacheKey(deps); + if (deps.probeVersion === false) return observed(resolveCodexRuntime(deps), resolveCacheEpoch); + const cacheKey = resolveCacheKey({ ...deps, signal: undefined }); if (cacheKey && resolveCache && resolveCache.key === cacheKey && Date.now() - resolveCache.at < RESOLVE_CACHE_MS) { - return cloneAndDeepFreeze(resolveCache.value); + return observed(cloneAndDeepFreeze(resolveCache.value), resolveCacheEpoch); } - if (!cacheKey) return cloneAndDeepFreeze(await resolveCodexRuntimeUncachedAsync(deps)); const startedEpoch = resolveCacheEpoch; + // A cancellable miss owns its probes; a warm memo remains usable without a new child. + if (!cacheKey || deps.signal) { + const result = await resolveCodexRuntimeUncachedAsync(deps); + const current = !deps.signal?.aborted && resolveCacheEpoch === startedEpoch + && codexRuntimeSelectionIdentity(deps) === inputs; + if (current && cacheKey) publishResolveCache(cacheKey, Date.now(), result); + return observed(result, current ? resolveCacheEpoch : null); + } if (asyncResolveInflight?.key === cacheKey && asyncResolveInflight.epoch === startedEpoch) { - return cloneAndDeepFreeze(await asyncResolveInflight.promise); + return asyncResolveInflight.promise; } const promise = resolveCodexRuntimeUncachedAsync(deps).then(result => { // A persist or clear while the probes ran makes this answer the previous selection's; @@ -886,13 +952,14 @@ export async function resolveCodexRuntimeAsync( // rewrite codex-runtime.json without touching this epoch, so the key is read again too. if (resolveCacheEpoch === startedEpoch && resolveCacheKey(deps) === cacheKey) { publishResolveCache(cacheKey, Date.now(), result); + return observed(result, resolveCacheEpoch); } - return result; + return observed(result, null); }).finally(() => { if (asyncResolveInflight?.promise === promise) asyncResolveInflight = null; }); asyncResolveInflight = { key: cacheKey, epoch: startedEpoch, promise }; - return cloneAndDeepFreeze(await promise); + return promise; } /** @@ -1087,7 +1154,10 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv async function resolveCodexRuntimeUncachedAsync(deps: ResolveCodexRuntimeDeps): Promise { const steps = resolveCodexRuntimeSteps(deps); let step = steps.next(); - while (!step.done) step = steps.next(await probeVersionAsync(step.value.command, deps)); + while (!step.done) { + if (deps.signal?.aborted) throw new Error("Codex runtime resolution aborted"); + step = steps.next(await probeVersionAsync(step.value.command, deps)); + } return step.value; } @@ -1096,6 +1166,24 @@ export function resolveAndPersistCodexRuntime( deps: ResolveCodexRuntimeDeps = {}, ): ResolveCodexRuntimeResult { const result = resolveCodexRuntime(deps); + return persistResolvedCodexRuntime(result, deps); +} + +/** Same selection/persistence as the CLI driver, with stale async results rejected. */ +export async function resolveAndPersistCodexRuntimeAsync( + deps: ResolveCodexRuntimeDeps = {}, + isCurrent: () => boolean = () => true, +): Promise { + const observation = await resolveCodexRuntimeObservationAsync(deps); + if (!isCurrent() || observation.epoch === null || observation.epoch !== resolveCacheEpoch + || observation.inputs !== codexRuntimeSelectionIdentity(deps)) return null; + return cloneAndDeepFreeze(persistResolvedCodexRuntime(observation.result, deps)); +} + +function persistResolvedCodexRuntime( + result: ResolveCodexRuntimeResult, + deps: ResolveCodexRuntimeDeps, +): ResolveCodexRuntimeResult { // Only WRITE when the selection actually changed. persistCodexRuntime() clears the // in-process resolve memo and persistedRuntimeCacheStamp() folds `updatedAt` into the // cache key, so an unconditional rewrite made every caller re-run the ~1s diff --git a/src/lib/translator-budget.ts b/src/lib/translator-budget.ts index d1914894f72..f5a8d93c7a2 100644 --- a/src/lib/translator-budget.ts +++ b/src/lib/translator-budget.ts @@ -191,6 +191,7 @@ class Budget implements TranslatorBudget { } openCall(id: string): void { + if (this.disposed) return; if (!this.calls.has(id)) { this.calls.set(id, { logicalBytes: 0, physicalBytes: 0 }); aggregateActiveCalls += 1; @@ -210,6 +211,7 @@ class Budget implements TranslatorBudget { bytes: number, scope: { kind: TranslatorBufferKind; callId?: string }, ): TranslatorTransientReservation { + if (this.disposed) return { commitRetained() {}, release() {} }; const admitted = normalizeBytes(bytes); if (scope.callId && admitted > this.maxCallArgumentBytes) { this.reject("tool_args", this.maxCallArgumentBytes); @@ -222,6 +224,7 @@ class Budget implements TranslatorBudget { return { commitRetained: () => { if (state !== "reserved") return; + if (this.disposed) { state = "released"; return; } state = "committed"; if (!scope.callId) return; const call = this.calls.get(scope.callId) ?? { logicalBytes: 0, physicalBytes: 0 }; @@ -234,6 +237,7 @@ class Budget implements TranslatorBudget { release: () => { if (state !== "reserved") return; state = "released"; + if (this.disposed) return; this.charged.set(scope.kind, Math.max(0, (this.charged.get(scope.kind) ?? 0) - admitted)); this.hardChargedBytes = Math.max(0, this.hardChargedBytes - admitted); this.changed(); @@ -242,6 +246,7 @@ class Budget implements TranslatorBudget { } chargeRetained(delta: number, scope: { kind: TranslatorBufferKind; callId?: string }): void { + if (this.disposed) return; const admitted = normalizeBytes(delta); if (scope.callId) { const call = this.calls.get(scope.callId) ?? { logicalBytes: 0, physicalBytes: 0 }; @@ -279,6 +284,7 @@ class Budget implements TranslatorBudget { } private observe(bytes: number): () => void { + if (this.disposed) return () => {}; const observed = normalizeBytes(bytes); this.observedBytes += observed; this.changed(); @@ -369,7 +375,7 @@ export function resetTranslatorAggregateForTests(): void { aggregateOverflows = 0; } -export function finalizeTranslatorBudgetResponse(response: Response, budget: TranslatorBudget): Response { +export function finalizeTranslatorBudgetResponse(response: Response, budget: TranslatorBudget, signal?: AbortSignal): Response { if (!response.body) { budget.dispose(); return response; @@ -379,9 +385,12 @@ export function finalizeTranslatorBudgetResponse(response: Response, budget: Tra const finalize = () => { if (finalized) return; finalized = true; + signal?.removeEventListener("abort", finalize); budget.dispose(); }; - return new Response(new ReadableStream({ + // The transport owns cancellation and terminal precedence. Abort releases only + // accounting here, including when no consumer ever pulls or cancels this body. + const body = new ReadableStream({ async pull(controller) { try { const result = await reader.read(); @@ -395,7 +404,11 @@ export function finalizeTranslatorBudgetResponse(response: Response, budget: Tra } }, async cancel(reason) { - try { await reader.cancel(reason); } finally { finalize(); } + finalize(); + await reader.cancel(reason); }, - }), { status: response.status, statusText: response.statusText, headers: response.headers }); + }); + if (signal?.aborted) finalize(); + else signal?.addEventListener("abort", finalize, { once: true }); + return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers }); } diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 3e6015e22ee..2c561039bab 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -113,6 +113,7 @@ export async function handleChatCompletions( return finalizeTranslatorBudgetResponse( await handleChatCompletionsWithBudget(req, config, logCtx, translatorBudget, logIds), translatorBudget, + req.signal, ); } catch (error) { translatorBudget.dispose(); diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 7b66ac91a2e..a2b33439141 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -837,6 +837,7 @@ export async function handleClaudeMessages( return finalizeTranslatorBudgetResponse( await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds, requestPolicy, ingress), translatorBudget, + req.signal, ); } catch (error) { translatorBudget.dispose(); diff --git a/src/server/responses/core-lifetime.ts b/src/server/responses/core-lifetime.ts index c6b5653ea28..213f63d9a2a 100644 --- a/src/server/responses/core-lifetime.ts +++ b/src/server/responses/core-lifetime.ts @@ -1,4 +1,5 @@ import type { TranslatorBudget } from "../../lib/translator-budget"; +import { finalizeTranslatorBudgetResponse } from "../../lib/translator-budget"; import { isNativePassthroughSseResponse, markNativePassthroughSseResponse, @@ -37,42 +38,8 @@ export const UPSTREAM_JSON_BODY_READ_OPTIONS = { -export function finalizeOwnedTranslatorBudget(response: Response, budget: TranslatorBudget): Response { - if (!response.body) { - budget.dispose(); - return response; - } - const reader = response.body.getReader(); - let finalized = false; - const finalize = () => { - if (finalized) return; - finalized = true; - budget.dispose(); - }; - const body = new ReadableStream({ - async pull(controller) { - try { - const result = await reader.read(); - if (result.done) { - finalize(); - controller.close(); - } else { - controller.enqueue(result.value); - } - } catch (error) { - finalize(); - controller.error(error); - } - }, - async cancel(reason) { - try { await reader.cancel(reason); } finally { finalize(); } - }, - }); - const finalizedResponse = new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); +export function finalizeOwnedTranslatorBudget(response: Response, budget: TranslatorBudget, signal?: AbortSignal): Response { + const finalizedResponse = finalizeTranslatorBudgetResponse(response, budget, signal); if (isNativePassthroughSseResponse(response)) { markNativePassthroughSseResponse(finalizedResponse); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 68862d971d5..e045022a645 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -66,7 +66,7 @@ export async function handleResponses( // Once at ingress, spend observer included: a combo child inherits the parent's holder. sendBudget: options.sendBudget ?? createInferenceSendBudget(req, logCtx), }, handleResponsesInner); - const finalResponse = ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget) : response; + const finalResponse = ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget, abortSignal) : response; if (!accountLoad.lease) { release(); return finalResponse; } return finalizeAccountLease(finalResponse, release); } catch (error) { diff --git a/structure/catalog.md b/structure/catalog.md index ff41aedea23..4f59e7e1e38 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,6 +1,6 @@ # Model Catalog -Activation-owned metadata discovery no longer refreshes known deadlines merely because quota snapshots age. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Pool 2 discovery uses the [instance credential ownership contract](providers/anthropic-account-pool.md#discovery-credential-ownership). +Activation-owned metadata discovery no longer refreshes known deadlines merely because quota snapshots age. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Pool 2 discovery uses the [instance credential ownership contract](providers/anthropic-account-pool.md#discovery-credential-ownership). The retained writer uses an observed catalog's nonblocking runtime snapshot for effort diagnostics; it does not run a synchronous version probe. Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Explicit Codex CLI installation observation supplies no selected-runtime proof to catalog discovery or publication. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). @@ -104,7 +104,7 @@ explicit observed-state merge policy and restore native priorities from the once backup rather than from a catalog whose priorities may already have been rewritten. A configured custom catalog remains the native metadata/template authority even when a bundled-catalog memo is warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability -evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. The default-on scheduler in `src/codex/catalog-auto-refresh.ts` settles bundled runtime and authenticated Codex roster observations before admission, using dynamic imports, a 15-second source wait and the loader's bounded synchronous probes. Source failure uses existing evidence, while a stopped generation cannot start the next source or converge. A changed set records `reloadRequired` through `src/codex/catalog-refresh-status.ts` for observed running app-servers and logs one content-free restart hint; a no-op keeps it while processes remain stale or the restart observation is unknown and clears it when they are fresh or gone. Automatic refresh never restarts processes. `tests/codex-integration/catalog-auto-refresh-scheduler.test.ts` covers these boundaries. `src/codex/catalog/bundled.ts` and `src/codex/runtime.ts` prefer the recorded native `launcherPath` from validated schema-2 PATH-overlay state within their shim candidates, retaining legacy candidates and other priorities. `src/codex/features.ts` follows that backing only when the selected wrapper matches validated state; it never probes unrelated installations. Overlay state and activation follow [the runtime lifecycle contract](runtime.md#lifecycle). +evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. The default-on scheduler in `src/codex/catalog-auto-refresh.ts` settles bundled runtime and authenticated Codex roster observations before admission, using dynamic imports, a 15-second source wait and asynchronous selected-runtime/model probes. Request metadata and templates use the same-selection last confirmed bundled memo, refresh its runtime observation every 15 seconds without awaiting it, and fall back to existing catalog/cache sources while a cold or changed selection settles. The bundled evidence keeps its 60-second freshness bound for convergence; failed request-triggered refreshes retain confirmed rows with a 60-second retry cooldown. Shared flights have a 45-second aggregate deadline, cancel obsolete selections and reject late catalog or runtime persistence after observed input/epoch changes. Explicit CLI synchronous loading and compatibility checks remain synchronous. Retained sync settles the async source before reading the content and evidence it later revalidates. Source failure uses existing evidence, while a stopped generation cannot start the next source or converge. A changed set records `reloadRequired` through `src/codex/catalog-refresh-status.ts` for observed running app-servers and logs one content-free restart hint; a no-op keeps it while processes remain stale or the restart observation is unknown and clears it when they are fresh or gone. Automatic refresh never restarts processes. `tests/codex-integration/catalog-auto-refresh-scheduler.test.ts` covers these boundaries. `src/codex/catalog/bundled.ts` and `src/codex/runtime.ts` prefer the recorded native `launcherPath` from validated schema-2 PATH-overlay state within their shim candidates, retaining legacy candidates and other priorities. `src/codex/features.ts` follows that backing only when the selected wrapper matches validated state; it never probes unrelated installations. Overlay state and activation follow [the runtime lifecycle contract](runtime.md#lifecycle). Custom Astra and Daybreak rows acquire native identity -- Responses Lite, multi-agent, context windows, display names -- only through the canonical `openai` forward destination and explicit diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 20a825fc1dc..dd168be2d22 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -9,6 +9,7 @@ Native steering follows [the shared WebSocket contract](../transports/streaming- Compatibility callers retain the public Responses ingress described by the [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. +Chat Completions and Messages release their owned translator budget on request abort even if no caller consumes the response body, following the [shared byte-accounting lifetime contract](../transports/byte-accounting.md#stream-buffer-accounting). Chat and Messages admission previews the [xAI OAuth Fast wire destination](../providers/xai-grok.md#grok-47-fast-lane-oauth) using the same policy as final Responses serialization. Native dispatch retains its own destination scope check. diff --git a/structure/runtime.md b/structure/runtime.md index fadb058d119..5e4ab34b4e9 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,6 +1,6 @@ # Runtime -The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. +The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence rejects observed selection/epoch changes and returns detached frozen results; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). ## Bun runtime and test runner diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 9714dc7fef3..b50afbabf98 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -61,6 +61,12 @@ lifecycle, cancellation races, protocol envelopes, and the real HTTP admission b ## Stream-buffer accounting +HTTP owners in `src/server/responses/core.ts`, `src/server/chat-completions.ts` and `src/server/claude-messages.ts` pass the request abort signal to `src/lib/translator-budget.ts`. An aborted request releases its owned translator budget even when its response body is never consumed. Abort finalization changes accounting only; the transport keeps ownership of producer cancellation and response terminal precedence. Explicit body cancellation also releases the budget before awaiting upstream reader cancellation. EOF and read errors share the same idempotent finalizer, which removes its abort listener. Responses lifetime wrapping in `src/server/responses/core-lifetime.ts` preserves native, eager-relay and preinspected-response markers. `tests/adapters/translator-budget-lifetime.test.ts` holds cancellation pending and covers abort before wrapping, abort after wrapping, explicit cancellation and successful EOF. + +Disposed budgets ignore late call, reservation, charge and observation work; reservations created before disposal cannot resurrect per-turn or aggregate counters. The same Bun contract checks both accounting scopes before any producer cancellation and while an explicit cancellation is pending. Every producer must honor its transport abort link; disposed accounting does not provide a byte cap to work that outlives its request. + +Prepared success, cancellation and timeout responses retain their bytes, status and headers when wrapping starts with an aborted request; its dead request budget is disposed immediately. This observer does not replace a transport verdict with a different abort error or an artificial EOF. + Devin's [Messages ordering buffer](../clients/claude-desktop.md#devin-messages-output-ordering) charges retained semantic events consumed from the independently bounded adapter queue to the shared translator budget until downstream delivery. Cancellation and overflow release held events before producer shutdown. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 9520b144d5d..f3ec9537a1b 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -448,7 +448,7 @@ lease while a block-local `admission` holds only the acquisition result. reads that holder rather than minting a per-phase allowance. Combo recursion is injected through `ResponsesDispatchers`: a child re-enters the public handler without a reverse runtime import from the combo implementation into `core.ts`. `core-lifetime.ts` owns the shared run-turn response -marker and translator-budget finalization, so the combo and delivery paths observe one identity. +marker and translator-budget finalization, so the combo and delivery paths observe one identity. Owned-budget cleanup on request abort, including unread response bodies, follows the [byte-accounting lifetime contract](byte-accounting.md#stream-buffer-accounting). The outer admission `finally` remains in `core.ts`. Native execution explicitly transfers its pending lease to `passthrough-execution.ts`; both owners await response construction before diff --git a/tests/adapters/translator-budget-lifetime.test.ts b/tests/adapters/translator-budget-lifetime.test.ts new file mode 100644 index 00000000000..e2a85cd8c9c --- /dev/null +++ b/tests/adapters/translator-budget-lifetime.test.ts @@ -0,0 +1,152 @@ +import { expect, test } from "bun:test"; +import { + createTranslatorBudget, finalizeTranslatorBudgetResponse, translatorLiveBudgetCountForTests, + translatorObservedBufferSnapshot, +} from "../../src/lib/translator-budget"; +import { finalizeOwnedTranslatorBudget } from "../../src/server/responses/core-lifetime"; +import { + markNativePassthroughSseResponse, isNativePassthroughSseResponse, + markEagerRelaySseResponse, isEagerRelaySseResponse, + markPreinspectedJsonResponse, isPreinspectedJsonResponse, +} from "../../src/server/relay"; + +for (const [kind, wrap] of [ + ["shared Chat/Messages", finalizeTranslatorBudgetResponse], + ["Responses", finalizeOwnedTranslatorBudget], +] as const) { + for (const mode of ["before", "after", "cancel"] as const) { + test(`${kind}: ${mode} releases unread accounting while producer cancellation is held`, async () => { + const baseline = translatorLiveBudgetCountForTests(); + const aggregate = translatorObservedBufferSnapshot(); + const budget = createTranslatorBudget(); + budget.openCall("call"); + budget.chargeRetained(512, { kind: "tool_args", callId: "call" }); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let cancellations = 0; + const original = new Response(new ReadableStream({ cancel() { cancellations++; return held; } })); + const controller = new AbortController(); + if (mode === "before") controller.abort(); + const response = wrap(original, budget, controller.signal); + let cancelled: Promise | undefined; + try { + if (mode !== "before") expect(translatorLiveBudgetCountForTests()).toBe(baseline + 1); + if (mode === "after") controller.abort(); + if (mode === "cancel") cancelled = response.body!.cancel("client gone"); + await Promise.resolve(); + expect(translatorLiveBudgetCountForTests()).toBe(baseline); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + expect(translatorObservedBufferSnapshot()).toMatchObject({ currentBytes: aggregate.currentBytes, active: aggregate.active }); + expect(cancellations).toBe(mode === "cancel" ? 1 : 0); + } finally { + cancelled ??= response.body!.cancel("cleanup"); + release(); + await cancelled; + budget.dispose(); + } + expect(cancellations).toBe(1); + expect(translatorLiveBudgetCountForTests()).toBe(baseline); + }); + } + + for (const status of [200, 499, 504]) { + test(`${kind}: already aborted prepared ${status} retains bytes, status and headers`, async () => { + const baseline = translatorLiveBudgetCountForTests(); + const budget = createTranslatorBudget(); + budget.observeAcceptedRequestCopy(1024); + const controller = new AbortController(); + controller.abort(); + const response = wrap(new Response("prepared verdict", { status, statusText: "Fixture", headers: { "x-fixture": "kept" } }), budget, controller.signal); + try { + expect(translatorLiveBudgetCountForTests()).toBe(baseline); + expect(budget.snapshot().currentBytes).toBe(0); + expect(response.status).toBe(status); + expect(response.statusText).toBe("Fixture"); + expect(response.headers.get("x-fixture")).toBe("kept"); + expect(await response.text()).toBe("prepared verdict"); + } finally { await response.body?.cancel().catch(() => {}); budget.dispose(); } + }); + } + + test(`${kind}: EOF and reader errors finalize once and detach the abort observer`, async () => { + const baseline = translatorLiveBudgetCountForTests(); + const budget = createTranslatorBudget(); + const controller = new AbortController(); + const response = wrap(new Response("EOF", { headers: { "x-fixture": "kept" } }), budget, controller.signal); + expect(await response.text()).toBe("EOF"); + expect(response.headers.get("x-fixture")).toBe("kept"); + expect(translatorLiveBudgetCountForTests()).toBe(baseline); + controller.abort(); + const errorBudget = createTranslatorBudget(); + const errorController = new AbortController(); + const broken = wrap(new Response(new ReadableStream({ pull(c) { c.error(new Error("producer failed")); } })), errorBudget, errorController.signal); + await expect(broken.text()).rejects.toThrow("producer failed"); + expect(translatorLiveBudgetCountForTests()).toBe(baseline); + errorController.abort(); + }); +} + +for (const operation of ["open", "reserve", "commit", "charge", "observe"] as const) { + test(`disposed budget ignores late ${operation} without resurrecting either accounting scope`, () => { + const aggregate = translatorObservedBufferSnapshot(); + const budget = createTranslatorBudget({ maxTurnBytes: 4096, maxCallArgumentBytes: 4096 }); + budget.openCall("prior"); + const prior = budget.reserveTransient(32, { kind: "tool_args", callId: "prior" }); + const observed = budget.observeAcceptedRequestCopy(64); + budget.dispose(); + try { + if (operation === "open") budget.openCall("late"); + if (operation === "reserve") budget.reserveTransient(8192, { kind: "tool_args", callId: "late" }).commitRetained(); + if (operation === "commit") prior.commitRetained(); + if (operation === "charge") budget.chargeRetained(8192, { kind: "retained_collectors" }); + if (operation === "observe") { + const accepted = budget.observeAcceptedRequestCopy(128); + const external = budget.observeExternallyCapped("mcp_payload", 256); + try { expect(budget.snapshot().currentBytes).toBe(0); } + finally { accepted(); external(); } + } + prior.release(); + observed(); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 0 }); + expect(translatorObservedBufferSnapshot()).toMatchObject({ currentBytes: aggregate.currentBytes, active: aggregate.active }); + } finally { budget.dispose(); } + }); +} + +for (const [mark, inspect] of [ + [markNativePassthroughSseResponse, isNativePassthroughSseResponse], + [markEagerRelaySseResponse, isEagerRelaySseResponse], + [markPreinspectedJsonResponse, isPreinspectedJsonResponse], +] as const) { + test(`Responses retains ${inspect.name} through budget wrapping`, async () => { + const source = mark(new Response("unchanged")); + const budget = createTranslatorBudget(); + const wrapped = finalizeOwnedTranslatorBudget(source, budget); + expect(inspect(wrapped)).toBe(true); + expect(await wrapped.text()).toBe("unchanged"); + }); +} + +for (const surface of ["responses", "chat-completions", "claude-messages"] as const) { + test(`${surface}: HTTP owner links request abort to its unread response budget`, async () => { + const handle = surface === "responses" + ? (await import("../../src/server/responses")).handleResponses + : surface === "chat-completions" + ? (await import("../../src/server/chat-completions")).handleChatCompletions + : (await import("../../src/server/claude-messages")).handleClaudeMessages; + const baseline = translatorLiveBudgetCountForTests(); + const controller = new AbortController(); + const response = await handle(new Request("http://localhost/v1/fixture", { + method: "POST", headers: { "content-type": "application/json" }, body: "{", signal: controller.signal, + }), { port: 0, defaultProvider: "fixture", providers: {} }, { model: "", provider: "" }); + try { + expect(response.status).toBe(400); + expect(translatorLiveBudgetCountForTests()).toBe(baseline + 1); + controller.abort(); + expect(translatorLiveBudgetCountForTests()).toBe(baseline); + expect(response.status).toBe(400); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(await response.text()).toContain("error"); + } finally { await response.body?.cancel().catch(() => {}); } + }); +} diff --git a/tests/codex-integration/catalog-async-lifetime.test.ts b/tests/codex-integration/catalog-async-lifetime.test.ts new file mode 100644 index 00000000000..8e296e53e81 --- /dev/null +++ b/tests/codex-integration/catalog-async-lifetime.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import * as childProcess from "node:child_process"; +import { SUBPROCESS_KILL_GRACE_MS } from "../../src/lib/bounded-subprocess"; +import * as runtime from "../../src/codex/runtime"; +import * as bundled from "../../src/codex/catalog/bundled"; +import { clampCatalogModelsToCodexSupport } from "../../src/codex/catalog/effort"; +import { repoPath } from "../helpers/repo-root"; + +const catalog = { models: [{ slug: "gpt-5.5", base_instructions: "fixture", supported_reasoning_levels: [{ effort: "medium", description: "fixture" }] }] }; +let root: string; +let selected: { command: string; version: string; source: "environment" }; +let saved: Record; +let exec: ReturnType>; + +beforeEach(() => { + mkdirSync(repoPath(".tmp"), { recursive: true }); + root = mkdtempSync(repoPath(".tmp/catalog-async-")); + selected = { command: join(root, "codex.exe"), version: "0.160.0", source: "environment" }; + writeFileSync(selected.command, "synthetic executable"); + saved = Object.fromEntries(["OPENCODEX_HOME", "CODEX_HOME", "CODEX_CLI_PATH"].map(key => [key, process.env[key]])); + process.env.OPENCODEX_HOME = root; + process.env.CODEX_HOME = join(root, "codex-home"); + process.env.CODEX_CLI_PATH = selected.command; + runtime.clearCodexRuntimeResolveCache(); + bundled.resetBundledCatalogCacheForTests(); + if (runtime.execCodexFileAsync) exec = spyOn(runtime, "execCodexFileAsync").mockImplementation(async (_file, args) => + args.includes("--version") ? "codex-cli 0.160.0" : JSON.stringify(catalog)); +}); + +afterEach(() => { + bundled.resetBundledCatalogCacheForTests(); + runtime.clearCodexRuntimeResolveCache(); + exec?.mockRestore(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); +}); + +function hold() { + let resolve!: (value: T) => void; + const promise = new Promise(settle => { resolve = settle; }); + return { promise, resolve }; +} + +function warmRuntime() { + runtime.setCodexRuntimeResolveCacheForTests({ runtime: selected, failures: [] }, { discoverAlternatives: false }); +} + +test("concurrent async loads share one version/model flight and warm reload runs no child", async () => { + const results = await Promise.all(Array.from({ length: 12 }, () => bundled.loadBundledCodexCatalogAsync())); + expect(results.every(value => value?.models[0]?.slug === "gpt-5.5")).toBe(true); + expect(exec.mock.calls.filter(call => call[1].includes("--version"))).toHaveLength(1); + expect(exec.mock.calls.filter(call => call[1].includes("debug"))).toHaveLength(1); + await bundled.loadBundledCodexCatalogAsync(); // stabilize post-persistence input signature + const count = exec.mock.calls.length; + await bundled.loadBundledCodexCatalogAsync(); + expect(exec.mock.calls).toHaveLength(count); +}); + +test("same-selection snapshot is detached and retains stale rows while refresh settles", async () => { + await bundled.loadBundledCodexCatalogAsync(); + bundled.setBundledCatalogCacheForTests(selected, catalog, { expiresAt: 0 }); + const held = hold(); + exec.mockImplementation(() => held.promise); + const first = bundled.bundledCodexCatalogSnapshot(); + const pending = bundled.loadBundledCodexCatalogAsync(); + expect(first?.models[0]?.slug).toBe("gpt-5.5"); + expect(Reflect.set(first!.models[0]!, "slug", "mutated")).toBe(false); + expect(bundled.bundledCodexCatalogSnapshot()?.models[0]?.slug).toBe("gpt-5.5"); + held.resolve(JSON.stringify(catalog)); + await pending; + process.env.CODEX_CLI_PATH = join(root, "missing.exe"); + expect(bundled.bundledCodexCatalogSnapshot()).toBeNull(); + await bundled.loadBundledCodexCatalogAsync(); +}); + +for (const change of ["epoch", "runtime epoch", "input", "stop"] as const) { + test(`late catalog result rejected after ${change} invalidation`, async () => { + const held = hold(); + let current = true; + const pending = bundled.loadBundledCodexCatalogAsync({ commandCandidates: () => [selected.command], execFile: () => held.promise }, () => current); + if (change === "epoch") bundled.invalidateBundledCatalogCache(); + if (change === "runtime epoch") runtime.clearCodexRuntimeResolveCache(); + if (change === "input") process.env.CODEX_CLI_PATH = "changed-selection"; + if (change === "stop") current = false; + held.resolve(JSON.stringify(catalog)); + expect(await pending).toBeNull(); + expect(bundled.bundledCatalogCacheState().valueIdentity).toBeNull(); + }); +} + +for (const change of ["pin", "clear", "stop", "abort", "valid"] as const) { + test(`async runtime persistence guards ${change} and returns detached frozen selection`, async () => { + const held = hold(); + const dir = join(root, change); + mkdirSync(dir); + const initial = { version: 1, command: selected.command, source: "environment", selectedVersion: "0.159.0", origin: "discovered", updatedAt: "2026-10-08T00:00:00Z" }; + let bytes = JSON.stringify(initial); + let current = true; + const controller = new AbortController(); + const deps: runtime.ResolveCodexRuntimeDeps = { + configDir: dir, env: { CODEX_CLI_PATH: selected.command, PATH: "" }, platform: "win32", + discoverAlternatives: false, existsSync: () => true, readdirSync: () => [], + readFileSync: path => { if (path.endsWith("codex-runtime.json")) return bytes; throw new Error("absent"); }, + execFile: () => held.promise, signal: controller.signal, + }; + const pending = runtime.resolveAndPersistCodexRuntimeAsync(deps, () => current); + if (change === "pin") bytes = JSON.stringify({ ...initial, origin: "pinned" }); + if (change === "clear") runtime.clearCodexRuntimeResolveCache(); + if (change === "stop") current = false; + if (change === "abort") controller.abort(); + held.resolve("codex-cli 0.160.0"); + const resolved = await pending; + const file = join(dir, "codex-runtime.json"); + expect(resolved !== null).toBe(change === "valid"); + expect(existsSync(file)).toBe(change === "valid"); + if (resolved) { + expect(JSON.parse(readFileSync(file, "utf8")).selectedVersion).toBe("0.160.0"); + expect(Object.isFrozen(resolved.runtime)).toBe(true); + } + }); +} + +test("a scoped source abort does not cancel an independent shared model flight", async () => { + warmRuntime(); + const held = hold(); + exec.mockImplementation(async (_file, args) => args.includes("--version") ? "codex-cli 0.160.0" : held.promise); + const independent = bundled.loadBundledCodexCatalogAsync(); + const controller = new AbortController(); + const scoped = bundled.loadBundledCodexCatalogAsync({}, () => true, controller.signal); + controller.abort(); + expect(await scoped).toBeNull(); + held.resolve(JSON.stringify(catalog)); + expect((await independent)?.models[0]?.slug).toBe("gpt-5.5"); + expect(bundled.bundledCodexCatalogSnapshot()?.models[0]?.slug).toBe("gpt-5.5"); +}); + +test("scoped flight abort propagates to its executor and cannot publish late output", async () => { + const held = hold(); + const controller = new AbortController(); + let signal: AbortSignal | undefined; + const pending = bundled.loadBundledCodexCatalogAsync({ commandCandidates: () => [selected.command], execFile: (_file, _args, options) => { signal = options.signal; return held.promise; } }, () => true, controller.signal); + controller.abort(); + expect(await pending).toBeNull(); + expect(signal?.aborted).toBe(true); + held.resolve(JSON.stringify(catalog)); + await Promise.resolve(); + expect(bundled.bundledCatalogCacheState().valueIdentity).toBeNull(); +}); + +test("failed refresh retains matching confirmed rows and bounds snapshot retries to cooldown", async () => { + await bundled.loadBundledCodexCatalogAsync(); + bundled.setBundledCatalogCacheForTests(selected, catalog, { expiresAt: 0 }); + exec.mockImplementation(async (_file, args) => args.includes("--version") ? "codex-cli 0.160.0" : "invalid catalog"); + expect(bundled.bundledCodexCatalogSnapshot()?.models[0]?.slug).toBe("gpt-5.5"); + await bundled.loadBundledCodexCatalogAsync(); + const count = exec.mock.calls.length; + for (let i = 0; i < 20; i++) expect(bundled.bundledCodexCatalogSnapshot()?.models[0]?.slug).toBe("gpt-5.5"); + await Promise.resolve(); + expect(exec.mock.calls).toHaveLength(count); + const now = Date.now(); + const clock = spyOn(Date, "now").mockReturnValue(now + bundled.BUNDLED_CATALOG_CACHE_MS + 1); + try { + bundled.bundledCodexCatalogSnapshot(); + await bundled.loadBundledCodexCatalogAsync(); + expect(exec.mock.calls.length).toBeGreaterThan(count); + } finally { clock.mockRestore(); } +}); + +test("failed different-version refresh drops old capability evidence", async () => { + await bundled.loadBundledCodexCatalogAsync(); + await bundled.loadBundledCodexCatalogAsync(); + bundled.setBundledCatalogCacheForTests({ ...selected, version: "0.159.0" }, catalog); + exec.mockImplementation(async (_file, args) => args.includes("--version") ? "codex-cli 0.160.0" : "invalid catalog"); + await bundled.loadBundledCodexCatalogAsync(); + expect(bundled.bundledCodexCatalogSnapshot()).toBeNull(); +}); + +test("45-second flight deadline bounds an uncooperative executor and next load succeeds", async () => { + const set = globalThis.setTimeout; + let deadline: number | undefined; + const timer = spyOn(globalThis, "setTimeout").mockImplementation(((fn, delay, ...args) => { + if (delay === 45_000) deadline = delay; + return set(fn, delay === 45_000 ? 10 : delay, ...args); + }) as typeof setTimeout); + try { + const expired = await bundled.loadBundledCodexCatalogAsync({ commandCandidates: () => [selected.command], execFile: () => new Promise(() => {}) }); + expect(deadline).toBe(45_000); + expect(expired).toBeNull(); + } finally { timer.mockRestore(); } + expect((await bundled.loadBundledCodexCatalogAsync({ commandCandidates: () => [selected.command], execFile: async () => JSON.stringify(catalog) }))?.models[0]?.slug).toBe("gpt-5.5"); +}); + +test("observed effort clamp never invokes synchronous runtime/model probes", () => { + warmRuntime(); + let syncCalls = 0; + const rows = [{ ...catalog.models[0]!, supported_reasoning_levels: [{ effort: "high", description: "fixture" }], default_reasoning_level: "high" }]; + clampCatalogModelsToCodexSupport(rows, { observedCatalog: catalog, execFileSync: () => { syncCalls++; return "codex-cli 0.160.0"; } }); + expect(syncCalls).toBe(0); +}); + +for (const mode of ["abort", "deadline"] as const) { + test(`native async executor reaps a READY child ignoring SIGTERM after ${mode}`, async () => { + exec.mockRestore(); + const ready = hold(); + const exited = hold(); + const controller = new AbortController(); + let child: childProcess.ChildProcess | undefined; + const actualExec = childProcess.execFile; + const spawn = spyOn(childProcess, "execFile").mockImplementation(((...args: Parameters) => { + child = actualExec(...args); + let output = ""; + child.stdout?.on("data", chunk => { output += String(chunk); if (output.includes("READY")) ready.resolve(); }); + child.once("exit", () => exited.resolve()); + return child; + }) as typeof actualExec); + const set = globalThis.setTimeout; + // Arm the deadline only after the child has installed its signal handler. + const timer = spyOn(globalThis, "setTimeout").mockImplementation(((fn, delay, ...args) => { + if (mode === "deadline" && delay === 1234) { + const scheduled = set(() => { void ready.promise.then(() => fn(...args)); }, 0); + return scheduled; + } + return set(fn, delay, ...args); + }) as typeof setTimeout); + const script = "if (process.platform !== 'win32') process.on('SIGTERM', () => {}); console.log('READY'); setInterval(() => {}, 20);"; + const pending = runtime.execCodexFileAsync(process.execPath, ["-e", script], { + encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: mode === "deadline" ? 1234 : 10_000, + windowsHide: true, signal: controller.signal, + }); + // Observe rejection before a fast Windows kill can settle it. + const rejected = pending.then(() => { throw new Error("held child unexpectedly completed"); }, error => error); + try { + await Promise.race([ready.promise, Bun.sleep(3000).then(() => { throw new Error("child never became READY"); })]); + const started = Date.now(); + if (mode === "abort") controller.abort(); + const error = await rejected; + expect(String(error)).toMatch(mode === "abort" ? /abort/i : /deadline|timed out|kill/i); + expect(Date.now() - started).toBeLessThan(1000); + const didExit = await Promise.race([ + exited.promise.then(() => true), Bun.sleep(SUBPROCESS_KILL_GRACE_MS + 1000).then(() => false), + ]); + expect(didExit).toBe(true); + expect(child!.exitCode !== null || child!.signalCode !== null).toBe(true); + if (process.platform !== "win32") expect(child!.signalCode).toBe("SIGKILL"); + expect(() => process.kill(child!.pid!, 0)).toThrow(); + } finally { + timer.mockRestore(); + spawn.mockRestore(); + controller.abort(); + if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await Promise.race([exited.promise, Bun.sleep(3000)]); + await rejected; + } + }, 10_000); +} + +test("model probes allow the bounded large catalog buffer and retain all rows", async () => { + let limit: number | undefined; + const large = { models: [catalog.models[0], { slug: "gpt-5.6-sol", base_instructions: "x".repeat(2 * 1024 * 1024) }] }; + const result = await bundled.loadBundledCodexCatalogAsync({ commandCandidates: () => [selected.command], execFile: async (_file, _args, options) => { limit = options.maxBuffer; return JSON.stringify(large); } }); + expect(limit).toBe(64 * 1024 * 1024); + expect(result?.models).toHaveLength(2); + expect(result?.models[1]?.base_instructions).toBe(large.models[1]!.base_instructions); +}); + +test("a new shared selection aborts obsolete model work and refuses its late publication", async () => { + expect(typeof bundled.loadBundledCodexCatalogAsync).toBe("function"); + const held = hold(); + const started = hold(); + let oldSignal: AbortSignal | undefined; + exec.mockImplementation(async (_file, args, options) => { + if (args.includes("--version")) return "codex-cli 0.160.0"; + if (!oldSignal) { oldSignal = options.signal; started.resolve(); return held.promise; } + return JSON.stringify(catalog); + }); + const obsolete = bundled.loadBundledCodexCatalogAsync(); + await started.promise; + process.env.CODEX_CLI_PATH = join(root, "next.exe"); + writeFileSync(process.env.CODEX_CLI_PATH, "synthetic executable"); + const next = bundled.loadBundledCodexCatalogAsync(); + expect(oldSignal?.aborted).toBe(true); + held.resolve(JSON.stringify(catalog)); + expect(await obsolete).toBeNull(); + expect((await next)?.models[0]?.slug).toBe("gpt-5.5"); + expect(bundled.bundledCodexCatalogSnapshot()?.models[0]?.slug).toBe("gpt-5.5"); +}); diff --git a/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts b/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts index 5eccb998edb..167a0b24a7a 100644 --- a/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts +++ b/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts @@ -76,7 +76,7 @@ beforeEach(() => { resetCatalogAutoRefreshForTests(); resetCatalogAutoRefreshStatusForTests(); sourceSpies = [ - spyOn(bundled, "loadBundledCodexCatalog").mockReturnValue(null), + spyOn(bundled, "loadBundledCodexCatalogAsync").mockResolvedValue(null), spyOn(entitlements, "ensureCodexEntitlementFreshness").mockResolvedValue(undefined), spyOn(entitlements, "discoverCodexNativeRoster").mockResolvedValue("unavailable"), spyOn(appServerProcesses, "listCodexAppServerProcesses").mockReturnValue([]), @@ -204,7 +204,7 @@ describe("catalog auto-refresh scheduler", () => { test.each(["none", "bundled", "roster", "discovery"])("sources settle before converge despite %s failure", async failure => { writeCatalogAutoRefreshConfig(); const steps: string[] = []; - spyOn(bundled, "loadBundledCodexCatalog").mockImplementation(() => { + spyOn(bundled, "loadBundledCodexCatalogAsync").mockImplementation(async () => { steps.push("bundled"); if (failure === "bundled") throw new Error("private source failure"); return null; @@ -227,7 +227,7 @@ describe("catalog auto-refresh scheduler", () => { test("stopping during source refresh prevents roster warm and convergence", async () => { writeCatalogAutoRefreshConfig(); - spyOn(bundled, "loadBundledCodexCatalog").mockImplementation(() => { + spyOn(bundled, "loadBundledCodexCatalogAsync").mockImplementation(async () => { stopCatalogAutoRefresh(); return null; }); @@ -316,7 +316,7 @@ describe("catalog auto-refresh scheduler", () => { await runCatalogAutoRefreshTickForTests(); expect(catalogAutoRefreshTickCountForTests()).toBe(0); expect(convergeFactoryCalls).toBe(0); - expect(bundled.loadBundledCodexCatalog).not.toHaveBeenCalled(); + expect(bundled.loadBundledCodexCatalogAsync).not.toHaveBeenCalled(); expect(entitlements.ensureCodexEntitlementFreshness).not.toHaveBeenCalled(); expect(lastCatalogAutoRefreshOutcome()).toBeNull(); }); @@ -844,7 +844,7 @@ describe("catalog auto-refresh without a managed Codex client", () => { writeIntegrationOffConfig(); await runCatalogAutoRefreshTickForTests(); expect(convergeFactoryCalls).toBe(0); - expect(bundled.loadBundledCodexCatalog).not.toHaveBeenCalled(); + expect(bundled.loadBundledCodexCatalogAsync).not.toHaveBeenCalled(); expect(entitlements.ensureCodexEntitlementFreshness).not.toHaveBeenCalled(); expect(entitlements.discoverCodexNativeRoster).not.toHaveBeenCalled(); expect(catalogAutoRefreshTickCountForTests()).toBe(0); @@ -854,7 +854,7 @@ describe("catalog auto-refresh without a managed Codex client", () => { writeIntegrationOffConfig({ enabled: true, intervalMinutes: 60 }); await runCatalogAutoRefreshTickForTests(); expect(convergeFactoryCalls).toBe(1); - expect(bundled.loadBundledCodexCatalog).not.toHaveBeenCalled(); + expect(bundled.loadBundledCodexCatalogAsync).not.toHaveBeenCalled(); expect(entitlements.ensureCodexEntitlementFreshness).not.toHaveBeenCalled(); expect(entitlements.discoverCodexNativeRoster).not.toHaveBeenCalled(); }); diff --git a/tests/codex-integration/catalog-slug-uniqueness-boundary.test.ts b/tests/codex-integration/catalog-slug-uniqueness-boundary.test.ts index 85d7689fc3b..f270e509194 100644 --- a/tests/codex-integration/catalog-slug-uniqueness-boundary.test.ts +++ b/tests/codex-integration/catalog-slug-uniqueness-boundary.test.ts @@ -73,7 +73,7 @@ describe("catalog slug uniqueness at the write boundary (#4730)", () => { // Ordering is load-bearing: the effort clamp splices whole rows out, so deduping first can // drop the row the clamp would have kept and then lose the slug entirely. const guardAt = retained.indexOf("enforceCatalogSlugUniqueness("); - const clampAt = retained.indexOf("clampCatalogModelsToCodexSupport(catalog.models)"); + const clampAt = retained.indexOf("clampCatalogModelsToCodexSupport(catalog.models"); const serializeAt = retained.indexOf("JSON.stringify(catalog, null, 2)"); expect(clampAt).toBeGreaterThan(-1); expect(guardAt).toBeGreaterThan(clampAt); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e7953e2947a..fc27fbfe460 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1052,7 +1052,7 @@ "token-estimate.test.ts": "lib", "token-guardian.test.ts": "codex-integration", "tool-argument-integers.test.ts": "adapters", "tool-catalog-nudge.test.ts": "adapters", "tool-choice-performance.test.ts": "adapters", "transient-budget-scope-source.test.ts": "lib", - "translator-budget.test.ts": "adapters", "transport-null-body.test.ts": "lib", + "translator-budget.test.ts": "adapters", "translator-budget-lifetime.test.ts": "adapters", "catalog-async-lifetime.test.ts": "codex-integration", "transport-null-body.test.ts": "lib", "tray-proxy-deadline.test.ts": "windows", "tray-proxy.test.ts": "windows", "types-barrel-identity.test.ts": "config", "ultrafast-tier-honesty.test.ts": "codex-integration", "umans-provider.test.ts": "providers", "uninstall.test.ts": "cli",