Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/adr/0083-migrate-cache-clears-into-cleanup-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Migrate explicit per-thread cache clears into the thread-cleanup registry

**Date:** 2026-06-28
**Area:** `engine`
**Issue:** [#893](https://github.com/frostney/GocciaScript/issues/893)

[ADR 0078](0078-thread-local-cleanup-registry.md) added `Goccia.ThreadCleanupRegistry` and routed the ~64 member-definition threadvars through it, but to avoid destabilising working teardown in [#891](https://github.com/frostney/GocciaScript/pull/891) it left seven pre-existing per-thread cache/memo clears as **explicit calls** inside `Goccia.Threading.ShutdownThreadRuntime`: `ClearImportMetaCache`, `ShutdownAtomicsWaitersForCurrentThread`, `ClearDisposableStackSlotMap`, `ClearSemverHosts`, `ClearTimeZoneCache`, `ClearRegExpInputMemo`, and `ClearAsciiMemo`. That left two cleanup idioms coexisting on the worker-exit path — explicit calls plus the registry drain — and coupled `Goccia.Threading`'s `uses` clause to seven cache-owning units.

## Decision

Each of the seven units registers its clear with `Goccia.ThreadCleanupRegistry` from its own `initialization` section, the same shape the 64 member-definition threadvars already use, and `ShutdownThreadRuntime` drops the seven explicit calls — keeping only the `RunThreadvarCleanups` drain followed by the ordered object-lifecycle shutdowns (MicrotaskQueue / CallStack / GarbageCollector), which stay explicit because their order matters. The drain still runs **before** those shutdowns, so a cache that touches the collector (`ClearImportMetaCache` unpins) is released while the GC is alive. `Goccia.Threading` no longer `uses` any of the seven units.

Units whose `finalization` existed only to clear a threadvar drop it and rely on the registry's own finalization for main-thread cleanup (`ImportMeta`, `DisposableStack`, `Semver`, `RegExp.VM`). Units whose finalization does more keep that part: `Temporal.TimeZone` retains the Windows ICU-lock teardown, and `Atomics` retains its all-threads shutdown (below).

Three cases needed care:

- **Atomics** is not a managed threadvar. `GAtomicsWaiters` is a shared, lock-guarded global; `ShutdownAtomicsWaitersForCurrentThread` removes only the calling thread's entries (keyed by owner thread id). It is registered for the worker path, but the unit's own `finalization` keeps the all-threads `ShutdownAtomicsWaiters` plus `DoneCriticalSection` for the main thread — preserving the per-thread/all-threads distinction. Because the registry finalizes *after* this unit (it is an earlier-initialised dependency), the registry's main-thread drain would otherwise call the per-thread proc *after* the lock is destroyed; a pre-lock `if not Assigned(GAtomicsWaiters)` guard makes that a safe no-op. The registry's callback contract is broadened to cover "a thread's own entries in a shared lock-guarded structure," not only managed threadvars.

- **TextSemantics** is generic shared infrastructure (`source/shared/`) with no engine dependency, used by the JSON, numeric-text and CLI-config tools as well as the engine. To keep it engine-free, its is-ASCII memo (#806) clear is registered from the engine's `Goccia.RegExp.VM` rather than self-registered — every engine binary links the regex VM, so the worker path is covered. Its **own main-thread `finalization` is retained**, because binaries that link `TextSemantics` but not `RegExp.VM` (the shared tools and `TextSemantics.Test`) populate the memo and would otherwise never release it.

- **ImportMeta** keeps `ClearImportMetaCache` exported: besides the registry, the engine calls it directly during its own teardown.

## Consequences

- Worker-thread cleanup is one mechanism (the registry drain); `Goccia.Threading` is decoupled from the cache-owning units. Adding a new per-thread cache no longer means editing `ShutdownThreadRuntime`.
- Main-thread cleanup is preserved on whichever path is correct per unit: the registry finalization for the self-registering engine units, and the unit's own finalization for `Atomics` (all-threads), the Windows ICU lock, and `TextSemantics` (binary-independent).
- A new gate in `Goccia.ThreadCleanupLeak.Test` pins each of the seven registrations via `IsThreadvarCleanupRegistered`, so a dropped `RegisterThreadvarCleanup` fails loudly instead of silently leaking. `Goccia.Threading.Test` still proves the drain fires once per worker exit. The full JavaScript suite passes in both modes and all Pascal unit tests pass.
- No behaviour or conformance change: this is the cleanliness/embeddability follow-up ADR 0078 deferred, not a runtime change.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,4 @@ Durable architecture and implementation decisions for GocciaScript. New ADRs use
- [0080 — FormatDouble first-hit precision scan](0080-formatdouble-first-hit-precision-scan.md)
- [0081 — Reject shared value caches as a runtime optimization](0081-reject-value-caches-for-allocation-reduction.md)
- [0082 — Unify embedded-data caches onto a lock-free publication primitive](0082-lazy-published-cache-primitive.md)
- [0083 — Migrate explicit per-thread cache clears into the thread-cleanup registry](0083-migrate-cache-clears-into-cleanup-registry.md)
21 changes: 16 additions & 5 deletions source/shared/TextSemantics.pas
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,13 @@ function NormalizeNewlinesToLF(const AText: string): string;
function NormalizeUTF8NewlinesToLF(const AText: UTF8String): UTF8String;
function StringListToLFText(const ALines: TStrings): string;

{ Release the per-thread is-ASCII memo. Called from this unit's finalization
(main thread) and from ShutdownThreadRuntime (worker threads), because FPC
does not auto-finalize managed threadvars at thread exit. }
{ Release the per-thread is-ASCII memo. FPC does not auto-finalize managed
threadvars at thread exit. This unit's own finalization clears the main
thread's slots on process shutdown; because the unit stays free of engine
dependencies, that path works in every binary that links it (including the
shared JSON/numeric/config tools that never link the engine). Worker threads
are covered separately: the engine's Goccia.RegExp.VM registers this proc with
Goccia.ThreadCleanupRegistry, whose drain releases each worker's slots on exit. }
procedure ClearAsciiMemo;

implementation
Expand Down Expand Up @@ -1262,8 +1266,10 @@ function StringListToLFText(const ALines: TStrings): string;
Result := Buffer.ToString;
end;

// Release the is-ASCII memo strings on shutdown; FPC does not finalize managed
// threadvars at thread exit (see the memo declaration above).
// Release the is-ASCII memo strings; FPC does not finalize managed threadvars at
// thread exit. Run from this unit's own finalization on the main thread, and —
// for worker threads — via Goccia.ThreadCleanupRegistry, where Goccia.RegExp.VM
// registers it (see the declaration comment above).
procedure ClearAsciiMemo;
begin
GAsciiMemoStr0 := '';
Expand All @@ -1279,6 +1285,11 @@ initialization
{$ENDIF}

finalization
// Main-thread cleanup, kept here because this generic unit has no engine
// dependency and so cannot self-register with Goccia.ThreadCleanupRegistry;
// it runs in every binary that links TextSemantics, including ones that never
// link the engine (Goccia.RegExp.VM registers the worker-thread path). FPC
// does not auto-finalize managed threadvars at thread exit.
ClearAsciiMemo;

end.
23 changes: 23 additions & 0 deletions source/units/Goccia.Builtins.Atomics.pas
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ implementation
Goccia.Error.Messages,
Goccia.GarbageCollector,
Goccia.InstructionLimit,
Goccia.ThreadCleanupRegistry,
Goccia.Timeout,
Goccia.Values.ArrayBufferValue,
Goccia.Values.BigIntValue,
Expand Down Expand Up @@ -908,6 +909,22 @@ procedure ShutdownAtomicsWaitersForCurrentThread;
Waiter: TAtomicsWaiter;
Waiters: TObjectList<TAtomicsWaiter>;
begin
// Registered with Goccia.ThreadCleanupRegistry, so the drain runs this on
// worker exit AND again at main-thread finalization — by which point this
// unit's own finalization has already run ShutdownAtomicsWaiters (nil-ing
// GAtomicsWaiters) and DoneCriticalSection'd GAtomicsLock. Bail out before
// touching the now-destroyed lock when the list is already gone.
//
// This pre-lock read of the shared GAtomicsWaiters is deliberately unlocked.
// On the main-thread finalization path the process is single-threaded, so it
// cannot race. On a worker-exit path the lock is alive and another worker may
// be creating GAtomicsWaiters (nil -> non-nil) under it, but the read still
// gives THIS thread a correct answer: a thread only has waiters to remove
// after the list was created (creation precedes any Add), so reading nil means
// this thread has nothing to clean. It is a single aligned-pointer load —
// atomic on supported targets, never torn.
if not Assigned(GAtomicsWaiters) then
Exit;
RemovedWaiters := TList<TAtomicsWaiter>.Create;
try
CurrentThreadId := GetCurrentThreadId;
Expand Down Expand Up @@ -1372,6 +1389,12 @@ function TGocciaAtomics.AtomicsXor(const AArgs: TGocciaArgumentsCollection;

initialization
InitCriticalSection(GAtomicsLock);
// Worker threads release their own Atomics waiters via the registry drain in
// ShutdownThreadRuntime. ShutdownAtomicsWaitersForCurrentThread removes only
// the calling thread's entries from the shared GAtomicsWaiters list; the
// main thread's all-threads teardown stays in this unit's finalization below
// (the per-thread/all-threads distinction is deliberate).
RegisterThreadvarCleanup(@ShutdownAtomicsWaitersForCurrentThread);

finalization
ShutdownAtomicsWaiters;
Expand Down
8 changes: 5 additions & 3 deletions source/units/Goccia.Builtins.DisposableStack.pas
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ implementation
Goccia.Error.Messages,
Goccia.Error.Suggestions,
Goccia.Scope.BindingMap,
Goccia.ThreadCleanupRegistry,
Goccia.Values.Error,
Goccia.Values.ErrorHelper,
Goccia.Values.FunctionBase,
Expand Down Expand Up @@ -565,8 +566,9 @@ procedure ClearDisposableStackSlotMap;
end;

initialization

finalization
ClearDisposableStackSlotMap;
// FPC does not auto-finalize managed threadvars at thread exit. The registry
// drain releases this thread's slot map on worker exit (ShutdownThreadRuntime)
// and on the main thread (Goccia.ThreadCleanupRegistry's finalization).
RegisterThreadvarCleanup(@ClearDisposableStackSlotMap);

end.
8 changes: 5 additions & 3 deletions source/units/Goccia.Builtins.Semver.pas
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ implementation
Goccia.Error.Suggestions,
Goccia.ObjectModel,
Goccia.Semver,
Goccia.ThreadCleanupRegistry,
Goccia.Values.ArrayValue,
Goccia.Values.ErrorHelper,
Goccia.Values.NativeFunction,
Expand Down Expand Up @@ -1489,8 +1490,9 @@ procedure ClearSemverHosts;

initialization
GSemverHosts := TSemverHostList.Create(True);

finalization
ClearSemverHosts;
// FPC does not auto-finalize managed threadvars at thread exit. The registry
// drain frees this thread's host list on worker exit (ShutdownThreadRuntime)
// and on the main thread (Goccia.ThreadCleanupRegistry's finalization).
RegisterThreadvarCleanup(@ClearSemverHosts);

end.
13 changes: 8 additions & 5 deletions source/units/Goccia.ImportMeta.pas
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ implementation
Goccia.Constants.PropertyNames,
Goccia.Error.Messages,
Goccia.Error.Suggestions,
Goccia.ThreadCleanupRegistry,
Goccia.URI,
Goccia.Values.ErrorHelper,
Goccia.Values.NativeFunction;
Expand Down Expand Up @@ -163,10 +164,12 @@ procedure ClearImportMetaCache;
end;
end;

finalization
// FPC does not auto-finalize managed threadvars at thread exit. Worker
// threads release this cache through ShutdownThreadRuntime; clear the main
// thread's copy on process shutdown too.
ClearImportMetaCache;
initialization
// FPC does not auto-finalize managed threadvars at thread exit. Register the
// cache clear so the registry drain releases this thread's copy on whichever
// thread tears down: a worker via ShutdownThreadRuntime, the main thread via
// Goccia.ThreadCleanupRegistry's finalization. (ClearImportMetaCache is also
// called directly from the engine's own teardown in Goccia.Engine.)
RegisterThreadvarCleanup(@ClearImportMetaCache);

end.
37 changes: 26 additions & 11 deletions source/units/Goccia.RegExp.VM.pas
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ function ExecuteRegExpVM(const AProgram: TRegExpProgram;
const AInput: string; const AStartIndex: Integer;
const ARequireStart: Boolean; out AResult: TRegExpVMResult): Boolean;

{ Release the per-thread input-decode memo. Called from this unit's
finalization (main thread) and from ShutdownThreadRuntime (worker threads),
because FPC does not auto-finalize managed threadvars at thread exit. }
{ Release the per-thread input-decode memo. Registered with
Goccia.ThreadCleanupRegistry from this unit's initialization, so the drain
releases it on worker exit (ShutdownThreadRuntime) and on the main thread (the
registry's finalization), because FPC does not auto-finalize managed
threadvars at thread exit. }
procedure ClearRegExpInputMemo;

implementation
Expand All @@ -33,6 +35,7 @@ implementation
TextSemantics,

Goccia.RegExp.UnicodeData,
Goccia.ThreadCleanupRegistry,
Goccia.Timeout;

const
Expand Down Expand Up @@ -1064,10 +1067,11 @@ function RunVM(const AProgram: TRegExpProgram; const AInput: TRegExpInput;
// so the hit check is O(1). Pure optimization — clearing it is always safe.
// Single-entry: a different subject replaces the retained pair via managed
// assignment (the prior string/array is released, so the cache never grows).
// FPC does not auto-finalize managed threadvars at thread exit, so the unit
// finalization below clears the main-thread memo on shutdown; a worker thread's
// last-held pair is a bounded residual, the same as the engine's other managed
// threadvars (e.g. each builtin's FStaticMembers).
// FPC does not auto-finalize managed threadvars at thread exit. ClearRegExpInputMemo
// is registered with Goccia.ThreadCleanupRegistry from this unit's initialization,
// so the registry drain releases each thread's pair on worker exit
// (ShutdownThreadRuntime) and the main thread's on process shutdown (the
// registry's finalization) — no thread retains a residual.
threadvar
GRegExpInputMemoStr: string;
GRegExpInputMemoUnits: array of Cardinal;
Expand Down Expand Up @@ -1149,8 +1153,10 @@ function ExecuteRegExpVM(const AProgram: TRegExpProgram;
end;
end;

// FPC does not auto-finalize managed threadvars at thread exit; release the
// main-thread memo on shutdown so its retained subject/units are not leaked.
// FPC does not auto-finalize managed threadvars at thread exit; registered in
// this unit's initialization so the registry drain releases this thread's memo
// on worker exit and at main-thread shutdown, keeping its retained subject/units
// from leaking.
procedure ClearRegExpInputMemo;
begin
GRegExpInputMemoStr := '';
Expand All @@ -1159,7 +1165,16 @@ procedure ClearRegExpInputMemo;
GRegExpInputMemoValid := False;
end;

finalization
ClearRegExpInputMemo;
initialization
// FPC does not auto-finalize managed threadvars at thread exit. Register this
// unit's regex-input memo, and also the is-ASCII memo owned by the shared
// TextSemantics unit: TextSemantics is generic infrastructure that stays free
// of engine dependencies, so its per-thread memo is registered here instead
// (every engine binary links the regex VM). See ClearAsciiMemo in
// source/shared/TextSemantics.pas. The registry drain releases both memos on
// worker exit (ShutdownThreadRuntime) and on the main thread
// (Goccia.ThreadCleanupRegistry's finalization).
RegisterThreadvarCleanup(@ClearRegExpInputMemo);
RegisterThreadvarCleanup(@ClearAsciiMemo);

end.
12 changes: 8 additions & 4 deletions source/units/Goccia.Temporal.TimeZone.pas
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ implementation
SysUtils,
Goccia.Temporal.Utils,
Goccia.Temporal.TimeZoneData,
Goccia.ThreadCleanupRegistry,
Goccia.Values.ErrorHelper,
TimeZoneInformationFile,
{$IFDEF UNIX}
Expand Down Expand Up @@ -1980,15 +1981,18 @@ initialization
CachedTimeZonePathCount := 0;
CachedTimeZoneCaseCount := 0;
CachedAvailablePrimaryTimeZoneIdentifiersLoaded := False;
// FPC does not auto-finalize managed threadvars at thread exit. The registry
// drain releases this thread's timezone cache on worker exit
// (ShutdownThreadRuntime) and on the main thread
// (Goccia.ThreadCleanupRegistry's finalization).
RegisterThreadvarCleanup(@ClearTimeZoneCache);
{$IFDEF MSWINDOWS}
InitCriticalSection(WindowsICUInitLock);
{$ENDIF}

finalization
// FPC does not auto-finalize managed threadvars at thread exit. Worker
// threads release the cache through ShutdownThreadRuntime; clear the main
// thread's copy on process shutdown too.
ClearTimeZoneCache;
// The timezone cache threadvars are released via Goccia.ThreadCleanupRegistry
// (registered above); only the Windows ICU init lock is torn down here.
{$IFDEF MSWINDOWS}
DoneCriticalSection(WindowsICUInitLock);
{$ENDIF}
Expand Down
30 changes: 30 additions & 0 deletions source/units/Goccia.ThreadCleanupLeak.Test.pas
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,15 @@
{$IFDEF UNIX}cthreads,{$ENDIF}
SysUtils,

TextSemantics,

Goccia.Builtins.Atomics,
Goccia.Builtins.DisposableStack,
Goccia.Builtins.Semver,
Goccia.GarbageCollector,
Goccia.ImportMeta,
Goccia.RegExp.VM,
Goccia.Temporal.TimeZone,
Goccia.ThreadCleanupRegistry,
Goccia.Threading.Init,
Goccia.Values.Primitives,
Expand All @@ -39,12 +47,15 @@ TLeakTests = class(TTestSuite)
procedure SetupTests; override;

procedure TestDrainReclaimsMemberDefinitionThreadvars;
procedure TestMigratedCacheCleanupsAreRegistered;
end;

procedure TLeakTests.SetupTests;
begin
Test('draining the registry reclaims per-thread member-definition threadvars',
TestDrainReclaimsMemberDefinitionThreadvars);
Test('each migrated per-thread cache/memo cleanup is registered with the registry',
TestMigratedCacheCleanupsAreRegistered);
end;

procedure TLeakTests.TestDrainReclaimsMemberDefinitionThreadvars;
Expand Down Expand Up @@ -74,6 +85,25 @@ procedure TLeakTests.TestDrainReclaimsMemberDefinitionThreadvars;
Expect<Boolean>((Populated - Drained) >= MIN_RECLAIMED_BYTES).ToBe(True);
end;

procedure TLeakTests.TestMigratedCacheCleanupsAreRegistered;
begin
// Issue #893: each per-thread cache/memo that ShutdownThreadRuntime used to
// clear by an explicit call must instead register its clear with
// Goccia.ThreadCleanupRegistry from its owning unit's initialization (those
// units are pulled in via this program's uses clause). If a unit drops its
// RegisterThreadvarCleanup call, that threadvar silently leaks again on every
// worker-thread exit; pinning each registration here makes that regress
// loudly. (TestDrainReclaimsMemberDefinitionThreadvars proves the drain then
// runs the registered cleanups and reclaims their heap.)
Expect<Boolean>(IsThreadvarCleanupRegistered(@ClearImportMetaCache)).ToBe(True);
Expect<Boolean>(IsThreadvarCleanupRegistered(@ShutdownAtomicsWaitersForCurrentThread)).ToBe(True);
Expect<Boolean>(IsThreadvarCleanupRegistered(@ClearDisposableStackSlotMap)).ToBe(True);
Expect<Boolean>(IsThreadvarCleanupRegistered(@ClearSemverHosts)).ToBe(True);
Expect<Boolean>(IsThreadvarCleanupRegistered(@ClearTimeZoneCache)).ToBe(True);
Expect<Boolean>(IsThreadvarCleanupRegistered(@ClearRegExpInputMemo)).ToBe(True);
Expect<Boolean>(IsThreadvarCleanupRegistered(@ClearAsciiMemo)).ToBe(True);
end;

begin
// EnsureSharedPrototypesInitialized builds singletons whose getters assert they
// were created on the main thread; pre-build them here first.
Expand Down
Loading
Loading