Skip to content

Commit 0abcfd1

Browse files
committed
fix(hash): cap traversal depth instead of overflowing the stack
1 parent b8d95f3 commit 0abcfd1

4 files changed

Lines changed: 162 additions & 12 deletions

File tree

β€Ž.agents/hash.mdβ€Ž

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Keep these consequences in mind:
3535
- **A constructor name is caller-controlled text.** A class name can hold any character β€” `{ ["A{},B"]: class {} }["A{},B"].name` is `A{},B` β€” so a raw tag can imitate a member boundary exactly as a string value can. `serialize(new Set([new A()]))` for that class rendered `Set[A{},B{}]`, which is also what a `Set` of one `A` and one `B` rendered. `serTag` routes every tag through `serString`. The one exception is a **plain object**, which keeps its bare `{…}`: it is the common shape and must not churn. An anonymous class therefore renders `'0:{…}`, which is what keeps it apart from `{}` (`hash(new (class {})()) === hash({})` before).
3636
- **Order-independent members.** Sort object, `Map`, and `Set` entries. `{a, b}` and `{b, a}` must share an entry. Object keys are unique and already provide a total order. `Map` entries are sorted as complete rendered `key:value` pairs. Rendered `Map` and `Set` members provide a total order, including for object keys. Use bare `sort()`. Its default string comparator uses code-unit order by definition. Never use `localeCompare`.
3737
- **Cycles terminate** as `#<n>` in visit order. Replace the `seen` entry with the completed rendering. A repeated reference that is not cyclic must still render in full.
38+
- **Nesting terminates at `MAX_DEPTH` (128) with a `RangeError`.** The traversal is recursive, so depth is stack frames. The ceiling is a refusal, never a truncation: a rendering that dropped or elided a subtree would give two different values one key. See "The depth ceiling" below.
3839
- **Functions render as source text** with each line break and the whitespace around it collapsed to **one space**. Equal source produces equal hashes across restarts. This makes `anon_<hash>` names and `integrity` usable with persistent backends. Reindentation alone does not make entries cold, because reindentation only changes whitespace that touches a line break. Collapsing to nothing instead of a space was a bug: it joined the tokens either side, so `() => { foo\nbar }` rendered as `() => { foobar }` and two unrelated functions shared one `anon_<hash>` name and one `integrity`. Three costs remain, and all three are the same fix β€” pass an explicit `name`, `getKey`, or `integrity`:
3940
- Equal source with different closed-over values is indistinguishable. This limitation appears in the `resolveName` documentation, `.agents/cache.md`, and the guides.
4041
- Inside a string or template literal, a line break and a space are the same rendering, so `` () => `a\nb` `` and `` () => `a b` `` share a hash. Telling them apart needs a tokenizer, and reindentation stability is worth more than the distinction.
@@ -72,6 +73,41 @@ The alternative considered was throwing. It was rejected: `serialize` cannot tel
7273

7374
**A shadowed own `constructor` still decides the tag.** `{ constructor: {} }` renders `'0:{…}`, the same shape as an anonymous class instance carrying that own key. Reading the tag off the value is what makes an own `constructor` key safe (see the `ctor == null` branch), and both readings are already the same rendering today.
7475

76+
## The depth ceiling
77+
78+
`ser`, `serObject`, and `serProperties` are mutually recursive, so one level of nesting is one to three stack frames. Without a ceiling, `serialize(JSON.parse("[".repeat(5000) + "]".repeat(5000)))` threw `RangeError: Maximum call stack size exceeded`. That is a 10 kB payload, `JSON.parse` accepts it without complaint (V8 parses iteratively and did not break at four million levels), and the default `getKey` hashes call arguments β€” so a small parsed body failed the call inside the cache layer before the wrapped function ever ran. The handler path is not exposed: `http/key.ts` only ever hashes strings.
79+
80+
**Where the stack actually gives out.** Measured on Node 24 (x64, default `--stack-size`), one cold probe per process, binary-searched on nesting depth:
81+
82+
| Shape | Cold limit | Warm limit (same process) |
83+
| ----------------------------- | ---------- | ------------------------- |
84+
| array (`ser` β†’ `serObject`) | 1787 | ~3900 |
85+
| object (adds `serProperties`) | 1389 | ~3450 |
86+
| `toJSON` chain | 1332 | β€” |
87+
| `Set` / `Map` | 1787 | ~6100 |
88+
89+
The two columns are the reason a ceiling beats leaving it alone: the same input overflows at 1389 on a cold call and survives past 3400 once the traversal is optimized, because an interpreted frame is much larger than an optimized one. The limit also scales with the stack β€” the object shape gives out at 718 with `--stack-size=512`, 354 at 256, and 172 at 128 β€” and it shrinks with whatever stack the caller already used. So the failure boundary moved with the runtime, the JIT tier, and the call site: one argument could hash on one request and throw on the next.
90+
91+
**Why 128.** It has to fire before the stack does in the worst configuration, not the best one, so the cold column is the one that matters and the ceiling has to sit under 1332 with room for a smaller stack. 128 still fires first at `--stack-size=128`, an eighth of Node's default, which covers a constrained runtime and a deep caller stack at the same time. It is also two orders of magnitude above any plausible key shape: a cache key argument is an id and an options object, and JSON body parsers in the same position cap nesting far lower (`qs` defaults to 5).
92+
93+
**The error is a `RangeError`,** not a new class. It is the type the runtime already threw for this condition, so a consumer that catches `RangeError` keeps working; it needs no new export; and the module is already over its bundle budget. The message names the depth and the fix (`getKey`), because it surfaces on a per-request path where the stack trace points at `hash`, not at the caller's argument.
94+
95+
**`toJSON` counts as a level.** The hook returns a fresh value that `seen` cannot stop, so `{ toJSON: () => ({ toJSON: … }) }` recurses without ever revisiting an object. Passing the current depth into `ser(toJSON.call(value), …)` covers it; leaving `toJSON` uncounted would have left the whole overflow reachable through one branch.
96+
97+
**The `seen` lookup stays ahead of the check.** A back-reference terminates without a frame, so a cycle at the ceiling must return its `#<n>` placeholder rather than be refused.
98+
99+
### Rejected: an iterative rewrite
100+
101+
An explicit-stack traversal removes the limit instead of naming it, and it was rejected on measurement. A prototype covering only the two hot shapes (arrays and plain objects, everything else delegated back to the recursive renderer, so it is a **lower bound** on a complete one) cost 3.9x on `[authority, path]`, 2.7x on a 2-arg `args`, and 2.3x on a 100-deep object. The cost is structural: a post-order machine needs a per-node frame and a per-node parts array, which is exactly the allocation the "concatenate members" and "no closure per call" optimizations below exist to avoid. It would also have to reproduce the cycle semantics β€” `#<n>` in visit order, the `seen` entry replaced by the completed rendering, a repeated non-cyclic reference still rendered in full β€” byte for byte across every branch, on a format that has already rotated three times, and it would spend more of a bundle budget that is already exceeded.
102+
103+
### Rejected: leaving it documented
104+
105+
The exposure is real (parsed bodies reach the default `getKey`) and the failure is undiagnosable from the outside: `RangeError: Maximum call stack size exceeded` with a stack full of `ser`/`serObject` says nothing about arguments or `getKey`. The warm/cold split above makes it worse than a plain limit, because it is not reproducible from the input alone.
106+
107+
### What did not change
108+
109+
Everything that renders today renders identically: the ceiling only adds a `depth` argument and a comparison. This was checked two ways against the pre-change implementation, in one process, on the same objects β€” a 101-value corpus covering every branch (including nesting at 1, 2, 8, 64, 100 and 126 levels) and a 20 000-case differential fuzz whose generator reuses objects to produce repeats and cycles. Zero differences in both. Chains of 127 levels render identically in every recursive branch (array, object, class, `Set`, `Map` key, `Map` value, `toJSON`); 128 is where each throws. The `depth` argument costs nothing measurable at the `hash` level: `[authority, path]` and a 2-arg `args` are within run-to-run noise, and strings still return before traversal. Bundle: +503 raw, +199 min, ~+125 gzip, the same on both platforms in `test/bundle.ts`, so it is shared logic rather than an arm of `#crypto`.
110+
75111
## Performance costs and optimizations
76112

77113
An automatic HTTP key always hashes the authority and path once. It adds one hash for each keyed header and one for an allowlisted cookie subset. Each miss also hashes the body etag. This is a per-request cost, not only a definition-time cost. Measurements on Node 24 and an i7-10700K show this cost per call:

β€Ždocs/2.functions.mdβ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ ocache **escapes** the resolved `name` before it adds the name to the key. It do
8888

8989
The default argument hash reads values it can read synchronously. This includes plain objects, arrays, `Map`, `Set`, `Date`, `URL`, `URLSearchParams`, `Headers`, typed arrays, and any class that provides `toJSON` or keeps its state in own enumerable properties. It cannot read a value whose contents are asynchronous or hidden, such as a `Blob`, a `ReadableStream`, a `Promise`, a `Request`, or a `WeakMap`. Such an argument contributes only its type to the key, so two different ones share an entry. Pass a `getKey` that derives the key from something readable when an argument of this kind decides the result.
9090

91+
The default argument hash also reads at most **128 levels** of nesting. An argument nested deeper than that throws a `RangeError` before the cached function runs. Arguments come from parsed request bodies in many applications, and a payload of a few kilobytes can nest thousands of levels deep, so this limit is a fixed refusal rather than a stack overflow whose depth moves between runtimes. Pass a `getKey` that reads the few fields the result depends on when an argument can be arbitrarily deep.
92+
9193
> [!IMPORTANT]
9294
> A source hash cannot distinguish two functions that have identical source but different closed-over variables. These functions share a key and repeatedly replace each other's entries. Give them an explicit `name` or `getKey`. The same holds for a **bound** function, whose source reads as `function () { [native code] }`: `fn.bind(null, 1)` and `fn.bind(null, 2)` hash alike, so give each an explicit `integrity` when they share a `name`.
9395
>

β€Žsrc/hash.tsβ€Ž

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import { digest } from "#crypto";
88
// enable it. Reading it once keeps the branch below a comparison instead of a per-call lookup.
99
const SharedBuffer = globalThis.SharedArrayBuffer as SharedArrayBufferConstructor | undefined;
1010

11+
// Nesting maps one-to-one onto stack frames, so without a ceiling a parsed payload overflows the
12+
// stack at a depth that varies with the runtime and the JIT tier. This one fires first down to an
13+
// eighth of Node's default stack. See `.agents/hash.md`.
14+
const MAX_DEPTH = 128;
15+
1116
/** Returns a base64url SHA-256 digest of the serialized input. */
1217
export function hash(input: unknown): string {
1318
return digest(serialize(input));
@@ -22,10 +27,12 @@ export function hash(input: unknown): string {
2227
* Functions use source text, so equal-source closures are indistinguishable.
2328
* A value with no synchronously readable contents, such as a `Blob` or a `Promise`, renders as
2429
* its type tag alone, so every value of that type shares a key.
30+
*
31+
* @throws {RangeError} when the value nests deeper than 128 levels.
2532
*/
2633
export function serialize(input: unknown): string {
2734
// Strings do not need cycle tracking.
28-
return typeof input === "string" ? serString(input) : ser(input, new Map<object, string>());
35+
return typeof input === "string" ? serString(input) : ser(input, new Map<object, string>(), 0);
2936
}
3037

3138
/**
@@ -49,7 +56,8 @@ function serTag(ctor: { name?: string }): string {
4956
return serString(typeof ctor.name === "string" ? ctor.name : "");
5057
}
5158

52-
function ser(value: unknown, seen: Map<object, string>): string {
59+
// `depth` counts the object levels already entered above `value`.
60+
function ser(value: unknown, seen: Map<object, string>, depth: number): string {
5361
if (value === null) {
5462
return "null";
5563
}
@@ -73,10 +81,16 @@ function ser(value: unknown, seen: Map<object, string>): string {
7381
case "object": {
7482
const ref = seen.get(value);
7583
if (ref !== undefined) {
84+
// A back-reference terminates here, so a cycle never reaches the ceiling.
7685
return ref;
7786
}
87+
if (depth >= MAX_DEPTH) {
88+
throw new RangeError(
89+
`[ocache] Cannot hash a value nested deeper than ${MAX_DEPTH} levels. Pass an explicit \`getKey\` that derives the key from what identifies the value.`,
90+
);
91+
}
7892
seen.set(value, `#${seen.size}`);
79-
const serialized = serObject(value, seen);
93+
const serialized = serObject(value, seen, depth + 1);
8094
seen.set(value, serialized);
8195
return serialized;
8296
}
@@ -86,19 +100,20 @@ function ser(value: unknown, seen: Map<object, string>): string {
86100
}
87101
}
88102

89-
function serObject(value: object, seen: Map<object, string>): string {
103+
// `depth` counts the object levels entered, including `value` itself.
104+
function serObject(value: object, seen: Map<object, string>, depth: number): string {
90105
if (Array.isArray(value)) {
91106
let items = "";
92107
for (let index = 0; index < value.length; index++) {
93-
items += index === 0 ? ser(value[index], seen) : `,${ser(value[index], seen)}`;
108+
items += index === 0 ? ser(value[index], seen, depth) : `,${ser(value[index], seen, depth)}`;
94109
}
95110
return `[${items}]`;
96111
}
97112
const ctor = (value as { constructor?: { name?: string } | null }).constructor;
98113
// Tag class instances, but keep common plain objects untagged. Parsed input can carry an own
99114
// `constructor` key, so a null one reads as a plain object like a null prototype does.
100115
if (ctor === Object || ctor == null) {
101-
return serProperties("", value, seen);
116+
return serProperties("", value, seen, depth);
102117
}
103118
if (value instanceof Date) {
104119
// Render all invalid dates identically without calling `toISOString`.
@@ -127,15 +142,15 @@ function serObject(value: object, seen: Map<object, string>): string {
127142
if (value instanceof Set) {
128143
const parts: string[] = [];
129144
for (const item of value) {
130-
parts.push(ser(item, seen));
145+
parts.push(ser(item, seen, depth));
131146
}
132147
return `Set[${parts.sort().join(",")}]`;
133148
}
134149
if (value instanceof Map) {
135150
// Sort rendered pairs because Map keys may not be comparable.
136151
const parts: string[] = [];
137152
for (const [key, item] of value) {
138-
parts.push(`${ser(key, seen)}:${ser(item, seen)}`);
153+
parts.push(`${ser(key, seen, depth)}:${ser(item, seen, depth)}`);
139154
}
140155
return `Map{${parts.sort().join(",")}}`;
141156
}
@@ -150,15 +165,21 @@ function serObject(value: object, seen: Map<object, string>): string {
150165
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength).join(",");
151166
return `${serTag(ctor)}[${items}]`;
152167
}
153-
return serProperties(serTag(ctor), value, seen);
168+
return serProperties(serTag(ctor), value, seen, depth);
154169
}
155170

156171
// `tag` is already rendered: `""` for a plain object, a length-prefixed name for a class.
157172
// Prefer `toJSON` because enumerable properties omit private state and getters.
158-
function serProperties(tag: string, value: object, seen: Map<object, string>): string {
173+
function serProperties(
174+
tag: string,
175+
value: object,
176+
seen: Map<object, string>,
177+
depth: number,
178+
): string {
159179
const toJSON = (value as { toJSON?: unknown }).toJSON;
160180
if (typeof toJSON === "function") {
161-
return `${tag}(${ser(toJSON.call(value), seen)})`;
181+
// The result is a fresh value that `seen` cannot stop, so it counts as a level like any child.
182+
return `${tag}(${ser(toJSON.call(value), seen, depth)})`;
162183
}
163184
// Use code-unit order; locale-dependent order would change keys across machines.
164185
const keys = Object.keys(value).sort();
@@ -167,7 +188,7 @@ function serProperties(tag: string, value: object, seen: Map<object, string>): s
167188
const key = keys[index]!;
168189
// A key is caller-controlled text like any other; render it the same way.
169190
parts += index === 0 ? `${serString(key)}:` : `,${serString(key)}:`;
170-
parts += ser((value as Record<string, unknown>)[key], seen);
191+
parts += ser((value as Record<string, unknown>)[key], seen, depth);
171192
}
172193
return `${tag}{${parts}}`;
173194
}

0 commit comments

Comments
Β (0)