Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/server/catalog-download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* copy is the one nobody looks at in the dashboard.
*/
import { createHash } from "node:crypto";
import { statSync } from "node:fs";

/**
* Upper bound for the REMOTE route only.
Expand All @@ -35,6 +36,29 @@ export interface SerializedCatalog {
etag?: string;
/** Byte length of `body`, present only when `body` is. */
bytes?: number;
/** Remote-only preflight failure; management serialization never sets this. */
error?: "too_large";
}

interface CatalogFileIdentity {
key: string;
size: number;
}

let remoteCache: { path: string; identity: string; serialized: SerializedCatalog } | undefined;
let remoteSerialization: { path: string; identity: string; result: Promise<SerializedCatalog> } | undefined;

function catalogFileIdentity(path: string): CatalogFileIdentity | null {
try {
const stat = statSync(path, { bigint: true });
if (!stat.isFile()) return null;
return {
key: `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`,
size: Number(stat.size),
};
} catch {
return null;
}
}

export function catalogEtag(body: string): string {
Expand All @@ -61,6 +85,46 @@ export async function serializePersistedCatalog(): Promise<SerializedCatalog> {
return { body, etag: catalogEtag(body), bytes };
}

/**
* Serialize the catalog for the remotely reachable route.
*
* File size is checked before the synchronous reader can materialize it. A
* validated file identity caches the expensive parse/stringify/hash result,
* and the shared promise limits cache misses to one serialization at a time.
*/
export async function serializeRemotePersistedCatalog(): Promise<SerializedCatalog> {
const { readCodexCatalogPath } = await import("../codex/catalog");
const path = readCodexCatalogPath();
const identity = catalogFileIdentity(path);
if (!identity) return { body: null };
if (identity.size > MAX_REMOTE_CATALOG_BYTES) return { body: null, error: "too_large" };
Comment on lines +98 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate the cache when the catalog becomes ineligible

When a previously cached catalog is later deleted, becomes unreadable, or is replaced by an oversized file, these early returns never clear remoteCache, so the old serialized body remains strongly referenced for the rest of the process unless another valid catalog is successfully serialized. Because one cached entry may be nearly MAX_REMOTE_CATALOG_BYTES (256 MiB), this can retain substantial heap even after the operator removes the problematic catalog; clear the stale cache whenever the current path or identity is missing or rejected.

Useful? React with 👍 / 👎.

if (remoteCache?.path === path && remoteCache.identity === identity.key) return remoteCache.serialized;
if (remoteSerialization) {
if (remoteSerialization.path === path && remoteSerialization.identity === identity.key) {
return remoteSerialization.result;
}
await remoteSerialization.result;
return serializeRemotePersistedCatalog();
}

const result = (async () => {
const serialized = await serializePersistedCatalog();
if (serialized.bytes !== undefined && serialized.bytes > MAX_REMOTE_CATALOG_BYTES) {
return { body: null, error: "too_large" } as SerializedCatalog;
}
const after = catalogFileIdentity(path);
if (after?.key !== identity.key) return { body: null };
Comment on lines +115 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry when the catalog changes during serialization

When a catalog sync atomically replaces this file after the initial identity check but before this post-serialization check, the new file is valid and present, yet this branch returns { body: null }, which /v1/catalog maps to 404 catalog_not_found. This race can occur during normal startup because src/cli/index.ts starts the listener at line 268 before running the startup catalog sync at lines 393–399, and it can also occur during a manual sync; retry serialization against the new identity instead of reporting that no catalog exists.

Useful? React with 👍 / 👎.

if (serialized.body !== null) remoteCache = { path, identity: identity.key, serialized };
return serialized;
})();
remoteSerialization = { path, identity: identity.key, result };
try {
return await result;
} finally {
if (remoteSerialization?.result === result) remoteSerialization = undefined;
}
}

/**
* The authoritative Codex version for a catalog response, or undefined.
*
Expand Down
30 changes: 14 additions & 16 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,34 +1084,32 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
if (!isAllowedRequestOrigin(req, policy)) {
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
}
const { serializePersistedCatalog, persistedCodexVersion, MAX_REMOTE_CATALOG_BYTES } = await import("./catalog-download");
const serialized = await serializePersistedCatalog();
if (serialized.body === null) {
// Built directly rather than through formatErrorResponse: that helper derives
// `code` from the status and message via classifyError, and these two need stable,
// specific codes. `catalog_not_found` in particular is what lets a caller — and
// tests/api-key-attribution.test.ts — tell "this route exists and has no catalog"
// apart from "this route is gone", which is the difference between admission proof
// and a vacuous pass.
const { serializeRemotePersistedCatalog, persistedCodexVersion } = await import("./catalog-download");
const serialized = await serializeRemotePersistedCatalog();
if (serialized.error === "too_large") {
return withCors(
new Response(JSON.stringify({
error: { type: "invalid_request_error", code: "catalog_not_found", message: "no materialized catalog is available" },
error: { type: "server_error", code: "catalog_too_large", message: "catalog exceeds the maximum served size" },
}), {
status: 404,
status: 507,
headers: { "content-type": "application/json" },
}),
req,
policy,
);
}
// Size policy belongs to this route, not the shared serializer: the management route
// must keep its existing behavior for a catalog of any supported size.
if (serialized.bytes !== undefined && serialized.bytes > MAX_REMOTE_CATALOG_BYTES) {
if (serialized.body === null) {
// Built directly rather than through formatErrorResponse: that helper derives
// `code` from the status and message via classifyError, and these two need stable,
// specific codes. `catalog_not_found` in particular is what lets a caller — and
// tests/api-key-attribution.test.ts — tell "this route exists and has no catalog"
// apart from "this route is gone", which is the difference between admission proof
// and a vacuous pass.
return withCors(
new Response(JSON.stringify({
error: { type: "server_error", code: "catalog_too_large", message: "catalog exceeds the maximum served size" },
error: { type: "invalid_request_error", code: "catalog_not_found", message: "no materialized catalog is available" },
}), {
status: 507,
status: 404,
headers: { "content-type": "application/json" },
}),
req,
Expand Down
18 changes: 18 additions & 0 deletions tests/api-catalog-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,24 @@ describe("GET|HEAD /v1/catalog least-privilege data-plane route (#809)", () => {
expect(mgmt?.status).toBe(200);
});

test("caches remote serialization and preflights oversized files", async () => {
isolatedCodexHome = installIsolatedCodexHome("ocx-v1-catalog-cache-");
const path = join(isolatedCodexHome.path, "opencodex-catalog.json");
writeFileSync(path, JSON.stringify(catalogFixture));

const { serializeRemotePersistedCatalog, MAX_REMOTE_CATALOG_BYTES } = await import("../src/server/catalog-download");
const first = await serializeRemotePersistedCatalog();
const cached = await serializeRemotePersistedCatalog();
expect(cached).toBe(first);

// A sparse over-limit file proves the ceiling is enforced from metadata,
// before the synchronous JSON reader can allocate or parse its contents.
const { truncateSync } = await import("node:fs");
truncateSync(path, MAX_REMOTE_CATALOG_BYTES + 1);
const oversized = await serializeRemotePersistedCatalog();
expect(oversized).toEqual({ body: null, error: "too_large" });
});

test("reports a distinguishable code when no catalog is materialized", async () => {
isolatedCodexHome = installIsolatedCodexHome("ocx-v1-catalog-missing-");
saveConfig(dataPlaneConfig());
Expand Down
Loading