You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 0abcfd1
Browse filesBrowse the repository at this point in the historyBrowse files
Copy file name to clipboardExpand all lines: .agents/hash.md
+36Lines changed: 36 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -35,6 +35,7 @@ Keep these consequences in mind:
35
35
-**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).
36
36
-**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`.
37
37
-**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.
38
39
-**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`:
39
40
- Equal source with different closed-over values is indistinguishable. This limitation appears in the `resolveName` documentation, `.agents/cache.md`, and the guides.
40
41
- 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
72
73
73
74
**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.
74
75
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:
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
+
75
111
## Performance costs and optimizations
76
112
77
113
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:
Copy file name to clipboardExpand all lines: docs/2.functions.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -88,6 +88,8 @@ ocache **escapes** the resolved `name` before it adds the name to the key. It do
88
88
89
89
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.
90
90
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
+
91
93
> [!IMPORTANT]
92
94
> 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`.
// A back-reference terminates here, so a cycle never reaches the ceiling.
76
85
returnref;
77
86
}
87
+
if(depth>=MAX_DEPTH){
88
+
thrownewRangeError(
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.`,
0 commit comments