Browse documentation

Thread-local cleanup registry for managed threadvars

Date: 2026-06-27 Area: engine Issue: #885 Pull Request: #891

FPC does not auto-finalize managed threadvars (AnsiString, dynamic arrays, interfaces) when a thread exits. The engine caches per-thread state in managed threadvars: ~64 builtin and value units hold their member-definition arrays (FStaticMembers / FPrototypeMembers : TArray<TGocciaMemberDefinition>, whose records carry string fields), and two units hold input memos (the RegExp input-decode memo, #805, and the is-ASCII memo, #806). Worker threads spawned by TGocciaThreadPool rebuild these caches per thread, so each worker leaked its last-held copies on exit. The leak is bounded (one copy per thread, overwritten in place) but matters for embedders that create and destroy many engine threads, and it is a prerequisite for any future heaptrc zero-leak gate. Per VISION.md this is a cleanliness / embeddability fix, not a performance change.

Added Goccia.ThreadCleanupRegistry: a dependency-free unit exposing RegisterThreadvarCleanup(proc) and RunThreadvarCleanups. Each leaking unit declares a small ClearThreadvarMembers proc that SetLengths its own member-definition threadvar(s) to zero and registers it once from the unit's initialization section. Goccia.Threading.ShutdownThreadRuntime drains the registry on every worker thread before it exits, and the registry unit's own finalization drains it on the main thread at process shutdown — so each callback releases the calling thread's copy on both paths, with no per-unit finalization boilerplate. Registration happens only during unit initialization, which FPC runs single-threaded before the program body spawns any worker, so the callback list is written once and read concurrently afterwards without a lock. Per-unit clear procs are unavoidable: @threadvar resolves to the registering thread's instance, so a generic pointer-based clear cannot release another thread's copy — each unit must expose a proc that names its own threadvar.

Two narrower alternatives were rejected. Ad-hoc per-unit clears wired directly into ShutdownThreadRuntime (mirroring the existing DisposableStack / Semver pattern) does not scale to ~64 units: it would couple Goccia.Threading to every builtin and value unit through its uses clause, require a ~64-line central call list kept in sync forever, and need ~64 separate finalization sections that the registry collapses into one. Centralizing the member-definition arrays in a single per-thread store was the largest change — it rewrites the member-registration path in all ~64 units, alters how members are built rather than only their lifetime (against the issue's lifetime-only scope), carries the most regression risk, and does not fit the two string memos.

The two memos already finalize on the main thread, so they only needed the worker path: ShutdownThreadRuntime calls their exported ClearRegExpInputMemo / ClearAsciiMemo explicitly, the same shape as the five caches already routed there (ImportMeta, Atomics, DisposableStack, Semver, TimeZone). This change also closes two pre-existing main-thread finalization gaps surfaced while auditing those caches: Goccia.ImportMeta had no finalization, and Goccia.Temporal.TimeZone's finalization did not call ClearTimeZoneCache. Migrating the five explicit caches (and the two memos) into the registry for one uniform mechanism (#893), and auditing object-reference threadvars (e.g. the symbol registry) (#892), are deferred to follow-ups. Two Pascal gates lock the behaviour in: Goccia.ThreadCleanupLeak.Test populates a thread's member-definition threadvars (by building a throwaway engine) and asserts the live heap drops by a meaningful amount when the registry is drained, and Goccia.Threading.Test separately asserts the registry runs every registered callback and that ShutdownThreadRuntime drains it exactly once per worker-thread exit. core-patterns.md. garbage-collector.md.