diff --git a/CHANGELOG.md b/CHANGELOG.md index d284157..fa4522e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @copilotkit/aimock +## 1.14.1 + +### Patch Changes + +- Cap in-memory journal (and fixture-match-counts map) to prevent heap OOM under sustained load. `Journal.entries` was unbounded, causing heap growth ~3.8MB/sec to 4GB → OOM in ~18 minutes on production Railway deployments. Default cap for CLI (`serve`) is now 1000 entries; programmatic `createServer()` remains unbounded by default (back-compat). See `--journal-max` flag. + ## 1.14.0 ### Minor Changes diff --git a/package-lock.json b/package-lock.json index 1782bab..71450a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@copilotkit/aimock", - "version": "1.14.0", + "version": "1.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@copilotkit/aimock", - "version": "1.14.0", + "version": "1.14.1", "license": "MIT", "bin": { "aimock": "dist/aimock-cli.js", @@ -135,50 +135,6 @@ "node": ">=14.17" } }, - "node_modules/@arethetypeswrong/core": { - "version": "0.18.2", - "resolved": "https://registry.npmjs.org/@arethetypeswrong/core/-/core-0.18.2.tgz", - "integrity": "sha512-GiwTmBFOU1/+UVNqqCGzFJYfBXEytUkiI+iRZ6Qx7KmUVtLm00sYySkfe203C9QtPG11yOz1ZaMek8dT/xnlgg==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@andrewbranch/untar.js": "^1.0.3", - "@loaderkit/resolve": "^1.0.2", - "cjs-module-lexer": "^1.2.3", - "fflate": "^0.8.2", - "lru-cache": "^11.0.1", - "semver": "^7.5.4", - "typescript": "5.6.1-rc", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@arethetypeswrong/core/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", - "extraneous": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@arethetypeswrong/core/node_modules/typescript": { - "version": "5.6.1-rc", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.1-rc.tgz", - "integrity": "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==", - "extraneous": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", diff --git a/package.json b/package.json index 5ba4b46..5592b8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/aimock", - "version": "1.14.0", + "version": "1.14.1", "description": "Mock infrastructure for AI application testing — LLM APIs, image generation, text-to-speech, transcription, video generation, MCP tools, A2A agents, AG-UI event streams, vector databases, search, rerank, and moderation. One package, one port, zero dependencies.", "license": "MIT", "keywords": [ diff --git a/src/__tests__/journal.test.ts b/src/__tests__/journal.test.ts index 606bad2..7291569 100644 --- a/src/__tests__/journal.test.ts +++ b/src/__tests__/journal.test.ts @@ -308,4 +308,69 @@ describe("Journal", () => { expect(journal.getLast()!.id).toBe(entry.id); }); }); + + describe("maxEntries (FIFO eviction)", () => { + it("caps entries to maxEntries, dropping oldest (FIFO)", () => { + const journal = new Journal({ maxEntries: 3 }); + + journal.add(makeEntry({ path: "/a" })); + journal.add(makeEntry({ path: "/b" })); + journal.add(makeEntry({ path: "/c" })); + journal.add(makeEntry({ path: "/d" })); + journal.add(makeEntry({ path: "/e" })); + + expect(journal.size).toBe(3); + const all = journal.getAll(); + expect(all.map((e) => e.path)).toEqual(["/c", "/d", "/e"]); + }); + + it("does not cap when maxEntries is unset (backwards compat)", () => { + const journal = new Journal(); + for (let i = 0; i < 5000; i++) journal.add(makeEntry({ path: `/${i}` })); + expect(journal.size).toBe(5000); + }); + + it("treats maxEntries = 0 or negative as uncapped", () => { + const journal0 = new Journal({ maxEntries: 0 }); + const journalNeg = new Journal({ maxEntries: -1 }); + for (let i = 0; i < 100; i++) { + journal0.add(makeEntry({ path: `/${i}` })); + journalNeg.add(makeEntry({ path: `/${i}` })); + } + expect(journal0.size).toBe(100); + expect(journalNeg.size).toBe(100); + }); + + it("getLast returns the most recent after eviction", () => { + const journal = new Journal({ maxEntries: 2 }); + journal.add(makeEntry({ path: "/a" })); + journal.add(makeEntry({ path: "/b" })); + const last = journal.add(makeEntry({ path: "/c" })); + expect(journal.getLast()!.id).toBe(last.id); + expect(journal.getLast()!.path).toBe("/c"); + }); + + it("findByFixture only returns surviving entries after eviction", () => { + const journal = new Journal({ maxEntries: 2 }); + const fixture: Fixture = { match: { userMessage: "x" }, response: { content: "X" } }; + + journal.add(makeEntry({ response: { status: 200, fixture } })); + journal.add(makeEntry({ response: { status: 200, fixture } })); + journal.add(makeEntry({ response: { status: 200, fixture } })); + + expect(journal.findByFixture(fixture)).toHaveLength(2); + }); + + it("memory does not grow unbounded under sustained load with cap", () => { + // Red-green anchor for the leak fix: 100k adds with cap=500 must stay at 500. + const journal = new Journal({ maxEntries: 500 }); + for (let i = 0; i < 100_000; i++) { + journal.add(makeEntry({ path: `/${i}` })); + } + expect(journal.size).toBe(500); + // Last 500 paths preserved, oldest 99,500 evicted + expect(journal.getLast()!.path).toBe("/99999"); + expect(journal.getAll()[0].path).toBe("/99500"); + }); + }); }); diff --git a/src/cli.ts b/src/cli.ts index 2256760..5789132 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,6 +25,7 @@ Options: --record Record mode: proxy unmatched requests and save fixtures --proxy-only Proxy mode: forward unmatched requests without saving --strict Strict mode: fail on unmatched requests + --journal-max Max request entries retained in memory (default: 1000, 0 = unbounded) --provider-openai Upstream URL for OpenAI (used with --record) --provider-anthropic Upstream URL for Anthropic --provider-gemini Upstream URL for Gemini @@ -70,6 +71,7 @@ const { values } = parseArgs({ "chaos-drop": { type: "string" }, "chaos-malformed": { type: "string" }, "chaos-disconnect": { type: "string" }, + "journal-max": { type: "string", default: "1000" }, help: { type: "boolean", default: false }, }, strict: true, @@ -110,6 +112,12 @@ if (Number.isNaN(chunkSize) || chunkSize < 1) { process.exit(1); } +const journalMax = Number(values["journal-max"]); +if (Number.isNaN(journalMax) || !Number.isInteger(journalMax)) { + console.error(`Invalid journal-max: ${values["journal-max"]} (must be an integer)`); + process.exit(1); +} + const logger = new Logger(logLevel); // Parse chaos config from CLI flags @@ -256,6 +264,7 @@ async function main() { metrics: values.metrics, record, strict: values.strict, + journalMaxEntries: journalMax, }, mounts, ); diff --git a/src/journal.ts b/src/journal.ts index 685a222..48115f7 100644 --- a/src/journal.ts +++ b/src/journal.ts @@ -29,9 +29,31 @@ function matchCriteriaEqual(a: FixtureMatch, b: FixtureMatch): boolean { ); } +export interface JournalOptions { + /** + * Maximum number of entries to retain. When exceeded, oldest entries are + * dropped FIFO. Set to 0 (or a negative value) for unbounded retention + * (the historical default — suitable for short-lived test runs only). + * + * Long-running servers (e.g. mock proxies in CI/demo environments) should + * always set a finite cap: every request appends an entry holding the + * request body + headers + fixture reference, and without a cap the + * journal grows until the process OOMs. + */ + maxEntries?: number; +} + export class Journal { private entries: JournalEntry[] = []; private readonly fixtureMatchCountsByTestId: Map> = new Map(); + private readonly maxEntries: number; + + constructor(options: JournalOptions = {}) { + // Treat 0 or negative as "unbounded" to preserve prior behavior when + // the option is omitted or explicitly disabled. + const cap = options.maxEntries; + this.maxEntries = cap !== undefined && cap > 0 ? cap : 0; + } /** Backwards-compatible accessor — returns the default (no testId) count map. */ get fixtureMatchCounts(): Map { @@ -45,6 +67,12 @@ export class Journal { ...entry, }; this.entries.push(full); + // FIFO eviction when over capacity. shift() in a tight loop would be + // O(n^2); we only ever overshoot by one per add, so a single shift is + // amortized O(1) per request. + if (this.maxEntries > 0 && this.entries.length > this.maxEntries) { + this.entries.shift(); + } return full; } diff --git a/src/server.ts b/src/server.ts index 07083fe..1b10cca 100644 --- a/src/server.ts +++ b/src/server.ts @@ -715,7 +715,7 @@ export async function createServer( } } - const journal = new Journal(); + const journal = new Journal({ maxEntries: options?.journalMaxEntries }); const videoStates: VideoStateMap = new Map(); // Share journal and metrics registry with mounted services diff --git a/src/types.ts b/src/types.ts index 488a0c9..591f78a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -398,6 +398,16 @@ export interface MockServerOptions { strict?: boolean; /** Record-and-replay: proxy unmatched requests to upstream and save fixtures. */ record?: RecordConfig; + /** + * Maximum number of request/response entries to retain in the in-memory + * journal. Oldest entries are dropped FIFO when the cap is exceeded. + * Set to 0 (or a negative value) for unbounded retention. + * + * Defaults vary by invocation path: the CLI applies a finite cap suitable + * for long-running servers (see `cli.ts`); programmatic callers default + * to unbounded to preserve prior behavior for short-lived test runs. + */ + journalMaxEntries?: number; /** * Normalize requests before matching and recording. Useful for stripping * dynamic data (timestamps, UUIDs, session IDs) that would cause fixture