Skip to content
Open
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
49 changes: 49 additions & 0 deletions packages/cachekit/src/serialization/interop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,55 @@ describe('interop argument encoding (args profile)', () => {
});
});

describe('interop Set encoding budgets (encodeCanonical, shared by both profiles)', () => {
it('rejects a Set whose elements are collectively over budget during iteration', () => {
// 32 × ~64 KiB elements: each far under the 1 MiB budget, ~2 MiB in
// aggregate. Set element sub-encodes buffer into `encoded[]` before the
// parent sink sees any bytes, so the running total must be threaded
// across the loop — otherwise all 32 elements materialise before the
// budget fires. Getter spies count how many elements were actually
// encoded: each element encodes to 65,550 bytes, so the throw lands
// during element 16 — mid-iteration, not after the loop.
const total = 32;
let encoded = 0;
const elements = Array.from({ length: total }, (_, i) => ({
get payload(): string {
encoded++;
return `${i}:`.padEnd(64 * 1024, 'x');
},
}));
expect(() => encodeInteropValue(new Set(elements))).toThrow(ValueTooLargeError);
expect(encoded).toBeLessThan(total);
});

it('accepts a duplicate-heavy Set whose deduped encoding fits the budget', () => {
// 20 distinct-identity objects with identical canonical encodings: ~4 MiB
// pre-dedupe, ~200 KiB deduped. Dedupe happens on insert, so duplicates
// advance neither the byte budget nor the count — this must encode
// byte-identically to the singleton, not throw at the pre-dedupe sum.
const dup = (): { k: string } => ({ k: 'x'.repeat(200 * 1024) });
const many = new Set(Array.from({ length: 20 }, dup));
expect(many.size).toBe(20);
expect(encodeInteropValue(many)).toEqual(encodeInteropValue(new Set([dup()])));
});

it('rejects a Set with too many distinct elements during iteration', () => {
// 10,002 tiny distinct elements: far under the byte budget, over the
// 10,000 collection cap. The cap fires on the 10,001st retained element,
// not after the full Set has been encoded and buffered.
const total = 10_002;
let encoded = 0;
const elements = Array.from({ length: total }, (_, i) => ({
get n(): number {
encoded++;
return i;
},
}));
expect(() => encodeInteropValue(new Set(elements))).toThrow(ValueTooLargeError);
expect(encoded).toBeLessThan(total);
});
});

describe('interop value encoding (value profile)', () => {
it('maps undefined to nil (no cross-SDK arity contract for values)', () => {
expect(hex(encodeInteropValue(undefined))).toBe('c0');
Expand Down
35 changes: 26 additions & 9 deletions packages/cachekit/src/serialization/interop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,11 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array {
*/
interface ChunkSink {
chunks: Uint8Array[];
/** Running encoded byte count (starts above 0 for Set element sub-encodes). */
/**
* Running budget cursor. Set element sub-encodes seed it with the parent
* total plus prior retained elements' bytes, so it may exceed the total
* length of `chunks`.
*/
bytes: number;
}

Expand Down Expand Up @@ -410,19 +414,32 @@ function encodeCanonical(
// encoded bytes (unsigned lexicographic) and dedupe post-normalization —
// a total, language-neutral order (spec: "Set ordering is not numeric
// order").
// Elements encode into isolated sub-sinks (the byte-order sort needs each
// element's bytes) threaded through a running byte total, and dedupe
// happens on insert, so the byte budget and the collection-size cap both
// fail DURING iteration — before every element is encoded and buffered —
// while counting exactly what the output retains: duplicates advance
// neither total. Acceptance matches checking after the loop, except that
// a duplicate arriving with less than its own encoded size left in the
// byte budget still throws mid-encode (duplicateness is unknowable until
// encoded). The parent's own total advances once, on the pushes below.
const encoded: Uint8Array[] = [];
const seen = new Set<string>();
let running = sink.bytes;
for (const element of v) {
// Isolated sub-sink (the byte-order sort needs each element's bytes),
// inheriting the parent's running total so a huge element fails fast.
// Parent bytes are counted once, when the deduped buffers are pushed.
const sub: ChunkSink = { chunks: [], bytes: sink.bytes };
const sub: ChunkSink = { chunks: [], bytes: running };
encodeCanonical(element, profile, depth + 1, sub);
encoded.push(concatChunks(sub.chunks));
const bytes = concatChunks(sub.chunks);
const key = bytesToHex(bytes);
if (seen.has(key)) continue;
seen.add(key);
checkCollectionSize(seen.size, 'array');
running = sub.bytes;
encoded.push(bytes);
}
encoded.sort(compareBytes);
const deduped = encoded.filter((b, i) => i === 0 || compareBytes(b, encoded[i - 1]!) !== 0);
encodeArrayHeader(deduped.length, sink);
for (const b of deduped) pushChunk(sink, b);
encodeArrayHeader(encoded.length, sink);
for (const b of encoded) pushChunk(sink, b);
} else if (Array.isArray(v)) {
encodeArrayHeader(v.length, sink);
for (const item of v) {
Expand Down