Argument collections root their elements
Date: 2026-08-06
Area: runtime
A live TGocciaArgumentsCollection is a garbage-collection root source: for as
long as one exists, every value it holds is marked. The collection registers
itself with the thread-local collector when it is constructed and unregisters
when it is destroyed, and TGarbageCollector.MarkRoots walks the registered
sources on every collection. This closes a hole that had no owner before: the
collection is the container every native builtin receives its arguments in, and
it was a plain TInterfacedObject holding a non-owning value list — never
marked, never a root.
Two shapes made that observable rather than theoretical. In bytecode,
TGocciaRegister is a tagged union whose grkInt and grkFloat cases hold raw
scalars, and MarkRegisterReferences marks only grkObject. Building a call
therefore boxes a fresh TGocciaNumberLiteralValue out of a scalar register
straight into the argument collection, and the source register still holds only
the scalar — the boxed number has no root at all. Separately, any builtin that
reads an argument into a native local and uses it after re-entering user code
holds it across a safe point: a getter, a callback, a Proxy trap, and a thenable
are all arbitrary script, and all of them can reach a collection. ADR 0020's
unified heap hierarchy means every such value is collectable; nothing about the
argument path exempted it.
The fix is central rather than per-site because the per-site surface is roughly 528 construction sites across more than 30 units, and the two shapes above are not local to any of them — the first is created by the VM's calling convention, not by the builtin. A per-site sweep would also have to be repeated for every builtin added afterwards. Rooting the container instead states the contract once and makes it structural: a builtin cannot opt out of it, and a new builtin inherits it without knowing it exists.
The mechanism is a TGCRootSource base declared in the collector unit, which is
a leaf and stays one. It keeps TInterfacedObject as its ancestor so the
collection's existing ancestry is unchanged, carries the index of its slot in the
collector's root-source list so unregistration is O(1), and carries a
back-pointer to the collector it registered with. The back-pointer is what makes
the lifetime safe in both directions: collectors are thread-local, so an instance
must unregister from the same one it registered with, and a collector that is
destroyed first nils the back-pointers of everything still registered so a later
destructor cannot unregister into freed memory. Registration is driven from
AfterConstruction/BeforeDestruction rather than from the constructors, so it
happens once per instance regardless of which constructor ran — including the
callback-argument subclasses, whose constructors do not chain to an inherited
one. Removal swaps the last entry into the vacated slot instead of leaving a nil
behind: nothing depends on root-source order, there is no sweep pass to compact
against, and nil slots would otherwise accumulate for the whole of a bytecode
run, during which automatic collection is disabled.
The cost is bounded because the hot paths do not construct a collection per
callback invocation. The VM pools its collections through
AcquireArguments/ReleaseArguments with a pool capped at 32, so a pooled
collection registers once and is reused — the bytecode call path pays nothing per
call. The array callbacks hoist: forEach, map, filter, and reduce build
one TGocciaArrayCallbackArgs or TGocciaReduceCallbackArgs and rewrite its
slots per element, and Array.from/fromAsync and the sort comparator do the
same. Some builtins do construct per iteration — Map.prototype.forEach,
Set.prototype.forEach, the WeakMap/WeakSet iteration helpers, and several
TypedArray conversion paths among them — and those pay one list append and one
O(1) swap-removal per element, with no hashing. At collection time the extra work
is one pass over a list whose length is the number of concurrently live
collections: the VM pool plus the handful of frames in the current builtin's
recursion.
This contract covers what a collection holds, and nothing else. Values a builtin
keeps in its own native state are still its own responsibility, and still need
explicit rooting through AddTempRootIfNeeded or TGocciaActiveRootFrame.
JSON.stringify's replacer traversal is the worked example: its cycle-detection
stack is a plain TList holding live objects across replacer invocations, and
its synthetic wrapper object and partially built copy live only in native locals
while user code runs. None of those are arguments, so all of them are rooted at
their own sites.