Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
48 changes: 2 additions & 46 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
65 changes: 65 additions & 0 deletions src/__tests__/journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
9 changes: 9 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n> Max request entries retained in memory (default: 1000, 0 = unbounded)
--provider-openai <url> Upstream URL for OpenAI (used with --record)
--provider-anthropic <url> Upstream URL for Anthropic
--provider-gemini <url> Upstream URL for Gemini
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -256,6 +264,7 @@ async function main() {
metrics: values.metrics,
record,
strict: values.strict,
journalMaxEntries: journalMax,
},
mounts,
);
Expand Down
28 changes: 28 additions & 0 deletions src/journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Map<Fixture, number>> = 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<Fixture, number> {
Expand All @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading