Realm-owned prototype method hosts and object-reference threadvar audit
Date: 2026-06-28
Area: engine
Issue: #892
Pull Request: #904
Follow-up to #885 / #891 (ADR 0078), which released managed threadvars (AnsiString, dynamic arrays) at thread exit via Goccia.ThreadCleanupRegistry but deliberately scoped out object-reference threadvars because FPC never auto-finalizes object references anywhere. #892 audits those; its sibling ADR 0083 (#893) migrates the remaining explicit per-thread cache clears into that same registry. There are two categories.
Object-reference container threadvars. The audit found genuine leakers and already-safe sites. Goccia.Values.SymbolValue's GSymbolRegistry: THashMap<Integer, TGocciaSymbolValue> was write-only — every TGocciaSymbolValue added itself and nothing ever read the map, so it grew unboundedly per thread for no purpose; it is removed as dead code. Goccia.ObjectModel's GPublishedGetterHosts: TObjectList (which owns its entries) and Goccia.Compiler.Statements' GUsingResources / GLabelControls working-state lists are created lazily and reused across compilations but never freed, leaking one container per thread; each now registers a FreeAndNil-based cleanup with Goccia.ThreadCleanupRegistry, released on both the worker-exit and main-thread-finalization paths exactly as in #891. The other compiler working-state lists (GBreakJumps/GContinueJumps via BeginLoopControl/EndLoopControl, GPendingFinally via Save/RestorePendingFinally, GPreallocatedUsingDisposeSlots via its switch-local save/restore) are balanced and nil at rest, so they stay as-is with a comment. Sites that were already safe are documented in place rather than changed: Goccia.Builtins.GlobalSymbol's GSharedSymbolRegistry is reference-counted and frees (and unpins its symbols) when the last TGocciaGlobalSymbol on the thread is destroyed; the non-owning "current" pointers (CurrentRealm, the active generator continuation, the active bytecode generator, the RegExp prototype cache, the source-pipeline options scope) point to objects owned elsewhere and are save/restored or overwritten per run; the per-thread Instance singletons (GC, call stack, microtask queue) are shut down explicitly in ShutdownThreadRuntime.
Manually-pinned prototype method hosts. Several value types kept a threadvar FPrototypeMethodHost / FMethodHost that InitializePrototype assigned and pinned process-wide via PinObject, never unpinning it, so cached member definitions — whose procedure of object pointers embed the host instance — stayed valid across realms on the thread. That host is a bounded per-thread pin that outlives every realm and is never reclaimed. Seven of them — Object, Array, String, Number, Boolean, Symbol, Iterator — now store the host in a per-realm slot via SetSlot, so the realm pins it on creation and unpins it on Destroy, exactly as it already does for the prototype objects themselves (see garbage-collector.md). Because the member definitions bind to the per-realm host, they are rebuilt per realm instead of cached cross-realm, and the corresponding #891 ClearThreadvarMembers registrations for those seven units are dropped (their member-definition threadvars no longer exist). This is the realm-slot direction the issue proposed.
Cross-realm member caches over realm-owned hosts (TGocciaSharedPrototype). A follow-up audit covered the ~30 value units that build their prototype via TGocciaSharedPrototype.Create(Self) (Map, Set, the weak collections, Promise, ArrayBuffer/SharedArrayBuffer, all Intl*/Temporal*, Headers, Response, TextDecoder, the FFI values, FinalizationRegistry). Their host was already realm-owned — the realm frees the TGocciaSharedPrototype owned slot on Destroy, which unpins the host — but they still cached their member definitions in a cross-realm threadvar guarded by if Length(FPrototypeMembers) = 0, i.e. the same realm-owned-host + cross-realm-cache combination, so a later realm reused callbacks bound to an earlier realm's freed host. That was not observably exploitable (every bound callback re-derives its receiver from the JS this and none is virtual, so the freed host was passed as Self but never dereferenced), but it relied on that implicit invariant. All ~30 are migrated the same way as the seven above: drop the cross-realm FPrototypeMembers cache, rebuild the member definitions per realm bound to the current realm's host, and drop their #891 ClearThreadvarMembers. This also makes safe the only two callbacks that reference the host at all — TGocciaTemporalZonedDateTimeValue.ZonedDateTimeSubtract and TGocciaTemporalPlainYearMonthValue.YearMonthSubtract, which delegate to a non-virtual sibling …Add via Self: with members rebuilt per realm, that Self is always the current realm's live host rather than a possibly-freed earlier one. Most constructor-style builtins are a separate, safe category — they cache FStaticMembers bound to process-pinned hosts. One is not: Goccia.Builtins.GlobalRegExp cached its FPrototypeMembers/FStaticMembers cross-realm over a per-realm TGocciaGlobalRegExp host that TGocciaEngine frees on Destroy, and it is migrated here too. It is the one genuinely exploitable member of this family. Unlike the thirty units above, two of its cached prototype callbacks — RegExp.prototype[Symbol.matchAll] and [Symbol.split] — read the host's FRegExpConstructor field and dereference it on the SpeciesConstructor fallback path (reached when the receiver's constructor is undefined), so a second realm on the thread reaches a freed host and crashes with an access violation. Dropping both caches and rebuilding per realm removes it; the crash is reproduced and then gated by Goccia.SharedPrototypeRealmReuse.Test (see Verification).
Two alternatives were rejected. Unpin-and-nil the threadvar host on teardown (keeping the cross-realm cache) is a smaller diff but keeps a per-thread pin released only at thread exit and still gives the host and its prototype divergent lifetimes; the realm-owned slot ties them together and reclaims both with the realm. Migrating the other builtins' FStaticMembers caches is out of scope — their hosts are process-pinned rather than realm-owned, so they fall outside this hazard (GlobalRegExp, whose host is realm-owned, is migrated above).
BigInt is deliberately excluded. Its host is TGocciaBigIntValue.BigIntZero — the 0n process-wide singleton (a class var), which Goccia.Builtins.GlobalBigInt passes to InitializePrototype. 0n is the same fixed-value-singleton category as the primitive singletons (undefined, NaN, …): pinned for the process lifetime by BigIntZero itself and out of #892's per-thread-leak scope (the threadvar holds only a non-owning pointer to it). Routing it through a realm slot made the realm unpin 0n on Destroy, freeing the shared singleton and breaking every engine created after the first on a thread. The host therefore stays a threadvar, documented in Goccia.Values.BigIntValue. The lesson: a Self-as-host migration to a realm slot is valid only when Self is a per-realm instance, never a cross-realm or process-wide singleton.
Verification. Full JavaScript suite 11046/11046 in both interpreter and bytecode modes — unchanged across the seven-unit host migration, the thirty-unit TGocciaSharedPrototype migration, and the BigInt exclusion. Goccia.ThreadCleanupLeak.Test — extended with an idempotency case that proves the new FreeAndNil container cleanups survive a repeated registry drain without double-freeing the eagerly-created published-getter host list — and Goccia.Threading.Test stay green, and Goccia.SharedPrototypeRealmReuse.Test runs many realms on one thread with a forced GC collection and heap stomp between them, exercising the shared-prototype types in the later realms so a reintroduced cross-realm cache would crash or corrupt. That test also drives the RegExp builtin specifically: because its host is freed to the FPC heap (not the GC heap), the test poisons that heap and forces the SpeciesConstructor fallback, so the stale-host dereference faults with an access violation against the pre-fix code and passes after — the one member of this family with a deterministic crash repro. The worker spawn→exit path runs many engines per thread, which is precisely what surfaced the BigInt regression during development. The per-realm member rebuild's cost is negligible: A/B-ing two clean test-runner binaries over many independent engines on one thread (the cache-vs-rebuild path) showed no measurable wall-clock change — within run-to-run variance — and a deterministic +0.2–0.3% in bytes allocated per engine (the rebuilt member-definition collections), with identical GC collection counts and no per-engine retention; the extra allocation is allocator-mitigated, consistent with ADR 0081. core-patterns.md. garbage-collector.md.