Skip to content
Open
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
14 changes: 14 additions & 0 deletions docs/adr/0077-thread-local-cleanup-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Thread-local cleanup registry for managed threadvars

**Date:** 2026-06-27
**Area:** `engine`
**Issue:** [#885](https://github.com/frostney/GocciaScript/issues/885)
**Pull Request:** [#891](https://github.com/frostney/GocciaScript/pull/891)

FPC does not auto-finalize managed `threadvar`s (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](../../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 `SetLength`s 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](../core-patterns.md). [garbage-collector.md](../garbage-collector.md).
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,4 @@ Durable architecture and implementation decisions for GocciaScript. New ADRs use
- [0074 — Deferred bytecode call-stack frames](0074-deferred-bytecode-call-stack-frames.md)
- [0075 — ShadowRealm full test262 conformance](0075-shadowrealm-conformance.md)
- [0076 — Same-runner benchmark comparison](0076-same-runner-benchmark-comparison.md)
- [0077 — Thread-local cleanup registry for managed threadvars](0077-thread-local-cleanup-registry.md)
5 changes: 5 additions & 0 deletions source/shared/TextSemantics.pas
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ 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. }
procedure ClearAsciiMemo;

implementation

uses
Expand Down
9 changes: 9 additions & 0 deletions source/units/Goccia.Builtins.CSV.pas
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ implementation
uses
Goccia.Constants.ErrorNames,
Goccia.Constants.PropertyNames,
Goccia.ThreadCleanupRegistry,
Goccia.Utils,
Goccia.Values.ArrayValue,
Goccia.Values.ErrorHelper,
Expand All @@ -58,6 +59,11 @@ implementation
threadvar
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FStaticMembers, 0);
end;

constructor TGocciaCSVBuiltin.Create(const AName: string;
const AScope: TGocciaScope; const AThrowError: TGocciaThrowErrorCallback);
var
Expand Down Expand Up @@ -423,4 +429,7 @@ function TGocciaCSVBuiltin.CSVStringify(
TGocciaCSVStringifier.Stringify(Data, Delimiter, Headers));
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
12 changes: 11 additions & 1 deletion source/units/Goccia.Builtins.Console.pas
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,18 @@ implementation
uses
SysUtils,

TimingUtils;
TimingUtils,

Goccia.ThreadCleanupRegistry;

threadvar
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FStaticMembers, 0);
end;

constructor TGocciaConsole.Create(const AName: string; const AScope: TGocciaScope; const AThrowError: TGocciaThrowErrorCallback);
var
Members: TGocciaMemberCollection;
Expand Down Expand Up @@ -391,4 +398,7 @@ function TGocciaConsole.ConsoleTable(const AArgs: TGocciaArgumentsCollection; co
Result := TGocciaUndefinedLiteralValue.UndefinedValue;
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
9 changes: 9 additions & 0 deletions source/units/Goccia.Builtins.GlobalArray.pas
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ implementation
Goccia.Error.Messages,
Goccia.Error.Suggestions,
Goccia.GarbageCollector,
Goccia.ThreadCleanupRegistry,
Goccia.Utils,
Goccia.Utils.Arrays,
Goccia.Values.ArrayValue,
Expand All @@ -56,6 +57,11 @@ implementation
threadvar
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FStaticMembers, 0);
end;

constructor TGocciaGlobalArray.Create(const AName: string; const AScope: TGocciaScope; const AThrowError: TGocciaThrowErrorCallback);
var
Members: TGocciaMemberCollection;
Expand Down Expand Up @@ -801,4 +807,7 @@ function TGocciaGlobalArray.ArrayOf(const AArgs: TGocciaArgumentsCollection; con
end;
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
9 changes: 9 additions & 0 deletions source/units/Goccia.Builtins.GlobalArrayBuffer.pas
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ implementation
Goccia.Constants.PropertyNames,
Goccia.Error.Messages,
Goccia.Error.Suggestions,
Goccia.ThreadCleanupRegistry,
Goccia.Values.DataViewValue,
Goccia.Values.ErrorHelper,
Goccia.Values.FunctionBase,
Expand All @@ -47,6 +48,11 @@ implementation
threadvar
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FStaticMembers, 0);
end;

const
NO_MAX_BYTE_LENGTH = -1;

Expand Down Expand Up @@ -159,4 +165,7 @@ function TGocciaGlobalArrayBuffer.ArrayBufferIsView(const AArgs: TGocciaArgument
Result := TGocciaBooleanLiteralValue.FalseValue;
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
9 changes: 9 additions & 0 deletions source/units/Goccia.Builtins.GlobalBigInt.pas
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ implementation
Goccia.Error.Messages,
Goccia.Error.Suggestions,
Goccia.ObjectModel.Types,
Goccia.ThreadCleanupRegistry,
Goccia.Values.BigIntValue,
Goccia.Values.ErrorHelper,
Goccia.Values.HoleValue,
Expand All @@ -53,6 +54,11 @@ implementation
threadvar
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FStaticMembers, 0);
end;

constructor TGocciaGlobalBigInt.Create(const AName: string; const AScope: TGocciaScope; const AThrowError: TGocciaThrowErrorCallback);
var
PrototypeInitializer: TGocciaBigIntValue;
Expand Down Expand Up @@ -277,4 +283,7 @@ function TGocciaGlobalBigInt.BigIntAsUintN(const AArgs: TGocciaArgumentsCollecti
Result := TGocciaBigIntValue.Create(BigIntVal.Value.AsUintN(Bits));
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
9 changes: 9 additions & 0 deletions source/units/Goccia.Builtins.GlobalFFI.pas
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ implementation
Goccia.Error.Messages,
Goccia.Error.Suggestions,
Goccia.FFI.DynamicLibrary,
Goccia.ThreadCleanupRegistry,
Goccia.Values.ErrorHelper,
Goccia.Values.FFILibrary,
Goccia.Values.FFIPointer,
Expand All @@ -39,6 +40,11 @@ implementation
threadvar
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FStaticMembers, 0);
end;

const
{$IFDEF DARWIN}
SHARED_LIBRARY_SUFFIX = '.dylib';
Expand Down Expand Up @@ -105,4 +111,7 @@ function TGocciaGlobalFFI.FFISuffixGetter(const AArgs: TGocciaArgumentsCollectio
Result := TGocciaStringLiteralValue.Create(SHARED_LIBRARY_SUFFIX);
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
9 changes: 9 additions & 0 deletions source/units/Goccia.Builtins.GlobalNumber.pas
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,18 @@ implementation

Goccia.Arguments.Validator,
Goccia.Constants.NumericLimits,
Goccia.ThreadCleanupRegistry,
Goccia.Values.NativeFunction,
Goccia.Values.ObjectPropertyDescriptor;

threadvar
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FStaticMembers, 0);
end;

constructor TGocciaGlobalNumber.Create(const AName: string; const AScope: TGocciaScope; const AThrowError: TGocciaThrowErrorCallback);
var
Members: TGocciaMemberCollection;
Expand Down Expand Up @@ -478,4 +484,7 @@ function TGocciaGlobalNumber.NumberIsSafeInteger(const AArgs: TGocciaArgumentsCo
Result := TGocciaBooleanLiteralValue.FalseValue;
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
9 changes: 9 additions & 0 deletions source/units/Goccia.Builtins.GlobalObject.pas
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ implementation
Goccia.Error.Messages,
Goccia.Error.Suggestions,
Goccia.GarbageCollector,
Goccia.ThreadCleanupRegistry,
Goccia.Utils,
Goccia.Values.ArrayBufferValue,
Goccia.Values.ArrayValue,
Expand All @@ -80,6 +81,11 @@ implementation
threadvar
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FStaticMembers, 0);
end;

type
TPendingDefineProperty = record
Name: string;
Expand Down Expand Up @@ -1389,4 +1395,7 @@ function TGocciaGlobalObject.ObjectGroupBy(const AArgs: TGocciaArgumentsCollecti
end;
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
9 changes: 9 additions & 0 deletions source/units/Goccia.Builtins.GlobalPromise.pas
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ implementation
Goccia.InstructionLimit,
Goccia.MicrotaskQueue,
Goccia.Realm,
Goccia.ThreadCleanupRegistry,
Goccia.Timeout,
Goccia.Utils,
Goccia.Values.Error,
Expand All @@ -76,6 +77,11 @@ implementation
threadvar
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FStaticMembers, 0);
end;

type
TPromiseCapability = record
Promise: TGocciaValue;
Expand Down Expand Up @@ -1925,4 +1931,7 @@ function TGocciaGlobalPromise.PromiseTry(const AArgs: TGocciaArgumentsCollection
Result := Capability.Promise;
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
9 changes: 9 additions & 0 deletions source/units/Goccia.Builtins.GlobalReflect.pas
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ implementation
Goccia.Constants.PropertyNames,
Goccia.Error.Messages,
Goccia.Error.Suggestions,
Goccia.ThreadCleanupRegistry,
Goccia.Utils,
Goccia.Values.ArrayValue,
Goccia.Values.Error,
Expand All @@ -56,6 +57,11 @@ implementation
threadvar
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FStaticMembers, 0);
end;

{ Helper: validate target is an object, throw TypeError if not }

procedure RequireObjectTarget(const ATarget: TGocciaValue; const AMethodName: string);
Expand Down Expand Up @@ -641,4 +647,7 @@ function TGocciaGlobalReflect.ReflectSetPrototypeOf(const AArgs: TGocciaArgument
Result := TGocciaBooleanLiteralValue.TrueValue;
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
10 changes: 10 additions & 0 deletions source/units/Goccia.Builtins.GlobalRegExp.pas
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ implementation
Goccia.GarbageCollector,
Goccia.RegExp.Engine,
Goccia.RegExp.Runtime,
Goccia.ThreadCleanupRegistry,
Goccia.Utils,
Goccia.Values.ArrayValue,
Goccia.Values.ErrorHelper,
Expand All @@ -102,6 +103,12 @@ implementation
FPrototypeMembers: TArray<TGocciaMemberDefinition>;
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FPrototypeMembers, 0);
SetLength(FStaticMembers, 0);
end;

type
TRegexReplacementCapture = record
Value: TGocciaValue;
Expand Down Expand Up @@ -1479,4 +1486,7 @@ function TGocciaGlobalRegExp.RegExpSymbolSplit(
end;
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
9 changes: 9 additions & 0 deletions source/units/Goccia.Builtins.GlobalString.pas
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ implementation
Goccia.Constants.PropertyNames,
Goccia.Error.Messages,
Goccia.Error.Suggestions,
Goccia.ThreadCleanupRegistry,
Goccia.Utils,
Goccia.Values.ArrayValue,
Goccia.Values.ErrorHelper,
Expand All @@ -42,6 +43,11 @@ implementation
threadvar
FStaticMembers: TArray<TGocciaMemberDefinition>;

procedure ClearThreadvarMembers;
begin
SetLength(FStaticMembers, 0);
end;

constructor TGocciaGlobalString.Create(const AName: string; const AScope: TGocciaScope; const AThrowError: TGocciaThrowErrorCallback);
var
Members: TGocciaMemberCollection;
Expand Down Expand Up @@ -201,4 +207,7 @@ function TGocciaGlobalString.StringRaw(const AArgs: TGocciaArgumentsCollection;
Result := TGocciaStringLiteralValue.Create(SB.ToString);
end;

initialization
RegisterThreadvarCleanup(@ClearThreadvarMembers);

end.
Loading
Loading