Share converted objects instead of nilling every repeat - #101
Conversation
6d3acaa to
e8938c7
Compare
|
Reviewed this properly rather than skimming it, because the memo changes what a converted value is and not only what it contains. It holds up. Notes below are one thing I would like added, one mechanical thing, and some corroboration for claims that were worth checking rather than taking on trust. Verified independentlyBuilt and probed on this branch, against
The claim about #99 is right, and worth having checked. Identical inputs,
objects leaked per eval, 3,000 evals each. The pins are released on the raising path, so this adds nothing to that leak. One thing I looked at and found fine: an object excluded from the memo pins on every occurrence, so One request: the README should say that occurrences are sharedThis introduces an observable property that the type table does not cover. The same JS object reached twice is now the same Ruby object, so: r = Quickjs.eval_code('const o = {a: 1}; [o, o]')
r[0]['a'] = 999
r #=> [{"a" => 999}, {"a" => 999}]Before this, nobody could depend on either behaviour, because the second occurrence was The two properties in your description, one snapshot per object and the truncation point following traversal order, I would leave in the code and the issue where they are. They are reasoning about the implementation. Aliasing is something a caller can trip over. MechanicalNeeds a rebase: this is on On the testsThe ten cases cover this better than I expected, including the one I asked for on #90: an object that is both an ancestor and a share. |
Cycle detection kept a single visited set for the whole conversion and
never removed entries from it, so it answered "have I ever seen this
object?" when the question it needs to ask is "is this object an ancestor
of the one I am converting?". Any object reached a second time became
nil, cycle or not:
Quickjs.eval_code('const o = {a: 1}; [o, o]')
#=> [{"a"=>1}, nil]
Non-cyclic sharing is ordinary — a config object referenced from several
places, a repeated record, shared nodes in a parsed tree — and half of it
vanished with no error and nothing to distinguish it from a real null.
Before hmsk#20 the JSON.stringify round trip duplicated shared subtrees, so
this was a regression rather than a longstanding limitation.
Replace the set with a memo. An object's address maps either to a marker
meaning "still converting, so this occurrence is a cycle" or to the Ruby
value it finished producing, which every later occurrence gets back.
Cycles still convert to nil; shares convert once and stay shared on the
Ruby side. That also makes the exponential expansion of a deep DAG
structurally impossible instead of merely unlikely, which matters when
the guest chooses the shape.
The map is keyed by raw address, and property getters and toJSON run
guest JS that can drop the last reference to an object and let a new one
be allocated at the same address. So every tracked object is pinned with
JS_DupValue and released in an ensure. Without the pin an object minted
by a getter answers to an earlier object's entry; the regression test
added here fails exactly that way if the pin is removed.
toJSON results are deliberately left out of the memo. They are the
object's stand-in rather than a container this conversion built, so
sharing them handed both slots of [d, d] the same unfrozen String for a
Date, where one <<= rewrites the other. Recomputing per occurrence also
matches JSON.stringify calling toJSON once per occurrence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The conversion table says what each JS type becomes, not what happens when the same object is reached twice. That was not worth documenting while the second occurrence was nil — nobody could depend on it either way — but now the occurrences are one Ruby object, and a caller mutating one branch of a result silently changes the others. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e8938c7 to
6021c93
Compare
|
Both addressed. README. Added under the conversion table, in the shape you asked for — the aliasing a caller can trip over, with the mutation shown rather than described, plus one line that a cycle converts to Rebased onto Thank you for checking the #99 claim with the same inputs on both sides. That was the one I most wanted a second measurement on: "the pins are released on the raising path" is easy to assert and hard to see, and identical per-eval counts on The Your prototype- |
|
Flagging the sequencing here too, since it decides what happens next in both places: this should land before the #99 work, and it needs only the README line from my review above. I have asked on #99 to hold the follow-up until then rather than stacking on this branch. To restate the one ask, so it is not buried: the same JS object reached twice now converts to the same Ruby object, so mutating one branch of a result mutates the others. That is worth a line under Value Conversion. A rebase onto Everything else I checked held up, including the leak parity with |
|
Both addressed, and the README wording is better than what I asked for. Splitting it into its own commit was the right call, and "mirroring the graph JavaScript built" says why the sharing is correct rather than only that it happens, which is the part a reader needs to accept it as a feature instead of a leak. The cycle sentence after it closes the obvious next question. Ran the README example verbatim to be sure the documentation is not aspirational: Rebase looks right, on 654acbc. 601 runs, 0 failures locally; 11 of 11 green on CI. Nothing further from me. |
Fixes #90.
The visited set added for cycle detection was never unwound, so it answered "have I ever seen this object?" rather than "is this object an ancestor of the one I am converting?". Every object reached twice became
nil, whether or not a cycle was involved.Now both branches convert, and the two occurrences are the same Ruby object — which is what the guest built.
What changed
An object's address maps either to a marker meaning "still converting, so this occurrence is a cycle" or to the Ruby value it finished producing. One map serves both roles; using it as its own marker cannot collide with any value an object could convert to.
Cycles are unaffected — the existing tests from #33 pass untouched. Deep DAGs stop expanding: the 20-level example from the issue goes from losing every right-hand branch to converting in 0.2 ms with one hash per level.
Pinning. The map is keyed by raw addresses, and getters and
toJSONrun guest JS that can free an object and let a new one land on the same address. Every tracked object is pinned withJS_DupValueand released in anrb_ensure. This is load-bearing rather than defensive: removing the pin makes the newan object freed by a getter cannot be mistaken for an earlier onetest fail with the recycled object's value.toJSONresults are not memoized. They are the object's stand-in rather than a container this conversion built. Sharing them handed both slots of[d, d]the same unfrozenStringfor aDate, where<<on one rewrites the other. Recomputing per occurrence also matchesJSON.stringify. WhentoJSONreturns another object, that object's conversion is still shared — correctly, since the stand-in genuinely is that object.Two properties worth knowing
Both are discussed at more length in the issue.
cur.self = cur), so the memo is unconditional and the asymmetry is pinned by a test instead.Review
Two adversarial reviews, on memory safety and on behaviour. Findings applied here:
toJSONresults excluded from the memo,conv_pinpublishing the new buffer before freeing the old one, the address-reuse regression test, and a misleading comment on the DAG test. The reviews found no GC-safety defect (ConvStateis stack-resident, so conservative scanning pins it; verified underGC.compactfrom inside getters andGC.stress), no leak on the pin path, and proved the NULL-convfast path cannot reach the object branch.Pre-existing issues they turned up are filed separately: #98 (revoked
Proxyconverts to[]) and #99 (conversion leaks JS objects when it raises — same walk, worth deciding alongside this).Note on #81: releasing the pins issues refcount writes at the end of a conversion, so a
dispose!from a getter now lands a few more writes in freed storage. The extra writes are new, the use-after-free is not, and #81's fix removes both.Benchmark
100k conversions, median of three runs:
A second measurement on larger payloads (20 × a 20k-element array of objects) came out at 0.292 s → 0.311 s, i.e. within the same range. The cost is the
rb_ensureand one extra hash write per object; primitives skip the machinery entirely. Two separate maps cost twice as much, which is why there is one.579 runs, 0 failures.
🤖 Generated with Claude Code