Answer a wrapped dictionary's existence questions from ContainsKey - #2969
Conversation
An ObjectWrapper caches the descriptor it resolves for a reflected member in _properties, so a second question about that name is a bag lookup. It deliberately does not for a dictionary key, because the dictionary can change under the wrapper - which left every existence question re-running the whole read: TryGetValue, then FromObject on the value, then a PropertyDescriptor around it, and then discarding both to answer a yes/no. FromObject on an object value builds a whole nested ObjectWrapper, so Object.keys over a JSON-shaped document wrapped one child per object-valued key, per call, and threw them all away. in, hasOwnProperty, propertyIsEnumerable, for..in, Object.assign, spread and JSON.stringify all reach the same probe. ObjectWrapper now overrides ProbeOwnProperty and asks the target's own ContainsKey, which sebastienros#2839 already compiled - it was reachable only from HasProperty's non-string-keyed arm, the one shape whose keys never enumerate. The lane only ever answers; it never decides a key is missing. A false from ContainsKey falls through to the descriptor path, because a wrapped dictionary still resolves CLR members for names the dictionary does not carry (Count, and every method) and that arm is not this method's to reproduce. So a miss costs one extra compiled ContainsKey on top of a resolver lookup it already paid, and the only way probe and GetOwnProperty can disagree is a target whose own ContainsKey and TryGetValue disagree with each other - which the compiled dictionary lanes already trust everywhere else. The three things that outrank a dictionary key are checked in GetOwnProperty's own order: a stored descriptor (Object.freeze, Object.defineProperty, a host SetOwnProperty), the pending length forwarder, and a symbol key. The crossing memo needs no check of its own - it holds descriptors for keys the dictionary has, carrying the flags every dictionary member gets, so it can only agree. TypeDescriptor gains CanTestDictionaryKey because ContainsDictionaryKey answers false both for "absent" and for "there was no ContainsKey to ask", and reading the second as the first is exactly the silent key-dropping this contract warns about. Pinned from both sides. OwnPropertyProbeTests is the in-box checker - it probes a spread of keys first and then requires each answer to match the descriptor, across a plain key, an object-valued key, a key shadowing a CLR member, a CLR member name, an absent name, an index-shaped name, a symbol, and a key a script has redefined non-enumerable. HostDictionaryEnumerationTests asserts the script-visible half from an embedder's vantage, with the host dictionary itself as the oracle, including that a counting IDictionary implementation sees exactly one ContainsKey and no TryGetValue for `'a' in doc`. InteropDictionaryProbeBenchmark is the new gate: InHit, HasOwnProperty, ObjectKeys and ForIn should improve in both columns, with ObjectValues and ReadMember as the controls that need the value and must not move, and InMiss as the control for what asking costs when the answer is no. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Benchmark gateTargeted —
The allocation column is the byte-deterministic channel and confirms every designed row: the discarded per-probe descriptor and nested wrapper are gone. Neighbours: Wide — SunSpider (26) + Dromaeo (24), single pair: allocation flat on all 50 rows. No SunSpider or Dromaeo script wraps a CLR dictionary, so no row executes the new code; the time swings (both directions, up to ±12%) appear on exactly those unreachable rows and contradict the movements the unrelated #2968 candidate showed on the same rows against the same baseline — single-draw baseline noise, reported as such. Machine idle, serial, default BDN job, both sides from their own worktree CWD; the new benchmark class was ported byte-identical to the baseline side so both ran the same harness. |
Full re-measure of both suites (script + interop, one session, default job, idle machine: 96 script rows and 20 interop rows) on the 4.16.0 release candidate 7b56c83. Every figure in the narrative sections is recomputed from the new reports; no number is carried forward from the 4.15.0 tables. Where the table stands now: - Jint is fastest outright on 5 of 12 scripts (minimal ~345x V8's compiled lane, evaluation-modern ~80x, linq-js ~6.7x, dromaeo-core-eval-modern ~5%, dromaeo-object-regexp-modern 1.25x ahead of V8's fresh-context lane and 1.46x ahead of its compiled lane), fastest managed engine on 10 of 12, and fastest interpreter on all 12 - V8 keeps the tight-loop rows: base64 9.8x, object-string 6.6x, stopwatch 6.0x, 3d-cube 3.4x, json-parse 2.2x, plus narrow leads on object-array (1.08x) and array-stress (1.09x) - array-stress being the one script row that changes hands, out of the rank-1 tie it held at 4.15.0 - Allocation: Jint is lowest of the managed engines on 10 of 12 scripts (Okojo on object-array, NiL.JS on minimal) and on all four interop rows, 3.9x-12.4x under the nearest managed competitor there - Interop: rank 1 on string-passing, and back into a rank-1 tie with NiL.JS on collection-traversal (1,251.0 vs 1,242.7 us, 0.7% apart); rank 2 on method-calls (NiL.JS ahead) and property-access (YantraJS by 2.6%). Plain ClearScript costs 8.6x-11.2x against Jint, FastProxy 3.4x-7.0x Adds a "What changed for 4.16.0" section: proper tail calls (#2975, which measured -15.6% time and -40.4% allocation on the Jint-only controlflow-recursive row against 4.15.3), the fast-call lane's growth (#2968, #2980, #2984), the wrapped-dictionary probe lane (#2969), and the disclosed cost of the join-hole re-read (#3003, +3.4% on hole-heavy joins). The comparison against the 4.15.0 tables is stated as directional only - the two sessions ran on .NET 10.0.10 -> 10.0.11 with YantraJS 1.2.419 -> 1.2.422 in between, so no row-for-row delta is claimed - and the two stale prose claims naming 4.15.0 outside the history sections are refreshed from this session's data. Environment: AMD Ryzen 9 5950X, .NET 10.0.11 (SDK 10.0.400), BenchmarkDotNet 0.15.8, default job, otherwise idle machine. ClearScript's V8 lanes land within ~3% of the 2026-07-28 session on the identical package. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ObjectWrapperhad noProbeOwnPropertyoverride, so every existence question —in,hasOwnProperty, and the per-key filter behindObject.keys/for-in/spread/JSON.stringify— fell to the base implementation, whose answer is the read:TryGetValue,FromObjecton the value (for an object value, a whole nestedObjectWrapper), aPropertyDescriptoraround it — then both discarded. Dictionary members are the one thing the wrapper never caches, so this repeated on every call:Object.keysover a wrapped JSON document paid it once per key. #2839 compiledContainsKey, but onlyHasProperty's non-string-keyed arm could reach it — the one shape whose keys never enumerate.The override answers
Enumerableon aContainsKeyhit and otherwise falls through tobase— it never decides a key is missing, because a wrapped dictionary still resolves CLR members for names the dictionary does not carry. The only possible disagreement is a target whose ownContainsKeyandTryGetValuedisagree, which the compiled lanes already trust. Precedence mirrorsGetOwnProperty's own order.TypeDescriptor.CanTestDictionaryKeyexists becauseContainsDictionaryKeyreturnsfalseboth for "absent" and for "there was noContainsKeyto ask" — and reading the second as the first is exactly the silent key-dropping the probe contract warns against.Proof it engages: a counting
IDictionaryshows exactly oneContainsKeyand zeroTryGetValuefor'a' in doc. The in-box agreement checker covers plain keys, object-valued keys, a key shadowing a CLR member, a CLR member name, an absent name, an index-shaped name, a symbol, and a script-redefined non-enumerable key; the embedder-side half lives inJint.Tests.PublicInterface/HostDictionaryEnumerationTests.cswith the host dictionary as oracle. Green underJINT_HOST_CONTRACT_VERIFICATION=1on both projects — which verifies every consumed probe againstGetOwnProperty— and AGENTS.md's dictionary gotcha records the new lane.The new benchmark's own
InMissrow is the honest control: a miss can only get slower (one extra compiledContainsKeyon top of the resolver lookup it already paid), and by how much is a number the gate reports rather than hides.ObjectValuesandReadMemberare value-needing controls no existence answer can serve.Full solution green on both TFMs; test262 exactly 99,738 / 0 / 157.
🤖 Generated with Claude Code